caixa_core/limits.rs
1//! Lunatic-style per-process resource limits — the typed slot of
2//! `caixa.lisp` that wasm-engine consumes at component instantiation.
3//!
4//! See `theory/INSPIRATIONS.md` §III.1 for the prior-art frame: every
5//! caixa Servico runs sandboxed by default; no "trust the author".
6//!
7//! ```lisp
8//! (defcaixa
9//! :nome "my-service"
10//! :versao "0.1.0"
11//! :kind Servico
12//! :limits ((:memory "64MiB") ;; max linear memory per instance
13//! (:fuel 1000000) ;; max wasm-instructions per request
14//! (:wall-clock "30s") ;; max wall-clock per request
15//! (:cpu "500m")) ;; soft cgroup CPU share (millicores)
16//! :servicos ("servicos/my-service.computeunit.yaml"))
17//! ```
18//!
19//! Authors omit the slot for "no limits" (today's behavior). When set,
20//! wasm-engine M2 wires:
21//!
22//! - [`LimitsSpec::memory`] → `wasmtime::StoreLimits::memory_size`
23//! - [`LimitsSpec::fuel`] → `Store::set_fuel` + per-tick refill
24//! - [`LimitsSpec::wall_clock`] → epoch deadline cancellation
25//! - [`LimitsSpec::cpu`] → cgroup-v2 hint propagated via the pod spec
26
27use std::time::Duration;
28
29use serde::{Deserialize, Deserializer, Serialize, Serializer};
30use thiserror::Error;
31
32/// Hard upper bound for `:limits :memory`, in bytes — the
33/// `wasm32-wasip2` linear-memory ceiling. The canonical caixa Servico
34/// compilation target ([`theory/CAIXA-SDLC.md` §V — *Substrate /
35/// Nix*][sdlc-v]) is `wasm32-wasip2`, whose linear memory is 32-bit-
36/// addressed at a 64 KiB page size; the in-spec maximum is
37/// `2^16 pages × 2^16 bytes/page = 2^32` bytes = 4 GiB exactly.
38/// A `:limits :memory` value above this bound is structurally
39/// unreachable under wasm32: wasmtime's `Store::limiter` cannot grow
40/// past the 32-bit address space, so an authored `"8GiB"` either
41/// silently saturates at the engine's effective cap or surfaces as a
42/// `memory.grow` trap at runtime, far from the source caixa.lisp.
43///
44/// Pairs with [`LimitsError::MemoryZero`] (the zero-floor gate added
45/// by the prior typed-shape lift on this axis) to bracket the valid
46/// `:memory` set top-to-bottom: every validated value lies in
47/// `1..=LIMITS_MEMORY_WASM32_MAX_BYTES` (inclusive on both ends).
48/// Renderers ([`crate::render::servico_m2_overlay`] and the M2.5
49/// `wasm-engine` instantiator the ABSORPTION-ROADMAP names as the
50/// downstream wiring) consume the typed value with no re-validation
51/// — the value-shape gate is the structural contract.
52///
53/// Lifted as a typed `pub const` (rather than an inline literal at
54/// the [`LimitsSpec::validate`] call site) so the bound has exactly
55/// one source of truth — a future axis reaching for the same value
56/// (a future `memory64`-target opt-in raising the cap to 2^64, a
57/// wasm-engine smoke test asserting the engine's effective limit
58/// matches the typed bound, the M4 `mesh.pleme.io/v1alpha1/Caixa`
59/// CR materializer's per-`:limits :memory` admission webhook)
60/// reads from one place. Same shape every other typed bound in this
61/// crate carries ([`crate::render::DNS_1123_LABEL_MAX_LEN`],
62/// [`crate::render::GATEWAY_API_HTTP_PATH_MAX_LEN`],
63/// [`crate::render::NATS_SUBJECT_MAX_LEN`]).
64///
65/// [sdlc-v]: https://github.com/pleme-io/theory/blob/main/CAIXA-SDLC.md
66pub const LIMITS_MEMORY_WASM32_MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
67
68/// Structural floor for `:limits :memory`, in bytes — the
69/// `wasm32-wasip2` linear-memory page size. The wasm spec defines
70/// linear memory in fixed 64 KiB pages (`2^16` bytes); every typed
71/// memory cap is consumed by `wasmtime::StoreLimits::memory_size` as a
72/// per-component byte ceiling against which the engine checks every
73/// `memory.grow` request. A cap below one page (`< 65536` bytes) is
74/// structurally a "no wasm linear memory allowed" cap — instantiation
75/// of any wasm component that declares `(memory 1)` (i.e. min=1 page,
76/// the canonical default for every cdylib-shaped wasm component cargo
77/// emits) fails immediately with `memory minimum size of 1 pages
78/// exceeds memory limits`; a min=0 component traps the first
79/// `memory.grow(1)` because the next-page allocation would cross the
80/// sub-page cap. Either way the typed value the wasm-engine consumes
81/// is operationally indistinguishable from [`LimitsError::MemoryZero`]
82/// (no memory at all), but the diagnostic surfaces at engine-load
83/// time rather than at caixa-build time, far from the source
84/// caixa.lisp.
85///
86/// Pairs with [`LIMITS_MEMORY_WASM32_MAX_BYTES`] (the 4 GiB upper
87/// cap added by the prior typed-shape lift on this axis) to bracket
88/// the valid `:memory` set top-to-bottom in *operational* units, not
89/// just byte units: every validated value lies in
90/// `LIMITS_MEMORY_WASM32_PAGE_BYTES..=LIMITS_MEMORY_WASM32_MAX_BYTES`
91/// inclusive on both ends — i.e. at least one wasm32 linear memory
92/// page can be allocated, and at most the wasm32 address-space
93/// ceiling fits.
94///
95/// Lifted as a typed `pub const` (rather than an inline literal at
96/// the [`LimitsSpec::validate`] call site) so the bound has exactly
97/// one source of truth — a future axis reaching for the same value
98/// (a future `memory64`-target opt-in raising the page size, the M4
99/// `mesh.pleme.io/v1alpha1/Caixa` CR materializer's per-`:limits
100/// :memory` admission webhook, a wasm-engine smoke test asserting
101/// every instantiated component can fit one page within its
102/// configured cap) reads from one place. Same single-source-of-truth
103/// shape every typed bound in this crate carries
104/// ([`LIMITS_MEMORY_WASM32_MAX_BYTES`],
105/// [`crate::render::DNS_1123_LABEL_MAX_LEN`]).
106pub const LIMITS_MEMORY_WASM32_PAGE_BYTES: u64 = 64 * 1024;
107
108/// Upper-bound ceiling on the `:limits :wall-clock` axis — every
109/// validated [`LimitsSpec::wall_clock`] past [`LimitsSpec::validate`]
110/// lies in `1ms..=LIMITS_WALL_CLOCK_MAX` (inclusive on both ends,
111/// integer-millisecond magnitudes by the canonical-form gate
112/// immediately preceding).
113///
114/// The typed field is `Option<Duration>` (the zero-floor arm
115/// [`LimitsError::WallClockZero`] already rejects `Duration::ZERO`, and
116/// the canonical-form arm [`LimitsError::WallClockNotCanonical`]
117/// already rejects sub-millisecond residue), so a programmatic struct
118/// literal (`LimitsSpec { wall_clock: Some(Duration::from_secs(86_400)),
119/// .. }` — 24h) and the equivalent author-surface form
120/// (`(:limits (:wall-clock "24h"))` — the codec emits `"<n>h"` for any
121/// integer-hour magnitude) both round-trip cleanly through serde — a
122/// structurally unbounded `Duration` ceiling. A `:wall-clock` value far
123/// above the per-process production band (Lunatic / Wasmtime documented
124/// per-call deadlines sit in the seconds-to-minutes range; Kubernetes
125/// activeDeadlineSeconds typical `≤ 3600s`; the longest per-request
126/// timeout any upstream HTTP runtime documents — Kubernetes
127/// ingress-nginx `proxy_read_timeout` — caps at the same 3600s) turns
128/// the typed per-process deadline into a nominal-only contract: the
129/// wasm-engine's epoch-deadline cancellation reaches for a `Duration`
130/// so long no realistic synchronous wasm call can hit it, the runaway-
131/// process invariant the MESH-COMPOSITION §V "no infinite blocking" CSE
132/// invariant pins at the per-Servico layer degenerates to a runtime,
133/// not build-time, contract. Pairs with the
134/// [`crate::POLICY_TIMEOUT_MAX`] cap on the sibling `:politicas :timeout`
135/// mesh-edge axis and the [`crate::POLICY_BREAKER_WINDOW_MAX`] cap on
136/// the sibling `:politicas :circuit-breaker :window` rolling-window
137/// axis — all three close the "structurally unbounded `Duration`
138/// ceiling on a typed slot" footgun the prior zero-floor-and-canonical-
139/// form-only checks left open.
140///
141/// The 1h (3600s = `3_600_000` ms) ceiling matches the largest unit
142/// the shared duration codec emits (`"<n>h"` for any integer-hour
143/// magnitude) — every value in the canonical authoring form's
144/// `<integer><unit>` grammar at or below this cap renders to a clean
145/// canonical string — and matches the two sibling typed-`Duration`
146/// caps already lifted to this surface
147/// ([`crate::POLICY_TIMEOUT_MAX`], [`crate::POLICY_BREAKER_WINDOW_MAX`]).
148/// The three typed-`Duration` axes — per-process `:limits :wall-clock`,
149/// per-edge `:politicas :timeout`, per-breaker `:politicas
150/// :circuit-breaker :window` — now share a single uniform top edge so
151/// the next typed-slot wiring (the wasm-engine M2.5 epoch-deadline
152/// cancellation hook, the future caixa-helm `pleme-computeunit` chart's
153/// `:limits` value mapping, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR
154/// materializer's per-`:limits :wall-clock` admission webhook) reaches
155/// for any of the three knowing the value is in `1ms..=1h` without
156/// re-validating at the renderer layer. The cap sits above the
157/// documented per-request playbook band (Envoy / Istio / Linkerd
158/// production `≤ 60s`, AWS App Mesh / ingress-nginx typical `≤ 300s`,
159/// Kubernetes activeDeadlineSeconds typical `≤ 3600s`) and below the
160/// clearly-pathological "effectively no deadline" floor (`24h`, `7d`,
161/// `Duration::MAX`): a value the author can plausibly want for a
162/// long-running synchronous workflow, but a hard wall above which the
163/// per-process deadline is structurally a non-deadline.
164///
165/// Lifted as a typed `pub const` so the bound has exactly one source
166/// of truth — the wasm-engine M2.5 epoch-deadline wiring, a wasm-engine
167/// smoke test asserting the engine's epoch interrupt fires within the
168/// typed bound, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR materializer's
169/// per-`:limits :wall-clock` admission webhook all read from one place.
170/// Same shape every other typed upper bound in this crate carries
171/// ([`LIMITS_MEMORY_WASM32_MAX_BYTES`], [`crate::POLICY_TIMEOUT_MAX`],
172/// [`crate::POLICY_BREAKER_WINDOW_MAX`],
173/// [`crate::render::DNS_1123_LABEL_MAX_LEN`],
174/// [`crate::render::NATS_SUBJECT_MAX_LEN`]).
175pub const LIMITS_WALL_CLOCK_MAX: Duration = Duration::from_secs(3600);
176
177/// Upper-bound ceiling on the `:limits :cpu` axis, in Kubernetes
178/// millicores — every validated [`LimitsSpec::cpu`] past
179/// [`LimitsSpec::validate`] lies in `1..=LIMITS_CPU_MILLICORES_MAX`
180/// (inclusive on both ends).
181///
182/// The typed field is `Option<u32>` (the zero-floor arm
183/// [`LimitsError::CpuZero`] already rejects `Some(0)` — a zero cgroup
184/// share starves the process), so a programmatic struct literal
185/// (`LimitsSpec { cpu: Some(u32::MAX), .. }` — ≈ 4.3 million cores)
186/// and the equivalent author-surface form (`(:limits (:cpu
187/// "1000000m"))` — the millicore codec parses any `u32`-shaped
188/// magnitude) both round-trip cleanly through serde — a structurally
189/// unbounded `u32` ceiling. The runtime substrate consuming the value
190/// ([`crate::render::servico_m2_overlay`]'s `pleme-computeunit.limits.cpu`
191/// projection, the M2.5 `wasm-engine` instantiator the
192/// `ABSORPTION-ROADMAP` names as the downstream wiring, the future
193/// M4 `mesh.pleme.io/v1alpha1/Caixa` CR materializer's admission
194/// webhook) lands the value verbatim as the K8s pod's
195/// `resources.requests.cpu`. A value far above the largest commodity
196/// node's vCPU count turns the typed slot into an unschedulable hint:
197/// the Kubernetes scheduler refuses to bind the pod to any node
198/// (insufficient `cpu` available), the Servico sits `Pending`
199/// indefinitely, and the per-process CSE invariant (every typed
200/// `:cpu` reaches a node) is a runtime, not build-time, contract —
201/// the canonical declared-but-unschedulable footgun the sibling
202/// `:limits :memory` wasm32-cap arm closes on its peer "cannot be
203/// honored" shape.
204///
205/// The `128_000` (128 cores) ceiling matches the largest commercially
206/// common non-metal cloud Kubernetes node vCPU count (AWS m7i.32xlarge
207/// / c7i.32xlarge = 128 vCPU; Azure HBv3-128rs = 128 vCPU; GCP
208/// c3-standard-128 = 128 vCPU — every major managed-Kubernetes provider
209/// tops out at 128 vCPU on its general-purpose non-metal SKUs) and sits
210/// two orders of magnitude above every realistic per-Servico
211/// production-playbook band (the canonical caixa Servico runs in the
212/// 100m–2000m band; the in-tree
213/// `limits_slot_propagates_into_values_block` smoke test pins
214/// `cpu: Some(500)` = 500m as the load-bearing example, peer to the
215/// `caixa-flux` projector's identical 500m default). A value above this
216/// cap is structurally unschedulable on any commercial managed
217/// Kubernetes node pool: GKE Standard / EKS managed / AKS default
218/// node-group SKU ladders cap at 128 vCPU per node for general-purpose
219/// instance families, so a `:cpu` request above `128_000m` cannot bind to
220/// any node the operator can provision through the standard
221/// cloud-provider control plane. The wasm32-wasip2 single-threaded
222/// execution model the canonical caixa Servico targets
223/// ([`theory/CAIXA-SDLC.md` §V][sdlc-v]) reinforces the structural
224/// argument: a single wasm component cannot saturate more than one
225/// core, so even the Lunatic-style supervised-multi-process host
226/// (`theory/INSPIRATIONS.md` §III.1) — which fans wasm processes across
227/// the host runtime's Tokio thread pool — bounds its useful CPU request
228/// to the host node's vCPU count, never higher.
229///
230/// Lifted as a typed `pub const` (rather than an inline literal at the
231/// [`LimitsSpec::validate`] call site) so the bound has exactly one
232/// source of truth — the future M4
233/// `mesh.pleme.io/v1alpha1/Caixa` CR materializer's per-`:limits :cpu`
234/// admission webhook, the caixa-helm `pleme-computeunit` chart's
235/// resource-request mapping, the M2.5 `wasm-engine` host-runtime
236/// thread-pool sizing hint all read from one place. Same shape every
237/// other typed upper bound in this crate carries
238/// ([`LIMITS_MEMORY_WASM32_MAX_BYTES`], [`LIMITS_WALL_CLOCK_MAX`],
239/// [`crate::POLICY_TIMEOUT_MAX`], [`crate::POLICY_BREAKER_WINDOW_MAX`],
240/// [`crate::POLICY_RATE_LIMIT_MAX`],
241/// [`crate::render::DNS_1123_LABEL_MAX_LEN`]).
242///
243/// [sdlc-v]: https://github.com/pleme-io/theory/blob/main/CAIXA-SDLC.md
244pub const LIMITS_CPU_MILLICORES_MAX: u32 = 128_000;
245
246/// Upper-bound ceiling on the `:limits :fuel` axis, in wasm
247/// instructions per outermost call — every validated
248/// [`LimitsSpec::fuel`] past [`LimitsSpec::validate`] lies in
249/// `1..=LIMITS_FUEL_MAX` (inclusive on both ends).
250///
251/// The typed field is `Option<u64>` (the zero-floor arm
252/// [`LimitsError::FuelZero`] already rejects `Some(0)` — wasmtime
253/// traps the first instruction at `fuel=0`), so a programmatic
254/// struct literal (`LimitsSpec { fuel: Some(u64::MAX), .. }` —
255/// ≈ 1.8 × 10¹⁹ instructions) and the equivalent author-surface
256/// form (`(:limits (:fuel 18446744073709551615))`) both
257/// round-trip cleanly through serde — a structurally unbounded
258/// `u64` ceiling. The runtime substrate consuming the value
259/// ([`crate::render::servico_m2_overlay`]'s
260/// `pleme-computeunit.limits.fuel` projection, the M2.5
261/// `wasm-engine` `Store::set_fuel` call the
262/// `ABSORPTION-ROADMAP` names as the downstream wiring, the
263/// future M4 `mesh.pleme.io/v1alpha1/Caixa` CR materializer's
264/// admission webhook) lands the value verbatim as the
265/// wasmtime store's per-call fuel budget. A value far above any
266/// reachable wasm execution count turns the typed slot into a
267/// no-op budget: the sibling [`LIMITS_WALL_CLOCK_MAX`] (1h)
268/// cap fires before the fuel counter ever drains, the per-call
269/// fuel-tracking contract degenerates to "rely on `:wall-clock`
270/// instead" enforcement, and the per-process CSE invariant
271/// (every typed `:fuel` is a meaningful budget the wasm-engine
272/// can actually consume) is a runtime, not build-time, contract
273/// on every above-cap input — the canonical declared-but-no-op
274/// footgun the sibling `:wall-clock` / `:cpu` / `:memory` cap
275/// arms close on the peer "cannot be honored" /
276/// "unschedulable hint" / "no-op budget" shapes, and the peer
277/// `:politicas :rate-limit` / `:politicas :timeout` /
278/// `:politicas :circuit-breaker :window` /
279/// `:supervisor :max-restarts` cap arms close on every other
280/// `Option<numeric>` axis on the typed Caixa surface.
281///
282/// The `1_000_000_000_000` (10¹² = 1 trillion wasm instructions)
283/// ceiling matches the operational envelope the sibling
284/// [`LIMITS_WALL_CLOCK_MAX`] cap pins: at wasmtime's documented
285/// fuel-tracked execution rate (~10⁸–10⁹ fuel-units per second
286/// on modern x86_64 / aarch64 hosts running wasmtime through
287/// Cranelift — the substrate's wasm32-wasip2 default backend per
288/// the `caixa-feira` runner), the largest realistic per-call
289/// fuel budget reachable within `LIMITS_WALL_CLOCK_MAX` (1h)
290/// sits at ~3.6 × 10¹¹–3.6 × 10¹² fuel-units. The 10¹² cap is
291/// the round-number ceiling above this operational envelope,
292/// sits six orders of magnitude above the canonical fixture
293/// (the in-tree `Caixa::template` documentation and
294/// `caixa-feira` examples carry `:fuel 1_000_000` = 10⁶,
295/// peer to wasmtime's official `Store::set_fuel(1_000_000)`
296/// example in the `wasmtime` book), and surfaces every
297/// paste-from-binary / overflow / u64-magnitude-typo footgun
298/// (`u64::MAX`, `0xFFFF_FFFF_FFFF_FFFF`, large hex literals
299/// confused for instruction-count budgets) at validate time.
300/// A value above this cap is operationally a no-op fuel
301/// counter: the wall-clock deadline ([`LIMITS_WALL_CLOCK_MAX`]
302/// = 3600s × ~10⁹ fuel/sec ≈ 3.6 × 10¹² instructions reachable)
303/// fires before the fuel counter could ever be drained,
304/// so the typed `:fuel` slot becomes a no-op budget far from
305/// the source caixa.lisp. The wasm32-wasip2 single-threaded
306/// execution model the canonical caixa Servico targets
307/// ([`theory/CAIXA-SDLC.md` §V][sdlc-v]) reinforces the
308/// structural argument: a single wasm component cannot
309/// out-execute its host's CPU clock, so even the Lunatic-style
310/// supervised-multi-process host (`theory/INSPIRATIONS.md`
311/// §III.1) bounds its useful fuel-per-call budget to a
312/// per-clock-tick magnitude, never higher.
313///
314/// Lifted as a typed `pub const` (rather than an inline literal
315/// at the [`LimitsSpec::validate`] call site) so the bound has
316/// exactly one source of truth — the future M4
317/// `mesh.pleme.io/v1alpha1/Caixa` CR materializer's per-`:limits
318/// :fuel` admission webhook, the caixa-helm `pleme-computeunit`
319/// chart's fuel-budget mapping, the M2.5 `wasm-engine` host-
320/// runtime `Store::set_fuel` propagation all read from one
321/// place. Same shape every other typed upper bound in this
322/// crate carries ([`LIMITS_MEMORY_WASM32_MAX_BYTES`],
323/// [`LIMITS_WALL_CLOCK_MAX`], [`LIMITS_CPU_MILLICORES_MAX`],
324/// [`crate::POLICY_TIMEOUT_MAX`],
325/// [`crate::POLICY_BREAKER_WINDOW_MAX`],
326/// [`crate::POLICY_RATE_LIMIT_MAX`],
327/// [`crate::SUPERVISOR_MAX_RESTARTS_MAX`],
328/// [`crate::render::DNS_1123_LABEL_MAX_LEN`]).
329///
330/// [sdlc-v]: https://github.com/pleme-io/theory/blob/main/CAIXA-SDLC.md
331pub const LIMITS_FUEL_MAX: u64 = 1_000_000_000_000;
332
333/// Per-process limits. All fields optional — `None` = unbounded for that axis.
334#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
335#[serde(rename_all = "camelCase")]
336pub struct LimitsSpec {
337 /// Max linear memory in bytes. Authored as a byte-size string
338 /// (`"64MiB"`, `"1GiB"`, `"512KB"`). Round-trips back to the same
339 /// canonical string on serialize.
340 #[serde(
341 default,
342 skip_serializing_if = "Option::is_none",
343 serialize_with = "ser_byte_size",
344 deserialize_with = "de_byte_size"
345 )]
346 pub memory: Option<u64>,
347
348 /// Max wasm instructions per outermost call (`wasmtime` fuel).
349 /// Plain integer; `None` = unbounded.
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub fuel: Option<u64>,
352
353 /// Wall-clock cap per outermost call. Authored as a duration
354 /// string (`"30s"`, `"500ms"`, `"2m"`).
355 #[serde(
356 default,
357 skip_serializing_if = "Option::is_none",
358 serialize_with = "ser_duration",
359 deserialize_with = "de_duration"
360 )]
361 pub wall_clock: Option<Duration>,
362
363 /// Soft CPU share. Authored as a Kubernetes-style millicore string
364 /// (`"500m"` for half a core, `"2"` or `"2000m"` for two cores).
365 /// Stored as millicores (u32).
366 #[serde(
367 default,
368 skip_serializing_if = "Option::is_none",
369 serialize_with = "ser_millicores",
370 deserialize_with = "de_millicores"
371 )]
372 pub cpu: Option<u32>,
373}
374
375impl LimitsSpec {
376 /// True when no axis is bounded.
377 #[must_use]
378 pub const fn is_empty(&self) -> bool {
379 self.memory().is_none()
380 && self.fuel().is_none()
381 && self.wall_clock().is_none()
382 && self.cpu().is_none()
383 }
384
385 /// Substrate-canonical per-`:limits` `:memory` Lunatic-per-process
386 /// wasm32-linear-memory byte-cap scalar accessor every consumer of
387 /// the Servico's `wasmtime::StoreLimits::memory_size` propagation
388 /// keys off — returns the author-declared `:limits :memory` typed
389 /// byte-cap verbatim as an `Option<u64>`, copied out of the typed
390 /// slot's own `Option<u64>` storage (`Option<u64>` is `Copy`, so
391 /// the accessor returns by value; no borrow of `&self` past the
392 /// call). `None` when the slot is absent (the "no memory cap
393 /// declared — engine-default applies, today the pre-M2 unbounded-
394 /// linear-memory shape" arm the module-level docstring names on
395 /// [`LimitsSpec::memory`] itself — [`LimitsSpec::is_empty`]'s
396 /// `memory().is_none()` arm reads this predicate too, so an
397 /// authored-but-unset `:limits (:memory ())` round-trips to a
398 /// `servico_m2_overlay` emission structurally identical to one
399 /// that omits the slot entirely).
400 ///
401 /// The `:limits :memory` slot carries the "per-process wasm32
402 /// linear-memory byte-cap" Lunatic-shaped sandboxing contract
403 /// (`theory/INSPIRATIONS.md` §III.1) — the typed slot's
404 /// `Option<u64>` accept-set (zero-floor rejected through
405 /// [`LimitsError::MemoryZero`], wasm32-page-floor rejected through
406 /// [`LimitsError::MemoryBelowWasm32Page`], upper-bounded by
407 /// [`LIMITS_MEMORY_WASM32_MAX_BYTES`], authored as a byte-size
408 /// string that round-trips back to the canonical form through
409 /// [`ser_byte_size`] / [`de_byte_size`]) maps onto the wasmtime
410 /// `Store::limiter`-side `memory_size` projection the wasm-engine
411 /// M2 wires and, via [`crate::render::servico_m2_overlay`], onto
412 /// the `pleme-computeunit` Helm-library-chart values sub-block's
413 /// `limits.memory` key that lands as the ComputeUnit CR's
414 /// `spec.limits.memory` field.
415 ///
416 /// Prior to this lift the `.memory` field was accessed inline at
417 /// four sites inside `impl LimitsSpec` — [`LimitsSpec::is_empty`]'s
418 /// `self.memory.is_none()` arm and three [`LimitsSpec::validate`]
419 /// arms (the numeric zero-floor arm at line 397, the wasm32-page
420 /// structural floor arm at line 427, and the wasm32 upper-cap
421 /// arm at line 449) — four open-coded field-accesses that
422 /// expressed no compile-time link back to the typed slot. A
423 /// future extension of the `:limits :memory` axis to a richer
424 /// author surface — a per-instance memory-declaration override
425 /// the operator pins through a future ComputeUnit CR-side
426 /// `spec.limits.memory` overlay, a split of the single `u64`
427 /// byte-cap into a `{min, max}` pair once wasm32's `(memory M N)`
428 /// two-arg form promotes past its current single-`max` typed
429 /// bound, a wasm64 promotion once the wasm-engine grows past the
430 /// wasm32 4 GiB structural ceiling — would have had to be
431 /// threaded through every open-coded copy in lockstep or the
432 /// emptiness predicate and the validate call would silently
433 /// disagree on which cap a given [`LimitsSpec`] resolves to.
434 /// Lifting the resolution to a typed method on the substrate
435 /// primitive means every downstream consumer of the Servico's
436 /// per-`:limits` byte-cap surface reaches for exactly one typed
437 /// dispatch — the resolver's accept-set migrates as a unit on any
438 /// future axis addition.
439 ///
440 /// First `Option<Copy-T>`-return accessor on the M2 slot family
441 /// (peer of the sibling per-`:politicas` [`crate::MeshPolicy::mtls_required`]
442 /// c0110f1 `Option<bool>` accessor, per-`:politicas`
443 /// [`crate::MeshPolicy::retries`] bdfb399 `Option<u32>` accessor,
444 /// and per-`:politicas` [`crate::MeshPolicy::timeout`] 7073d0f
445 /// `Option<Duration>` accessor on the M3 mesh-slot family — same
446 /// "one typed dispatch on the substrate primitive, thin
447 /// projections at each consumer" discipline extended onto the
448 /// peer per-`:limits` typed-`u64` optional-scalar axis; opens the
449 /// "optional per-slot Copy-T scalar" projection pattern the
450 /// sibling per-`:limits` `:fuel` (Option<u64>) / `:wall-clock`
451 /// (Option<Duration>) / `:cpu` (Option<u32>) future lifts fold
452 /// on). Named `memory()` to match the storage field's name; the
453 /// accessor's identity maps onto the canonical Lunatic-shaped
454 /// `theory/INSPIRATIONS.md` §III.1 vocabulary the slot's docstring
455 /// already carries.
456 #[must_use]
457 pub const fn memory(&self) -> Option<u64> {
458 self.memory
459 }
460
461 /// Substrate-canonical per-`:limits` `:fuel` wasmtime-per-call
462 /// wasm-instruction budget scalar accessor every consumer of the
463 /// Servico's `wasmtime::Store::set_fuel` propagation keys off —
464 /// returns the author-declared `:limits :fuel` typed
465 /// wasm-instruction budget verbatim as an `Option<u64>`, copied
466 /// out of the typed slot's own `Option<u64>` storage
467 /// (`Option<u64>` is `Copy`, so the accessor returns by value; no
468 /// borrow of `&self` past the call). `None` when the slot is
469 /// absent (the "no fuel budget declared — engine-default applies,
470 /// today the pre-M2 unbounded-fuel-counter shape" arm the
471 /// module-level docstring names on [`LimitsSpec::fuel`] itself —
472 /// [`LimitsSpec::is_empty`]'s `fuel().is_none()` arm reads this
473 /// predicate too, so an authored-but-unset `:limits (:fuel ())`
474 /// round-trips to a `servico_m2_overlay` emission structurally
475 /// identical to one that omits the slot entirely).
476 ///
477 /// The `:limits :fuel` slot carries the "per-call wasm-instruction
478 /// budget" wasmtime-shaped sandboxing contract
479 /// (`theory/INSPIRATIONS.md` §III.1 — Lunatic's supervised
480 /// wasm-`Store`-per-process fuel accounting, translated onto
481 /// pleme-io's typed `:limits` slot) — the typed slot's
482 /// `Option<u64>` accept-set (zero-floor rejected through
483 /// [`LimitsError::FuelZero`] because wasmtime traps the first
484 /// instruction at `fuel=0`, upper-bounded by [`LIMITS_FUEL_MAX`]
485 /// (10¹² wasm instructions — the operationally-reachable
486 /// per-call budget within the sibling [`LIMITS_WALL_CLOCK_MAX`]
487 /// 1h ceiling)) maps onto the wasmtime `Store::set_fuel` call
488 /// the M2.5 wasm-engine wires per outermost call and, via
489 /// [`crate::render::servico_m2_overlay`], onto the
490 /// `pleme-computeunit` Helm-library-chart values sub-block's
491 /// `limits.fuel` key that lands as the `ComputeUnit` CR's
492 /// `spec.limits.fuel` field.
493 ///
494 /// Prior to this lift the `.fuel` field was accessed inline at
495 /// two sites inside `impl LimitsSpec` — [`LimitsSpec::is_empty`]'s
496 /// `self.fuel.is_none()` arm and [`LimitsSpec::validate`]'s
497 /// `if let Some(f) = self.fuel { … }` zero-floor + upper-cap
498 /// bracket arm — two open-coded field-accesses that expressed no
499 /// compile-time link back to the typed slot. A future extension
500 /// of the `:limits :fuel` axis to a richer author surface — a
501 /// per-instance `ComputeUnit` CR-side `spec.limits.fuel` overlay
502 /// the operator pins per-cluster, a wasm-instruction-count →
503 /// wasmtime-fuel-unit rescale once the fuel-tracking backend
504 /// switches from Cranelift's implicit 1:1 count to a
505 /// per-opcode-weighted budget, a split of the single
506 /// per-outermost-call `u64` budget into a `{per_call, per_second}`
507 /// pair once the wasm-engine grows a sustained-throughput cap —
508 /// would have had to be threaded through every open-coded copy in
509 /// lockstep or the emptiness predicate and the validate call
510 /// would silently disagree on which fuel budget a given
511 /// [`LimitsSpec`] resolves to. Lifting the resolution to a typed
512 /// method on the substrate primitive means every downstream
513 /// consumer of the Servico's per-`:limits` fuel-budget surface
514 /// reaches for exactly one typed dispatch — the resolver's
515 /// accept-set migrates as a unit on any future axis addition.
516 ///
517 /// Second `Option<Copy-T>`-return accessor on the M2 slot family
518 /// (peer of the sibling per-`:limits` [`LimitsSpec::memory`]
519 /// (620c067) `Option<u64>` accessor — same typed-`u64`
520 /// optional-scalar shape, extended to the peer per-`:limits`
521 /// wasm-instruction-budget axis; sibling to
522 /// [`crate::MeshPolicy::mtls_required`] (c0110f1) / [`crate::MeshPolicy::retries`]
523 /// (bdfb399) / [`crate::MeshPolicy::timeout`] (7073d0f) on the
524 /// closed M3 mesh-slot `Option<Copy-T>` accessor family). The
525 /// pair `(memory(), fuel())` jointly projects the two `Option<u64>`
526 /// axes every M2 `:limits` consumer that fans on
527 /// wasm-linear-memory-cap + wasm-fuel-budget keys off. Two of the
528 /// four `:limits` axes now route through a typed dispatch on the
529 /// substrate primitive; the two remaining (`wall_clock:
530 /// Option<Duration>`, `cpu: Option<u32>`) fold on the same
531 /// one-line accessor + is_empty-arm-route + validate-arm-route +
532 /// three-test pattern. Named `fuel()` to match the storage field's
533 /// name; the accessor's identity maps onto the canonical
534 /// wasmtime-`Store::set_fuel`-shaped vocabulary the slot's
535 /// docstring already carries.
536 #[must_use]
537 pub const fn fuel(&self) -> Option<u64> {
538 self.fuel
539 }
540
541 /// Substrate-canonical per-`:limits` `:wall-clock` wasmtime-per-call
542 /// wall-clock deadline scalar accessor every consumer of the
543 /// Servico's `wasmtime::Store::epoch_deadline_*` / `wasi:clocks`
544 /// propagation keys off — returns the author-declared `:limits
545 /// :wall-clock` typed `Duration` verbatim as an `Option<Duration>`,
546 /// copied out of the typed slot's own `Option<Duration>` storage
547 /// (`Duration` is `Copy`, so `Option<Duration>` is `Copy` and the
548 /// accessor returns by value; no borrow of `&self` past the call).
549 /// `None` when the slot is absent (the "no wall-clock deadline
550 /// declared — engine-default applies, today the pre-M2
551 /// unbounded-wall-clock shape" arm the module-level docstring names
552 /// on [`LimitsSpec::wall_clock`] itself — [`LimitsSpec::is_empty`]'s
553 /// `wall_clock().is_none()` arm reads this predicate too, so an
554 /// authored-but-unset `:limits (:wall-clock ())` round-trips to a
555 /// `servico_m2_overlay` emission structurally identical to one that
556 /// omits the slot entirely).
557 ///
558 /// The `:limits :wall-clock` slot carries the "per-outermost-call
559 /// wall-clock deadline" wasmtime-shaped sandboxing contract
560 /// (`theory/INSPIRATIONS.md` §III.1 — Lunatic's supervised
561 /// wasm-`Store`-per-process epoch-deadline accounting, translated
562 /// onto pleme-io's typed `:limits` slot) — the typed slot's
563 /// `Option<Duration>` accept-set (zero-floor rejected through
564 /// [`LimitsError::WallClockZero`] because a zero deadline traps the
565 /// first instruction; integer-millisecond granularity enforced
566 /// through [`LimitsError::WallClockNotCanonical`] because the
567 /// duration codec's canonical form emits `"1500ms"` not `"1.5s"`
568 /// and the operator's wall-clock scheduler quantizes at
569 /// milliseconds; upper-bounded by [`LIMITS_WALL_CLOCK_MAX`] (1h —
570 /// the coarsest per-call deadline any operationally-reachable
571 /// Servico can honor without spanning multiple scheduler epochs))
572 /// maps onto the wasmtime `Store::epoch_deadline_*` call the M2.5
573 /// wasm-engine wires per outermost call and, via
574 /// [`crate::render::servico_m2_overlay`], onto the
575 /// `pleme-computeunit` Helm-library-chart values sub-block's
576 /// `limits.wallClock` key that lands as the `ComputeUnit` CR's
577 /// `spec.limits.wallClock` field.
578 ///
579 /// Prior to this lift the `.wall_clock` field was accessed inline at
580 /// two sites inside `impl LimitsSpec` — [`LimitsSpec::is_empty`]'s
581 /// `self.wall_clock.is_none()` arm and [`LimitsSpec::validate`]'s
582 /// `if let Some(w) = self.wall_clock { … }` zero-floor +
583 /// canonical-form + upper-cap bracket arm — two open-coded
584 /// field-accesses that expressed no compile-time link back to the
585 /// typed slot. A future extension of the `:limits :wall-clock` axis
586 /// to a richer author surface — a per-instance `ComputeUnit`
587 /// CR-side `spec.limits.wallClock` overlay the operator pins
588 /// per-cluster, a wall-clock-vs-monotonic-clock discriminator once
589 /// the wasm-engine grows a `:limits (:wall-clock (:kind monotonic
590 /// …))` axis, a split of the single per-outermost-call `Duration`
591 /// budget into a `{deadline, warn_at}` pair once the wasm-engine
592 /// grows a soft-deadline warning surface — would have had to be
593 /// threaded through every open-coded copy in lockstep or the
594 /// emptiness predicate and the validate call would silently
595 /// disagree on which deadline a given [`LimitsSpec`] resolves to.
596 /// Lifting the resolution to a typed method on the substrate
597 /// primitive means every downstream consumer of the Servico's
598 /// per-`:limits` wall-clock-deadline surface reaches for exactly
599 /// one typed dispatch — the resolver's accept-set migrates as a
600 /// unit on any future axis addition.
601 ///
602 /// Third `Option<Copy-T>`-return accessor on the M2 slot family
603 /// (peer of the sibling per-`:limits` [`LimitsSpec::memory`]
604 /// (620c067) `Option<u64>` accessor and per-`:limits`
605 /// [`LimitsSpec::fuel`] (795dee7) `Option<u64>` accessor — same
606 /// typed-optional-scalar shape extended to the peer per-`:limits`
607 /// wall-clock-deadline axis; sibling to [`crate::MeshPolicy::timeout`]
608 /// (7073d0f) on the closed M3 mesh-slot `Option<Duration>` accessor
609 /// axis — same typed-`Duration` shape extended from the M3
610 /// per-call-timeout to the M2 per-outermost-call deadline). The
611 /// triple `(memory(), fuel(), wall_clock())` jointly projects three
612 /// of the four `Option<Copy-T>` axes every M2 `:limits` consumer
613 /// that fans on wasm-linear-memory-cap + wasm-fuel-budget +
614 /// wall-clock-deadline keys off. Three of the four `:limits` axes
615 /// now route through a typed dispatch on the substrate primitive;
616 /// the one remaining (`cpu: Option<u32>`) folds on the same
617 /// one-line accessor + is_empty-arm-route + validate-arm-route +
618 /// three-test pattern in the next run, closing the M2 `:limits`
619 /// slot family's `Option<Copy-T>` accessor axis. Named `wall_clock()`
620 /// to match the storage field's name; the accessor's identity maps
621 /// onto the canonical wasmtime-`Store::epoch_deadline_*`-shaped
622 /// vocabulary the slot's docstring already carries.
623 #[must_use]
624 pub const fn wall_clock(&self) -> Option<Duration> {
625 self.wall_clock
626 }
627
628 /// Substrate-canonical per-`:limits` `:cpu` Kubernetes-millicore
629 /// soft cgroup-share scalar accessor every consumer of the Servico's
630 /// pod-spec `resources.requests.cpu` propagation keys off — returns
631 /// the author-declared `:limits :cpu` typed millicore magnitude
632 /// verbatim as an `Option<u32>`, copied out of the typed slot's own
633 /// `Option<u32>` storage (`Option<u32>` is `Copy`, so the accessor
634 /// returns by value; no borrow of `&self` past the call). `None`
635 /// when the slot is absent (the "no cpu share declared —
636 /// scheduler-default applies, today the pre-M2 unbounded-cpu-share
637 /// shape" arm the module-level docstring names on
638 /// [`LimitsSpec::cpu`] itself — [`LimitsSpec::is_empty`]'s
639 /// `cpu().is_none()` arm reads this predicate too, so an
640 /// authored-but-unset `:limits (:cpu ())` round-trips to a
641 /// `servico_m2_overlay` emission structurally identical to one that
642 /// omits the slot entirely).
643 ///
644 /// The `:limits :cpu` slot carries the "per-process soft cgroup-v2
645 /// CPU share" Kubernetes-scheduler-shaped sandboxing hint
646 /// (`theory/INSPIRATIONS.md` §III.1 — Lunatic's supervised
647 /// wasm-`Store`-per-process host-runtime CPU accounting, translated
648 /// onto pleme-io's typed `:limits` slot as a scheduler-facing
649 /// millicore request the pod's kubelet propagates to the container's
650 /// cgroup) — the typed slot's `Option<u32>` accept-set (zero-floor
651 /// rejected through [`LimitsError::CpuZero`] because a zero cgroup
652 /// share starves the process; upper-bounded by
653 /// [`LIMITS_CPU_MILLICORES_MAX`] (128 cores — the largest commercially-
654 /// common non-metal cloud Kubernetes node vCPU count on managed GKE
655 /// / EKS / AKS general-purpose SKUs)) maps onto the K8s pod spec's
656 /// `spec.containers[].resources.requests.cpu` field the
657 /// M2.5 `wasm-engine` host-runtime lands on the `ComputeUnit` CR-side
658 /// pod template and, via [`crate::render::servico_m2_overlay`], onto
659 /// the `pleme-computeunit` Helm-library-chart values sub-block's
660 /// `limits.cpu` key that lands as the `ComputeUnit` CR's
661 /// `spec.limits.cpu` field.
662 ///
663 /// Prior to this lift the `.cpu` field was accessed inline at two
664 /// sites inside `impl LimitsSpec` — [`LimitsSpec::is_empty`]'s
665 /// `self.cpu.is_none()` arm and [`LimitsSpec::validate`]'s
666 /// `if let Some(m) = self.cpu { … }` zero-floor + upper-cap bracket
667 /// arm — two open-coded field-accesses that expressed no
668 /// compile-time link back to the typed slot. A future extension of
669 /// the `:limits :cpu` axis to a richer author surface — a
670 /// per-instance `ComputeUnit` CR-side `spec.limits.cpu` overlay the
671 /// operator pins per-cluster, a split of the single `u32` millicore
672 /// request into a `{request, limit}` pair once the pod spec's
673 /// `resources.requests.cpu` / `resources.limits.cpu` distinction
674 /// promotes past its current single-request author surface, a
675 /// millicore → cgroup-v2 `cpu.weight` rescale once the operator's
676 /// scheduler-facing translation lands past its current kubelet
677 /// passthrough — would have had to be threaded through every
678 /// open-coded copy in lockstep or the emptiness predicate and the
679 /// validate call would silently disagree on which cgroup share a
680 /// given [`LimitsSpec`] resolves to. Lifting the resolution to a
681 /// typed method on the substrate primitive means every downstream
682 /// consumer of the Servico's per-`:limits` cpu-share surface reaches
683 /// for exactly one typed dispatch — the resolver's accept-set
684 /// migrates as a unit on any future axis addition.
685 ///
686 /// Fourth and final `Option<Copy-T>`-return accessor on the M2 slot
687 /// family (peer of the sibling per-`:limits` [`LimitsSpec::memory`]
688 /// (620c067) `Option<u64>` accessor, per-`:limits`
689 /// [`LimitsSpec::fuel`] (795dee7) `Option<u64>` accessor, and
690 /// per-`:limits` [`LimitsSpec::wall_clock`] (8cb717b)
691 /// `Option<Duration>` accessor — same typed-optional-scalar shape
692 /// extended to the peer per-`:limits` cgroup-cpu-share axis; sibling
693 /// to [`crate::MeshPolicy::mtls_required`] (c0110f1) /
694 /// [`crate::MeshPolicy::retries`] (bdfb399) /
695 /// [`crate::MeshPolicy::timeout`] (7073d0f) on the closed M3
696 /// mesh-slot `Option<Copy-T>` accessor family). The four-tuple
697 /// `(memory(), fuel(), wall_clock(), cpu())` jointly projects every
698 /// `Option<Copy-T>` axis on the M2 `:limits` slot every consumer
699 /// that fans on wasm-linear-memory-cap + wasm-fuel-budget +
700 /// wall-clock-deadline + cgroup-cpu-share keys off — closes the M2
701 /// `:limits` slot family's `Option<Copy-T>` accessor axis (the
702 /// last unlifted `:limits` field-access site on the M2 slot family;
703 /// every axis now routes through a typed dispatch on the substrate
704 /// primitive, with no open-coded field access anywhere on the impl).
705 /// Named `cpu()` to match the storage field's name; the accessor's
706 /// identity maps onto the canonical Kubernetes-`resources.requests.cpu`-
707 /// shaped vocabulary the slot's docstring already carries.
708 #[must_use]
709 pub const fn cpu(&self) -> Option<u32> {
710 self.cpu
711 }
712
713 /// Reject operationally-meaningless zero values on every declared
714 /// axis. Each axis remains optional — omitting a field expresses
715 /// "no bound on this axis"; the bug being closed is *carrying* a
716 /// zero value, which the wasm-engine consumes as "trap the first
717 /// instruction" / "instantiation refused" / "immediate timeout"
718 /// rather than the author's intended "an unspecified bound".
719 ///
720 /// Mirrors the discipline applied to `:politicas` axes in
721 /// `AplicacaoSpec::validate` and to `SupervisorSpec::max_restarts`
722 /// — every typed value carried by a slot is either absent or
723 /// meaningfully non-zero.
724 pub fn validate(&self) -> Result<(), LimitsError> {
725 // Route the `:memory` axis's four value-shape gates
726 // (zero-floor → wasm32-page-floor → wasm32-address-cap →
727 // page-multiple) through the substrate helper
728 // [`crate::render::require_positive_quantum_multiple_bounded_u64`]
729 // rather than four sequential inline
730 // `if let Some(m) = self.memory()` guards each restating one
731 // arm. Brings the `:memory` axis onto the same "one substrate
732 // helper per typed axis" discipline the peer `:fuel` (routed
733 // through [`crate::render::require_positive_bounded_u64`]),
734 // `:wall-clock` (through
735 // [`crate::render::require_positive_canonical_bounded_duration`]),
736 // and `:cpu` (through
737 // [`crate::render::require_positive_bounded_u32`]) axes
738 // already carry — every `LimitsSpec::validate` axis is now
739 // exactly one typed-helper dispatch, with the four-arm
740 // ordering (zero → below-quantum → cap → not-multiple)
741 // promoted from a per-site convention four inline blocks
742 // re-derived by hand to a structural contract on the
743 // substrate primitive. Byte-equal today: the helper fires the
744 // same four arms in the same canonical order at the same
745 // boundary values, threading the offending byte count into
746 // the same `MemoryBelowWasm32Page` / `MemoryExceedsWasm32Cap`
747 // / `MemoryNotPageMultiple` discriminator fields the four
748 // pre-lift inline arms already carried, so every existing
749 // per-arm test in this module continues to pin the same
750 // shape unchanged. Pinned end-to-end by
751 // `validate_memory_axis_routes_through_quantum_multiple_bounded_helper`.
752 if let Some(m) = self.memory() {
753 crate::render::require_positive_quantum_multiple_bounded_u64(
754 m,
755 LIMITS_MEMORY_WASM32_PAGE_BYTES,
756 LIMITS_MEMORY_WASM32_MAX_BYTES,
757 || LimitsError::MemoryZero,
758 |bytes| LimitsError::MemoryBelowWasm32Page { bytes },
759 |bytes| LimitsError::MemoryExceedsWasm32Cap { bytes },
760 |bytes| LimitsError::MemoryNotPageMultiple { bytes },
761 )?;
762 }
763 // Zero-floor + upper-cap bracket on the typed `:fuel` axis. See
764 // [`crate::render::require_positive_bounded_u64`] for the
765 // ordering discipline (zero-floor arm strictly precedes cap arm
766 // so `Some(0)` surfaces the self-locating `FuelZero` diagnostic
767 // with its omit-axis remediation directly named, not the
768 // misleading `0 > LIMITS_FUEL_MAX == false` cap-arm miss).
769 // Until this bracket landed the `Option<u64>` slot accepted any
770 // value past zero (the parser's only upper bound was `u64::MAX`),
771 // so `(:fuel 18446744073709551615)` round-tripped cleanly
772 // through serde and the per-process CSE invariant (no value the
773 // wasm-engine's fuel counter can't honor as a meaningful budget
774 // before the sibling `:wall-clock` deadline fires) was a
775 // runtime, not build-time, contract on every above-cap input
776 // — the canonical declared-but-no-op footgun the sibling
777 // [`LimitsError::MemoryExceedsWasm32Cap`] /
778 // [`LimitsError::WallClockExceedsCap`] /
779 // [`LimitsError::CpuExceedsCap`] arms close on the peer
780 // "cannot be honored" / "unschedulable hint" /
781 // "nominal-only deadline" shapes, the peer
782 // [`crate::AplicacaoError::PolicyTimeoutExceedsCap`] /
783 // [`crate::AplicacaoError::PolicyBreakerWindowExceedsCap`] /
784 // [`crate::AplicacaoError::PolicyRateLimitExceedsCap`] arms
785 // close on the no-op-deadline / lifetime-counter / no-op-limiter
786 // shapes, and the
787 // [`crate::SupervisorError::MaxRestartsExceedsCap`] arm closes
788 // on the no-op-supervisor shape. The four `:limits` axes are
789 // now uniformly bracketed top and bottom (`:memory` in
790 // `LIMITS_MEMORY_WASM32_PAGE_BYTES..=LIMITS_MEMORY_WASM32_MAX_BYTES`,
791 // `:fuel` in `1..=LIMITS_FUEL_MAX`, `:wall-clock` in
792 // `1ms..=LIMITS_WALL_CLOCK_MAX`, `:cpu` in
793 // `1..=LIMITS_CPU_MILLICORES_MAX`).
794 if let Some(f) = self.fuel() {
795 crate::render::require_positive_bounded_u64(
796 f,
797 LIMITS_FUEL_MAX,
798 || LimitsError::FuelZero,
799 |fuel| LimitsError::FuelExceedsCap { fuel },
800 )?;
801 }
802 if let Some(w) = self.wall_clock() {
803 // Zero-floor + integer-millisecond canonical-form +
804 // upper-cap bracket on the typed `:wall-clock` axis. See
805 // [`crate::render::require_positive_canonical_bounded_duration`]
806 // for the full three-arm ordering discipline (zero-floor
807 // strictly precedes canonical-form so `Duration::ZERO`
808 // surfaces the self-locating `WallClockZero` diagnostic;
809 // canonical-form strictly precedes the cap arm so a
810 // sub-millisecond above-cap value surfaces the more
811 // fundamental round-trip-shape diagnostic first) and the
812 // three peer typed-`Duration` sites that share this
813 // canonical bracket ([`crate::MeshPolicy::timeout`],
814 // [`crate::CircuitBreaker::window`],
815 // [`crate::SupervisorSpec::restart_window`]). Every
816 // validated value lies in `1ms..=LIMITS_WALL_CLOCK_MAX`
817 // (1ms..=1h), integer-millisecond granularity.
818 crate::render::require_positive_canonical_bounded_duration(
819 w,
820 LIMITS_WALL_CLOCK_MAX,
821 || LimitsError::WallClockZero,
822 |wall_clock| LimitsError::WallClockNotCanonical { wall_clock },
823 |wall_clock| LimitsError::WallClockExceedsCap { wall_clock },
824 )?;
825 }
826 // Zero-floor + upper-cap bracket on the typed `:cpu` axis. See
827 // [`crate::render::require_positive_bounded_u32`] for the
828 // ordering discipline (zero-floor arm strictly precedes cap arm
829 // so `Some(0)` surfaces the self-locating `CpuZero` diagnostic
830 // with its omit-axis remediation directly named, not the
831 // misleading `0 > LIMITS_CPU_MILLICORES_MAX == false` cap-arm
832 // miss). The bracket set is `1..=LIMITS_CPU_MILLICORES_MAX`
833 // (128 cores = 128_000 millicores — the largest commercially-
834 // common non-metal cloud Kubernetes node vCPU count). Until
835 // this bracket landed the millicore codec accepted any
836 // `Option<u32>` past zero (the prior numeric-zero arm's only
837 // floor), so `(:cpu "1000000m")` (1000 cores) round-tripped
838 // cleanly through serde and the per-axis CSE invariant (no
839 // value the Kubernetes scheduler can't honor) was a runtime,
840 // not build-time, contract on every above-cap input: the
841 // `pleme-computeunit` chart's `resources.requests.cpu` landed
842 // verbatim, the pod sat `Pending` indefinitely with a `0/N
843 // nodes are available: N Insufficient cpu` event, and the
844 // typed `:cpu` slot became an unschedulable hint far from the
845 // source caixa.lisp. Closes the same gap the wasm32-wasip2
846 // upper ceiling closes on the `:memory` axis — the typed `:cpu`
847 // axis is now operationally bracketed. Peer with every sibling
848 // cap arm on this surface ([`LimitsError::MemoryExceedsWasm32Cap`],
849 // [`LimitsError::WallClockExceedsCap`],
850 // [`crate::AplicacaoError::PolicyTimeoutExceedsCap`],
851 // [`crate::AplicacaoError::PolicyRetriesExceedsCap`],
852 // [`crate::AplicacaoError::PolicyBreakerMaxFailuresExceedsCap`],
853 // [`crate::AplicacaoError::PolicyBreakerWindowExceedsCap`],
854 // [`crate::AplicacaoError::PolicyRateLimitExceedsCap`],
855 // [`crate::SupervisorError::MaxRestartsExceedsCap`]).
856 if let Some(m) = self.cpu() {
857 crate::render::require_positive_bounded_u32(
858 m,
859 LIMITS_CPU_MILLICORES_MAX,
860 || LimitsError::CpuZero,
861 |millicores| LimitsError::CpuExceedsCap { millicores },
862 )?;
863 }
864 Ok(())
865 }
866}
867
868#[derive(Debug, Error, PartialEq, Eq)]
869pub enum LimitsError {
870 #[error("byte-size: missing magnitude in {0:?}")]
871 EmptyByteSize(String),
872 #[error("byte-size: unknown unit {unit:?} (expected one of B, KB, MB, GB, KiB, MiB, GiB)")]
873 UnknownByteUnit { unit: String },
874 #[error("byte-size: failed to parse magnitude {0:?}")]
875 BadByteMagnitude(String),
876 #[error(
877 "byte-size: magnitude {value:?} is not a non-negative integer — the canonical \
878 authoring form for `:limits :memory` is `<integer><unit>` (e.g. `\"1024\"`, \
879 `\"64MiB\"`, `\"1GiB\"`) with no decimal point and no leading `+` sign. A \
880 fractional / decimal-shaped magnitude (`\"1.5KiB\"`, `\"1.0MiB\"`, `\"0.5GiB\"`, \
881 `\"+1024\"`) round-trips through `render_byte_size` to a *different* canonical \
882 form (`\"1536\"`, `\"1MiB\"`, `\"512MiB\"`, `\"1KiB\"`) on first serialize — \
883 breaking the THEORY.md §V.2.7 render-determinism contract every typed slot \
884 carries. Pick an integer magnitude in the unit that divides cleanly (write \
885 `\"1536\"` instead of `\"1.5KiB\"`; `\"512MiB\"` instead of `\"0.5GiB\"`)"
886 )]
887 NonIntegerByteMagnitude { value: String },
888 #[error(
889 "byte-size: magnitude {value:?} has a non-canonical leading zero — the canonical \
890 authoring form for `:limits :memory` is `<integer><unit>` (e.g. `\"64MiB\"`, \
891 `\"1GiB\"`, `\"512KiB\"`, `\"1024\"`) with no leading-zero padding on the magnitude. \
892 A leading-zero magnitude (`\"064MiB\"`, `\"01024\"`, `\"00KiB\"`, `\"0500MB\"`) round-trips \
893 through `render_byte_size` to a *different* canonical form (`\"64MiB\"`, `\"1KiB\"`, \
894 `\"0\"`, `\"500MB\"`) on first serialize — breaking the THEORY.md Part V \
895 render-determinism contract every typed slot carries. Strip the leading zeros \
896 (write `\"64MiB\"` instead of `\"064MiB\"`)"
897 )]
898 LeadingZeroByteMagnitude { value: String },
899 #[error(
900 "byte-size: value {value:?} contains whitespace byte 0x{byte:02x} — the canonical \
901 authoring form for `:limits :memory` is `<integer><unit>` (e.g. `\"64MiB\"`, \
902 `\"1GiB\"`, `\"512KiB\"`, `\"1024\"`) with no whitespace bytes anywhere. A \
903 whitespace-carrying shape (`\" 64MiB\"`, `\"64MiB \"`, `\"64 MiB\"`, `\"\\t64MiB\"`, \
904 `\"64MiB\\n\"`) round-trips through `render_byte_size` to a *different* canonical \
905 form (`\"64MiB\"`) on first serialize — breaking the THEORY.md Part V \
906 render-determinism contract every typed slot carries. Strip every whitespace byte \
907 (write `\"64MiB\"` verbatim)"
908 )]
909 WhitespaceInByteSize { value: String, byte: u8 },
910 #[error(
911 "byte-size: value {value:?} contains a non-ASCII Unicode whitespace character \
912 {ch:?} (U+{codepoint:04X}) — the canonical authoring form for `:limits :memory` \
913 is `<integer><unit>` (e.g. `\"64MiB\"`, `\"1GiB\"`, `\"512KiB\"`, `\"1024\"`) \
914 with no whitespace characters anywhere (ASCII or Unicode). A non-ASCII-whitespace-\
915 carrying shape (`\"\\u{{00A0}}64MiB\"` — paste-from-typography NBSP prefix; \
916 `\"64MiB\\u{{2028}}\"` — paste-from-web-doc line-separator suffix; \
917 `\"64\\u{{2003}}MiB\"` — paste-from-typography EM-SPACE between magnitude and \
918 unit) survives the pre-existing `u8::is_ascii_whitespace` byte-scan (none of \
919 its bytes match the ASCII whitespace set) but `str::trim` (which uses \
920 `char::is_whitespace` — the Unicode `White_Space` property, strictly wider than \
921 the ASCII byte set) silently strips it at parse entry, and the value round-trips \
922 through `render_byte_size` to a *different* canonical form (`\"64MiB\"`) on \
923 first serialize — breaking the THEORY.md Part V render-determinism contract \
924 every typed slot carries. Strip every non-ASCII whitespace character (write \
925 `\"64MiB\"` verbatim with only ASCII bytes)"
926 )]
927 NonAsciiWhitespaceInByteSize {
928 value: String,
929 ch: char,
930 codepoint: u32,
931 },
932 #[error("duration: missing magnitude in {0:?}")]
933 EmptyDuration(String),
934 #[error("duration: unknown unit {unit:?} (expected one of ms, s, m, h)")]
935 UnknownDurationUnit { unit: String },
936 #[error("duration: failed to parse magnitude {0:?}")]
937 BadDurationMagnitude(String),
938 #[error(
939 "duration: magnitude {value:?} is not a non-negative integer — the canonical \
940 authoring form for `:limits :wall-clock` is `<integer><unit>` (e.g. `\"30s\"`, \
941 `\"500ms\"`, `\"2m\"`, `\"1h\"`) with no decimal point and no leading `+` sign. A \
942 fractional / decimal-shaped magnitude (`\"1.5s\"`, `\"1.0s\"`, `\"0.5m\"`, \
943 `\"+30s\"`, `\"-30s\"`) round-trips through `render_duration` to a *different* \
944 canonical form (`\"1500ms\"`, `\"1s\"`, `\"30s\"`, `\"30s\"`) on first serialize \
945 — breaking the THEORY.md Part V render-determinism contract every typed slot \
946 carries. Pick an integer magnitude in the unit that divides cleanly (write \
947 `\"1500ms\"` instead of `\"1.5s\"`; `\"30s\"` instead of `\"0.5m\"`)"
948 )]
949 NonIntegerDurationMagnitude { value: String },
950 #[error(
951 "duration: magnitude {value:?} has a non-canonical leading zero — the canonical \
952 authoring form for `:limits :wall-clock` is `<integer><unit>` (e.g. `\"30s\"`, \
953 `\"500ms\"`, `\"2m\"`, `\"1h\"`) with no leading-zero padding on the magnitude. \
954 A leading-zero magnitude (`\"030s\"`, `\"00s\"`, `\"01h\"`, `\"0500ms\"`) round-trips \
955 through `render_duration` to a *different* canonical form (`\"30s\"`, `\"0s\"`, \
956 `\"1h\"`, `\"500ms\"`) on first serialize — breaking the THEORY.md Part V \
957 render-determinism contract every typed slot carries. Strip the leading zeros \
958 (write `\"30s\"` instead of `\"030s\"`)"
959 )]
960 LeadingZeroDurationMagnitude { value: String },
961 #[error(
962 "duration: value {value:?} contains whitespace byte 0x{byte:02x} — the canonical \
963 authoring form for `:limits :wall-clock` is `<integer><unit>` (e.g. `\"30s\"`, \
964 `\"500ms\"`, `\"2m\"`, `\"1h\"`) with no whitespace bytes anywhere. A \
965 whitespace-carrying shape (`\" 30s\"`, `\"30s \"`, `\"30 s\"`, `\"\\t30s\"`, \
966 `\"30s\\n\"`) round-trips through `render_duration` to a *different* canonical form \
967 (`\"30s\"`) on first serialize — breaking the THEORY.md Part V render-determinism \
968 contract every typed slot carries. Strip every whitespace byte (write `\"30s\"` \
969 verbatim)"
970 )]
971 WhitespaceInDuration { value: String, byte: u8 },
972 #[error(
973 "duration: value {value:?} contains a non-ASCII Unicode whitespace character \
974 {ch:?} (U+{codepoint:04X}) — the canonical authoring form for `:limits :wall-clock` \
975 is `<integer><unit>` (e.g. `\"30s\"`, `\"500ms\"`, `\"2m\"`, `\"1h\"`) with no \
976 whitespace characters anywhere (ASCII or Unicode). A non-ASCII-whitespace-\
977 carrying shape (`\"\\u{{00A0}}30s\"` — paste-from-typography NBSP prefix; \
978 `\"30s\\u{{2028}}\"` — paste-from-web-doc line-separator suffix; \
979 `\"30\\u{{2003}}s\"` — paste-from-typography EM-SPACE between magnitude and \
980 unit) survives the pre-existing `u8::is_ascii_whitespace` byte-scan (none of \
981 its bytes match the ASCII whitespace set) but `str::trim` (which uses \
982 `char::is_whitespace` — the Unicode `White_Space` property, strictly wider than \
983 the ASCII byte set) silently strips it at parse entry, and the value round-trips \
984 through `render_duration` to a *different* canonical form (`\"30s\"`) on first \
985 serialize — breaking the THEORY.md Part V render-determinism contract every \
986 typed slot carries. Strip every non-ASCII whitespace character (write `\"30s\"` \
987 verbatim with only ASCII bytes)"
988 )]
989 NonAsciiWhitespaceInDuration {
990 value: String,
991 ch: char,
992 codepoint: u32,
993 },
994 #[error("millicores: bad value {0:?} (expected `<int>m` or `<int>`)")]
995 BadMillicores(String),
996 #[error(
997 "millicores: magnitude {value:?} is not a non-negative integer — the canonical \
998 authoring form for `:limits :cpu` is `<integer>m` (Kubernetes millicores, e.g. \
999 `\"500m\"` for half a core, `\"2000m\"` for two cores) or the bare-core \
1000 shorthand `<integer>` (e.g. `\"2\"` = `\"2000m\"`), with no decimal point and \
1001 no leading `+` sign. A fractional / decimal-shaped magnitude (`\"1.5\"`, \
1002 `\"500.0m\"`, `\"+500m\"`, `\"-100m\"`) round-trips through `render_millicores` \
1003 to a *different* canonical form (`\"1500m\"`, `\"500m\"`, `\"500m\"`, \
1004 parse-rejection) on first serialize — breaking the THEORY.md Part V \
1005 render-determinism contract every typed slot carries. Pick an integer magnitude \
1006 in millicores (write `\"1500m\"` instead of `\"1.5\"`; `\"500m\"` instead of \
1007 `\"500.0m\"`)"
1008 )]
1009 NonIntegerMillicoreMagnitude { value: String },
1010 #[error(
1011 "millicores: magnitude {value:?} has a non-canonical leading zero — the canonical \
1012 authoring form for `:limits :cpu` is `<integer>m` (Kubernetes millicores, e.g. \
1013 `\"500m\"` for half a core, `\"2000m\"` for two cores) or the bare-core shorthand \
1014 `<integer>` (e.g. `\"2\"` = `\"2000m\"`) with no leading-zero padding on the \
1015 magnitude. A leading-zero magnitude (`\"0500m\"`, `\"00m\"`, `\"02\"`, `\"01500m\"`) \
1016 round-trips through `render_millicores` to a *different* canonical form (`\"500m\"`, \
1017 `\"0m\"`, `\"2000m\"`, `\"1500m\"`) on first serialize — breaking the THEORY.md Part \
1018 V render-determinism contract every typed slot carries. Strip the leading zeros \
1019 (write `\"500m\"` instead of `\"0500m\"`; `\"2\"` instead of `\"02\"`)"
1020 )]
1021 LeadingZeroMillicoreMagnitude { value: String },
1022 #[error(
1023 "millicores: value {value:?} contains whitespace byte 0x{byte:02x} — the canonical \
1024 authoring form for `:limits :cpu` is `<integer>m` (Kubernetes millicores, e.g. \
1025 `\"500m\"`, `\"2000m\"`) or the bare-core shorthand `<integer>` (e.g. `\"2\"`) \
1026 with no whitespace bytes anywhere. A whitespace-carrying shape (`\" 500m\"`, \
1027 `\"500m \"`, `\"500 m\"`, `\"\\t500m\"`, `\"500m\\n\"`) round-trips through \
1028 `render_millicores` to a *different* canonical form (`\"500m\"`) on first \
1029 serialize — breaking the THEORY.md Part V render-determinism contract every \
1030 typed slot carries. Strip every whitespace byte (write `\"500m\"` verbatim)"
1031 )]
1032 WhitespaceInMillicores { value: String, byte: u8 },
1033 #[error(
1034 "millicores: value {value:?} contains a non-ASCII Unicode whitespace character \
1035 {ch:?} (U+{codepoint:04X}) — the canonical authoring form for `:limits :cpu` is \
1036 `<integer>m` (Kubernetes millicores, e.g. `\"500m\"`, `\"2000m\"`) or the \
1037 bare-core shorthand `<integer>` (e.g. `\"2\"`) with no whitespace characters \
1038 anywhere (ASCII or Unicode). A non-ASCII-whitespace-carrying shape \
1039 (`\"\\u{{00A0}}500m\"` — paste-from-typography NBSP prefix; \
1040 `\"500m\\u{{2028}}\"` — paste-from-web-doc line-separator suffix; \
1041 `\"500\\u{{2003}}m\"` — paste-from-typography EM-SPACE between magnitude and \
1042 unit) survives the pre-existing `u8::is_ascii_whitespace` byte-scan (none of \
1043 its bytes match the ASCII whitespace set) but `str::trim` (which uses \
1044 `char::is_whitespace` — the Unicode `White_Space` property, strictly wider than \
1045 the ASCII byte set) silently strips it at parse entry, and the value round-trips \
1046 through `render_millicores` to a *different* canonical form (`\"500m\"`) on \
1047 first serialize — breaking the THEORY.md Part V render-determinism contract \
1048 every typed slot carries. Strip every non-ASCII whitespace character (write \
1049 `\"500m\"` verbatim with only ASCII bytes)"
1050 )]
1051 NonAsciiWhitespaceInMillicores {
1052 value: String,
1053 ch: char,
1054 codepoint: u32,
1055 },
1056 #[error(
1057 ":limits :memory must be > 0 — wasmtime StoreLimits refuses a zero memory cap; omit the field for unbounded"
1058 )]
1059 MemoryZero,
1060 #[error(
1061 ":limits :memory ({bytes} bytes) is below the wasm32-wasip2 linear-memory page size (64 KiB = 65536 bytes) — a sub-page cap cannot hold a single wasm linear memory page, so instantiation of any component declaring `(memory 1)` traps with `memory minimum size of 1 pages exceeds memory limits` and a `(memory 0)` component traps the first `memory.grow(1)`. Pin a value ≥ 64 KiB (e.g. `\"64KiB\"`, `\"1MiB\"`, `\"64MiB\"`) or omit the field for unbounded"
1062 )]
1063 MemoryBelowWasm32Page { bytes: u64 },
1064 #[error(
1065 ":limits :memory ({bytes} bytes) exceeds the wasm32-wasip2 linear-memory ceiling (4 GiB = 4294967296 bytes); pin a value ≤ 4 GiB or omit the field for unbounded"
1066 )]
1067 MemoryExceedsWasm32Cap { bytes: u64 },
1068 #[error(
1069 ":limits :memory ({bytes} bytes) carries a sub-page residue the wasm32-wasip2 \
1070 linear-memory model cannot honor — the wasm spec defines linear memory in \
1071 fixed 64 KiB pages (LIMITS_MEMORY_WASM32_PAGE_BYTES = 65536 bytes) and \
1072 wasmtime's StoreLimits::memory_size is consumed as a page-quantized ceiling: \
1073 the engine can grow at most floor({bytes} / 65536) pages, and the bytes in \
1074 [floor({bytes} / 65536) * 65536, {bytes}] are structural dead space the \
1075 runtime cannot honor. Pin a page-aligned value in 64KiB..=4GiB \
1076 (the canonical authoring magnitudes — `\"64KiB\"`, `\"128KiB\"`, `\"1MiB\"`, \
1077 `\"64MiB\"`, `\"1GiB\"`, `\"4GiB\"` — every power-of-1024 unit the byte-size \
1078 codec emits divides cleanly by the page size) or omit the field for unbounded"
1079 )]
1080 MemoryNotPageMultiple { bytes: u64 },
1081 #[error(
1082 ":limits :fuel must be > 0 — wasmtime traps the first instruction at fuel=0; omit the field for unbounded"
1083 )]
1084 FuelZero,
1085 #[error(
1086 ":limits :fuel ({fuel} instructions) exceeds the per-process ceiling \
1087 (LIMITS_FUEL_MAX = 1_000_000_000_000 = 10^12 wasm instructions) — a value \
1088 above this cap turns the typed per-call fuel counter into a no-op budget: \
1089 the sibling `:wall-clock` cap (LIMITS_WALL_CLOCK_MAX = 1h = 3600s) fires \
1090 before the fuel counter could ever be drained (wasmtime's documented \
1091 fuel-tracked execution rate sits at ~10^8–10^9 fuel-units per second on \
1092 modern x86_64 / aarch64 hosts running wasmtime through Cranelift, so the \
1093 largest realistic per-call fuel budget reachable within 1h sits at ~3.6 × \
1094 10^11–3.6 × 10^12 fuel-units, and a value above 10^12 is structurally \
1095 unreachable as a per-call counter), so the typed `:fuel` slot becomes a \
1096 declared-but-no-op contract far from the source caixa.lisp. Pin a value \
1097 in 1..=1_000_000_000_000 (the canonical caixa Servico runs in the \
1098 10^6..=10^9 fuel band — the in-tree `Caixa::template` documentation and \
1099 `caixa-feira` examples carry `:fuel 1_000_000` = 10^6, peer to \
1100 wasmtime's official `Store::set_fuel(1_000_000)` example in the wasmtime \
1101 book; production-shape per-request fuel budgets sit in the 10^7..=10^9 \
1102 band for compute-bound workloads) or omit :fuel to express `no per-call \
1103 fuel budget on this axis` (the wasm-engine then relies entirely on the \
1104 sibling `:wall-clock` cgroup / Kubernetes activeDeadlineSeconds deadline)"
1105 )]
1106 FuelExceedsCap { fuel: u64 },
1107 #[error(
1108 ":limits :wall-clock must be > 0 — a zero deadline expires before the call starts; omit the field for unbounded"
1109 )]
1110 WallClockZero,
1111 #[error(
1112 ":limits :wall-clock ({wall_clock:?}) carries a sub-millisecond residue the typed `:wall-clock` duration codec cannot round-trip — \
1113 the codec truncates to `as_millis()` before picking the canonical unit, so a value with `subsec_nanos() % 1_000_000 != 0` either \
1114 truncates on first serialize (e.g. `Duration::from_micros(1500)` → \"1ms\" → `Duration::from_millis(1)` ≠ original) or renders \
1115 as \"0s\" the `WallClockZero` arm then rejects on re-validate. Pin an integer-millisecond magnitude in the canonical authoring form \
1116 (`<integer><unit>` for unit ∈ {{ms, s, m, h}}, e.g. `\"500ms\"`, `\"30s\"`, `\"2m\"`, `\"1h\"`) or omit the field for unbounded"
1117 )]
1118 WallClockNotCanonical { wall_clock: Duration },
1119 #[error(
1120 ":limits :wall-clock ({wall_clock:?}) exceeds the per-process ceiling \
1121 (LIMITS_WALL_CLOCK_MAX = 1h = 3600s) — a value above this cap turns the typed \
1122 per-call deadline into a nominal-only contract (the wasm-engine's epoch-deadline \
1123 cancellation reaches for a `Duration` so long no realistic synchronous wasm call \
1124 can hit it), and the MESH-COMPOSITION §V \"no infinite blocking\" CSE invariant \
1125 degenerates to enforcement only at the per-Servico cgroup / Kubernetes \
1126 activeDeadlineSeconds layer — far above the per-call granularity the typed \
1127 `:limits :wall-clock` slot is meant to express. Pin a value in 1ms..=1h \
1128 (Envoy / Istio / Linkerd production per-request playbooks all recommend ≤ 60s; \
1129 AWS App Mesh / ingress-nginx typical ≤ 300s; the longest per-request \
1130 `proxy_read_timeout` ingress-nginx documents maxes out at the same 3600s ceiling) \
1131 or omit :wall-clock to express `no per-process deadline on this axis` (the \
1132 deadline then relies entirely on the cluster-level cgroup / pod \
1133 activeDeadlineSeconds bound)"
1134 )]
1135 WallClockExceedsCap { wall_clock: Duration },
1136 #[error(
1137 ":limits :cpu must be > 0m — a zero cgroup share starves the process; omit the field for unbounded"
1138 )]
1139 CpuZero,
1140 #[error(
1141 ":limits :cpu ({millicores}m) exceeds the per-process ceiling \
1142 (LIMITS_CPU_MILLICORES_MAX = 128_000m = 128 cores) — a value above this cap is \
1143 structurally unschedulable on every commercially-common managed-Kubernetes node \
1144 pool (GKE Standard / EKS managed / AKS default general-purpose SKU ladders top out \
1145 at 128 vCPU per node; AWS m7i.32xlarge / c7i.32xlarge, Azure HBv3-128rs, GCP \
1146 c3-standard-128 all sit at the same 128-vCPU ceiling), so the resulting \
1147 `pleme-computeunit` chart's `resources.requests.cpu` lands as a hint the \
1148 Kubernetes scheduler cannot bind to any node — the pod sits `Pending` indefinitely \
1149 with a `0/N nodes are available: N Insufficient cpu` event, and the typed `:cpu` \
1150 slot becomes an unschedulable contract far from the source caixa.lisp. The \
1151 wasm32-wasip2 single-threaded execution model the canonical caixa Servico targets \
1152 reinforces the structural argument: a single wasm component cannot saturate more \
1153 than one core, so even the Lunatic-style supervised-multi-process host bounds its \
1154 useful CPU request to the host node's vCPU count. Pin a value in 1m..=128000m \
1155 (the canonical caixa Servico runs in the 100m..=2000m band — every in-tree \
1156 example uses 500m; AWS App Mesh / Envoy / Istio per-pod CPU production playbooks \
1157 all sit ≤ 8000m / 8 cores; the longest documented per-Servico CPU request any \
1158 pleme-io substrate playbook recommends maxes at ~16 cores) or omit :cpu to \
1159 express `no per-process CPU hint on this axis` (the cgroup share then defaults to \
1160 the cluster-level `LimitRange` / `ResourceQuota` policy the operator pins on the \
1161 host namespace)"
1162 )]
1163 CpuExceedsCap { millicores: u32 },
1164}
1165
1166// ── byte-size codec ────────────────────────────────────────────────────
1167
1168fn parse_byte_size(s: &str) -> Result<u64, LimitsError> {
1169 // Whitespace-rejection arm — peer with the leading-`+` / fractional
1170 // arm below (`"+1024"`, `"1.5KiB"`) and the leading-zero arm below
1171 // (`"064MiB"`) on the same canonical-form render-determinism axis.
1172 // Until this gate landed the parser silently tolerated leading /
1173 // trailing / internal whitespace via the top-level `s.trim()` at
1174 // parse entry and the per-part `num_part.trim()` / `unit.trim()`
1175 // calls below, so every whitespace-carrying shape (`" 64MiB"` —
1176 // paste-from-aligned-doc / YAML-quoted-plain-scalar leading-space;
1177 // `"64MiB "` — paste-from-shell-history trailing-space; `"64 MiB"`
1178 // — paste-from-typography whitespace-between-magnitude-and-unit;
1179 // `"\t64MiB"` — paste-from-indented-doc / YAML-block-scalar tab
1180 // byte; `"64MiB\n"` — trailing newline from a multi-line paste)
1181 // parsed to the same 64 * 1024 * 1024 bytes and serde silently
1182 // round-tripped to `"64MiB"` on the next emit (a *different*
1183 // canonical string) — breaking the THEORY.md Part V
1184 // render-determinism contract every typed slot carries.
1185 //
1186 // The canonical author shape is `<integer><unit>` (or `<integer>`
1187 // for the bare-integer-as-bytes shorthand) with no whitespace
1188 // bytes anywhere — every string [`render_byte_size`] emits carries
1189 // none, so the parser's accepted set must match for serialize /
1190 // deserialize to round-trip losslessly. This gate makes the pre-
1191 // existing `s.trim()` / `num_part.trim()` / `unit.trim()` calls
1192 // below strict no-ops on the accepted set (every byte-position
1193 // match they would perform is now already trimmed away by the
1194 // accepted set itself), while the arm surfaces every rejected
1195 // whitespace-carrying shape with a typed `WhitespaceInByteSize`
1196 // diagnostic naming the offending byte and the canonical form the
1197 // author intended, peer with every prior canonical-form-drift arm
1198 // on this codec.
1199 //
1200 // Routed through the lifted
1201 // [`crate::render::find_ascii_whitespace_byte`] predicate — the
1202 // single source of truth every typed-magnitude codec in
1203 // caixa-core (`parse_byte_size` / `parse_duration` /
1204 // `parse_millicores` / `supervisor::duration_codec` /
1205 // `rate_limit_codec`) shares. `u8::is_ascii_whitespace()` at the
1206 // predicate covers the five WhatWG-conformant ASCII whitespace
1207 // bytes every downstream YAML / JSON / TOML parser can feed
1208 // through a quoted-scalar value verbatim — space (`0x20`), tab
1209 // (`0x09`), LF (`0x0A`), FF (`0x0C`), CR (`0x0D`) — deliberately
1210 // narrower than POSIX's `[:space:]` which also admits VT
1211 // (`0x0B`). Drift between any two codec sites' rejection set is
1212 // a single-edit fix at the shared predicate rather than five
1213 // independent scans diverging over time — same "single lifted
1214 // source of truth" discipline the peer non-ASCII arm below
1215 // (routed through [`crate::render::find_non_ascii_whitespace_char`])
1216 // carries on the strictly-complementary Unicode `White_Space`
1217 // class.
1218 if let Some(byte) = crate::render::find_ascii_whitespace_byte(s) {
1219 return Err(LimitsError::WhitespaceInByteSize {
1220 value: s.into(),
1221 byte,
1222 });
1223 }
1224 // Non-ASCII Unicode `White_Space` arm — the strictly-complementary
1225 // class the ASCII arm above cannot see. `str::trim` at the top of
1226 // the codec uses `char::is_whitespace` (the Unicode `White_Space`
1227 // property, strictly wider than the ASCII byte set), so an NBSP
1228 // (`\u{00A0}`) / LINE SEPARATOR (`\u{2028}`) / EM-SPACE
1229 // (`\u{2003}`) survives the byte-scan (its UTF-8 bytes are not in
1230 // `is_ascii_whitespace`), gets silently stripped by the top-level
1231 // `s.trim()` below, and the value round-trips through
1232 // `render_byte_size` to a *different* canonical form on the next
1233 // emit — breaking the THEORY.md Part V render-determinism
1234 // contract every typed slot carries. Same drift class across every
1235 // typed-magnitude codec in caixa-core; closed here (byte-size),
1236 // and at the peer sites (`parse_duration`,
1237 // `supervisor::duration_codec`, `rate_limit_codec`) through the
1238 // shared [`crate::render::find_non_ascii_whitespace_char`]
1239 // predicate — the "single lifted predicate across all four codec
1240 // sites in one follow-up run" the 24a8ad4 commit body's `Forward
1241 // compounding` bullet named as the next compounding step.
1242 if let Some(ch) = crate::render::find_non_ascii_whitespace_char(s) {
1243 return Err(LimitsError::NonAsciiWhitespaceInByteSize {
1244 value: s.into(),
1245 ch,
1246 codepoint: ch as u32,
1247 });
1248 }
1249 let s = s.trim();
1250 if s.is_empty() {
1251 return Err(LimitsError::EmptyByteSize(s.into()));
1252 }
1253 let split_at = s.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(s.len());
1254 let (num_part, unit) = s.split_at(split_at);
1255 let num_trim = num_part.trim();
1256 // The canonical authoring form for `:limits :memory` is
1257 // `<integer><unit>` — every magnitude `render_byte_size` emits is a
1258 // non-negative integer with no decimal point and no leading sign,
1259 // so the parser's accepted set must match for serialize/deserialize
1260 // to round-trip without canonical-form drift. Until this gate
1261 // landed the parser accepted any `f64`-shaped magnitude
1262 // (`"1.5KiB"` → 1536 bytes, `"1.0MiB"` → 1MiB, `"0.5GiB"` → 512MiB,
1263 // `"+1024"` → 1024) and serde silently round-tripped the value to
1264 // a *different* canonical string on the next emit (`"1.5KiB"` →
1265 // 1536 → `"1536"`, `"1.0MiB"` → 1048576 → `"1MiB"`, `"0.5GiB"` →
1266 // 536870912 → `"512MiB"`, `"+1024"` → 1024 → `"1KiB"`) — breaking
1267 // the THEORY.md §V.2.7 render-determinism contract every typed slot
1268 // carries.
1269 //
1270 // Strict canonical form: every byte of the magnitude is an ASCII
1271 // digit (no `.`, no `+`, no `-`). On current Rust `u64::from_str`
1272 // permissively accepts a leading `+` (`"+1024"` → 1024) — that's a
1273 // canonical-drift shape `render_byte_size` never emits, so the
1274 // digit-only check is what closes the leading-sign class; relying
1275 // on `u64::from_str`'s strictness alone would silently admit it.
1276 // On non-digit-only inputs the gate distinguishes "non-canonical-
1277 // but-numeric" (parses as f64 or i64, so it's an authoring-shape
1278 // footgun) from "garbage" (parses as neither, so it's not a
1279 // numeric input at all) — the diagnostic names the offending
1280 // magnitude shape verbatim rather than collapsing both authoring
1281 // footguns into a single opaque `BadByteMagnitude`.
1282 //
1283 // Same canonical-form discipline
1284 // [`crate::AplicacaoSpec::validate_politicas`]'s
1285 // [`is_canonical_rate_limit_window`] gate (808017c) applies to the
1286 // rate-limit `:window` axis — the codec's accepted set matches its
1287 // emitted set, structurally.
1288 //
1289 // (Scientific-notation magnitudes like `"1e3KiB"` are also rejected,
1290 // but on a different arm: the parser splits on the first ASCII-
1291 // alphabetic byte, so the `e` is read as a unit prefix and the
1292 // input falls into the `UnknownByteUnit { unit: "e3KiB" }` branch
1293 // before this gate is consulted — that's the existing diagnostic
1294 // for the scientific-shape footgun, and this gate is additive to
1295 // it.)
1296 //
1297 // Routed through the lifted
1298 // [`crate::render::is_digit_only_magnitude`] predicate — the
1299 // single source of truth every typed-magnitude codec in
1300 // caixa-core (`parse_byte_size` / `parse_duration` /
1301 // `parse_millicores` / `supervisor::duration_codec` /
1302 // `rate_limit_codec`) shares. Drift between any two codec sites'
1303 // digit-only rejection set becomes a single-edit fix at the
1304 // shared predicate rather than five independent
1305 // `!<var>.is_empty() && <var>.bytes().all(|b| b.is_ascii_digit())`
1306 // scans diverging over time — same "single lifted source of truth"
1307 // discipline the peer canonical-form predicates
1308 // ([`crate::render::find_ascii_whitespace_byte`] /
1309 // [`crate::render::find_non_ascii_whitespace_char`] /
1310 // [`crate::render::is_leading_zero_padded_magnitude`]) carry on
1311 // the whitespace and leading-zero-padding drift-class axes.
1312 let digit_only = crate::render::is_digit_only_magnitude(num_trim);
1313 if !digit_only {
1314 // Distinguish "non-canonical-but-numeric" (`"1.5"`, `"1.0"`,
1315 // `"+1024"`, `"-1"`) from "garbage" (`"abc"`, `"--1"`) so the
1316 // diagnostic names the offending magnitude shape verbatim.
1317 // Use f64 + i64 fallbacks for the "numeric" detection so every
1318 // non-digit-only-but-parseable input lands on
1319 // `NonIntegerByteMagnitude` regardless of sign or fractionality.
1320 let numeric = num_trim.parse::<f64>().is_ok() || num_trim.parse::<i64>().is_ok();
1321 if numeric {
1322 return Err(LimitsError::NonIntegerByteMagnitude {
1323 value: num_trim.into(),
1324 });
1325 }
1326 return Err(LimitsError::BadByteMagnitude(num_part.into()));
1327 }
1328 // Leading-zero arm — peer with the `parse_duration` leading-zero
1329 // arm (39762d7), the `supervisor::duration_codec` leading-zero arm
1330 // (9178904) and the `rate_limit_codec` leading-zero arm (4f46830)
1331 // on the same canonical-form render-determinism axis. The
1332 // digit-only gate accepts `"0064MiB"`, `"01024"`, `"00KiB"`,
1333 // `"0500MB"` as `u64::from_str` parses them losslessly (= 64, 1024,
1334 // 0, 500), but `render_byte_size` emits the leading-zero-stripped
1335 // form (`"64MiB"`, `"1KiB"`, `"0"`, `"500MB"`) — a *different*
1336 // canonical string on the next emit, breaking the THEORY.md Part V
1337 // render-determinism contract the same way `"+1024"` did before the
1338 // leading-`+` arm landed. The single-byte magnitude `"0"` (or
1339 // `"0B"` / `"0KiB"`) round-trips losslessly through
1340 // `render_byte_size` (`render_byte_size(0)` emits `"0"`) — the
1341 // downstream semantic-zero gate [`LimitsError::MemoryZero`] refuses
1342 // zero-magnitude authoring at the typed-validate layer above, so
1343 // the single-byte `"0"` stays in the accepted set at this codec
1344 // layer and the diagnostic partitioning between canonical-form
1345 // drift (this arm) and semantic-zero (the downstream gate) remains
1346 // stable. Same codec-layer / typed-validate-layer partition the
1347 // peer codecs preserve.
1348 //
1349 // Routed through the lifted
1350 // [`crate::render::is_leading_zero_padded_magnitude`] predicate —
1351 // the single source of truth every typed-magnitude codec in
1352 // caixa-core (`parse_byte_size` / `parse_duration` /
1353 // `parse_millicores` / `supervisor::duration_codec` /
1354 // `rate_limit_codec`) shares. Drift between any two codec sites'
1355 // leading-zero rejection set becomes a single-edit fix at the
1356 // shared predicate rather than five independent
1357 // `s.len() > 1 && s.as_bytes()[0] == b'0'` scans diverging over
1358 // time — same "single lifted source of truth" discipline the
1359 // peer whitespace predicates
1360 // ([`crate::render::find_ascii_whitespace_byte`] /
1361 // [`crate::render::find_non_ascii_whitespace_char`]) carry on
1362 // their strictly-complementary axes.
1363 if crate::render::is_leading_zero_padded_magnitude(num_trim) {
1364 return Err(LimitsError::LeadingZeroByteMagnitude {
1365 value: num_trim.into(),
1366 });
1367 }
1368 // `digit_only` guarantees every byte is `[0-9]`, so the only way
1369 // u64::from_str can fail here is overflow (the magnitude exceeds
1370 // u64::MAX). Surface that as `BadByteMagnitude` with an overflow-
1371 // shaped wording so the diagnostic names the offending magnitude
1372 // verbatim rather than collapsing onto the non-canonical arm.
1373 let num: u64 = num_trim.parse::<u64>().map_err(|_| {
1374 LimitsError::BadByteMagnitude(format!("{num_trim} (digit-only magnitude overflows u64)"))
1375 })?;
1376 let multiplier: u64 = match unit.trim() {
1377 "" | "B" => 1,
1378 "KB" => 1_000,
1379 "MB" => 1_000_000,
1380 "GB" => 1_000_000_000,
1381 "KiB" => 1024,
1382 "MiB" => 1024 * 1024,
1383 "GiB" => 1024 * 1024 * 1024,
1384 other => {
1385 return Err(LimitsError::UnknownByteUnit { unit: other.into() });
1386 }
1387 };
1388 // Overflow surfaces as `BadByteMagnitude` (a u64-saturating
1389 // multiply would silently truncate to `u64::MAX` and then the
1390 // wasm32-cap gate at validate time would catch it — but a u64
1391 // overflow is a parse-shaped failure on the author's input, not a
1392 // domain-cap rejection on a well-formed value, so it surfaces here
1393 // as a parser diagnostic naming the offending magnitude × unit
1394 // pair rather than as `MemoryExceedsWasm32Cap { bytes: u64::MAX }`
1395 // far from the author's intent).
1396 num.checked_mul(multiplier).ok_or_else(|| {
1397 LimitsError::BadByteMagnitude(format!(
1398 "{num_trim}{unit_trim} overflows u64 (magnitude × unit > 2^64-1)",
1399 unit_trim = unit.trim()
1400 ))
1401 })
1402}
1403
1404fn render_byte_size(n: u64) -> String {
1405 // Prefer the largest power-of-1024 unit that divides cleanly; fall
1406 // back to bytes if nothing matches.
1407 const UNITS: &[(u64, &str)] = &[
1408 (1024 * 1024 * 1024, "GiB"),
1409 (1024 * 1024, "MiB"),
1410 (1024, "KiB"),
1411 ];
1412 for (mult, label) in UNITS {
1413 if n >= *mult && n % mult == 0 {
1414 return format!("{}{label}", n / mult);
1415 }
1416 }
1417 format!("{n}")
1418}
1419
1420fn ser_byte_size<S: Serializer>(v: &Option<u64>, s: S) -> Result<S::Ok, S::Error> {
1421 match v {
1422 Some(n) => s.serialize_str(&render_byte_size(*n)),
1423 None => s.serialize_none(),
1424 }
1425}
1426
1427fn de_byte_size<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
1428 let opt: Option<String> = Option::deserialize(d)?;
1429 match opt {
1430 None => Ok(None),
1431 Some(s) => parse_byte_size(&s)
1432 .map(Some)
1433 .map_err(serde::de::Error::custom),
1434 }
1435}
1436
1437// ── duration codec ─────────────────────────────────────────────────────
1438
1439fn parse_duration(s: &str) -> Result<Duration, LimitsError> {
1440 // Whitespace-rejection arm — peer with the leading-`+` / fractional
1441 // arm below (`"+30s"`, `"1.5s"`) and the leading-zero arm below
1442 // (`"030s"`) on the same canonical-form render-determinism axis.
1443 // Until this gate landed the parser silently tolerated leading /
1444 // trailing / internal whitespace via the top-level `s.trim()` at
1445 // parse entry and the per-part `num_part.trim()` / `unit.trim()`
1446 // calls below, so every whitespace-carrying shape (`" 30s"` —
1447 // paste-from-aligned-doc / YAML-quoted-plain-scalar leading-space;
1448 // `"30s "` — paste-from-shell-history trailing-space; `"30 s"` —
1449 // paste-from-typography whitespace-between-magnitude-and-unit;
1450 // `"\t30s"` — paste-from-indented-doc / YAML-block-scalar tab byte;
1451 // `"30s\n"` — trailing newline from a multi-line paste) parsed to
1452 // the same `Duration::from_secs(30)` and serde silently round-
1453 // tripped to `"30s"` on the next emit (a *different* canonical
1454 // string) — breaking the THEORY.md Part V render-determinism
1455 // contract every typed slot carries.
1456 //
1457 // The canonical author shape is `<integer><unit>` (or `<integer>`
1458 // for the bare-integer-as-seconds shorthand) with no whitespace
1459 // bytes anywhere — every string [`render_duration`] emits carries
1460 // none, so the parser's accepted set must match for serialize /
1461 // deserialize to round-trip losslessly. This gate makes the pre-
1462 // existing `s.trim()` / `num_part.trim()` / `unit.trim()` calls
1463 // below strict no-ops on the accepted set (every byte-position
1464 // match they would perform is now already trimmed away by the
1465 // accepted set itself), while the arm surfaces every rejected
1466 // whitespace-carrying shape with a typed `WhitespaceInDuration`
1467 // diagnostic naming the offending byte and the canonical form the
1468 // author intended, peer with every prior canonical-form-drift arm
1469 // on this codec.
1470 //
1471 // Routed through the lifted
1472 // [`crate::render::find_ascii_whitespace_byte`] predicate — the
1473 // same source of truth the four peer typed-magnitude codec sites
1474 // share. `u8::is_ascii_whitespace()` at the predicate covers the
1475 // five WhatWG-conformant ASCII whitespace bytes (space, tab, LF,
1476 // FF, CR); the "single lifted predicate" discipline the peer
1477 // non-ASCII arm below carries on the strictly-complementary
1478 // Unicode `White_Space` class extends here to the ASCII byte set
1479 // as well.
1480 if let Some(byte) = crate::render::find_ascii_whitespace_byte(s) {
1481 return Err(LimitsError::WhitespaceInDuration {
1482 value: s.into(),
1483 byte,
1484 });
1485 }
1486 // Non-ASCII Unicode `White_Space` arm — the strictly-complementary
1487 // class the ASCII arm above cannot see. Same shape as the
1488 // `parse_byte_size` peer arm: `str::trim` uses
1489 // `char::is_whitespace` (Unicode `White_Space`, strictly wider
1490 // than the ASCII byte set), so an NBSP / LINE SEPARATOR / EM-SPACE
1491 // survives the byte-scan, gets silently stripped at parse entry,
1492 // and round-trips through `render_duration` to a *different*
1493 // canonical form on next emit — breaking the THEORY.md Part V
1494 // render-determinism contract. Closed here (`:limits :wall-clock`)
1495 // and at the three peer codec sites through the shared
1496 // [`crate::render::find_non_ascii_whitespace_char`] predicate.
1497 if let Some(ch) = crate::render::find_non_ascii_whitespace_char(s) {
1498 return Err(LimitsError::NonAsciiWhitespaceInDuration {
1499 value: s.into(),
1500 ch,
1501 codepoint: ch as u32,
1502 });
1503 }
1504 let s = s.trim();
1505 if s.is_empty() {
1506 return Err(LimitsError::EmptyDuration(s.into()));
1507 }
1508 let split_at = s.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(s.len());
1509 let (num_part, unit) = s.split_at(split_at);
1510 let num_trim = num_part.trim();
1511 // The canonical authoring form for `:limits :wall-clock` is
1512 // `<integer><unit>` — every magnitude `render_duration` emits is a
1513 // non-negative integer with no decimal point and no leading sign,
1514 // so the parser's accepted set must match for serialize/deserialize
1515 // to round-trip without canonical-form drift. Until this gate
1516 // landed the parser accepted any `f64`-shaped magnitude
1517 // (`"1.5s"` → 1500ms, `"1.0s"` → 1s, `"0.5m"` → 30s, `"+30s"` →
1518 // 30s) and serde silently round-tripped the value to a *different*
1519 // canonical string on the next emit (`"1.5s"` → 1500ms →
1520 // `"1500ms"`, `"1.0s"` → 1s → `"1s"`, `"0.5m"` → 30s → `"30s"`,
1521 // `"+30s"` → 30s → `"30s"`) — breaking the THEORY.md Part V
1522 // render-determinism contract every typed slot carries. The same
1523 // canonical-form discipline `parse_byte_size`'s integer-magnitude
1524 // gate (the immediate predecessor on the peer `:limits :memory`
1525 // codec) applies; this gate is the direct successor on the
1526 // `:limits :wall-clock` codec.
1527 //
1528 // Strict canonical form: every byte of the magnitude is an ASCII
1529 // digit (no `.`, no `+`, no `-`). On current Rust `u64::from_str`
1530 // permissively accepts a leading `+` (`"+30"` → 30) — that's a
1531 // canonical-drift shape `render_duration` never emits, so the
1532 // digit-only check is what closes the leading-sign class; relying
1533 // on `u64::from_str`'s strictness alone would silently admit it.
1534 // On non-digit-only inputs the gate distinguishes "non-canonical-
1535 // but-numeric" (parses as f64 or i64 — surfaced as the new
1536 // `NonIntegerDurationMagnitude` variant with a self-locating
1537 // diagnostic) from "garbage" (parses as neither — surfaced as the
1538 // existing `BadDurationMagnitude` so its narrower diagnostic
1539 // remains load-bearing).
1540 //
1541 // Routed through the lifted
1542 // [`crate::render::is_digit_only_magnitude`] predicate — the same
1543 // source of truth the four peer typed-magnitude codec sites share.
1544 let digit_only = crate::render::is_digit_only_magnitude(num_trim);
1545 if !digit_only {
1546 let numeric = num_trim.parse::<f64>().is_ok() || num_trim.parse::<i64>().is_ok();
1547 if numeric {
1548 return Err(LimitsError::NonIntegerDurationMagnitude {
1549 value: num_trim.into(),
1550 });
1551 }
1552 return Err(LimitsError::BadDurationMagnitude(num_part.into()));
1553 }
1554 // Leading-zero arm — peer with the `supervisor::duration_codec`
1555 // leading-zero arm (9178904) and the `rate_limit_codec`
1556 // leading-zero arm (4f46830) on the same canonical-form
1557 // render-determinism axis. The digit-only gate accepts `"030s"`,
1558 // `"00s"`, `"01h"`, `"0500ms"` as `u64::from_str` parses them
1559 // losslessly (= 30, 0, 1, 500), but `render_duration` emits the
1560 // leading-zero-stripped form (`"30s"`, `"0s"`, `"1h"`, `"500ms"`)
1561 // — a *different* canonical string on the next emit, breaking the
1562 // THEORY.md Part V render-determinism contract the same way
1563 // `"+30s"` did before the leading-`+` arm landed. The single-byte
1564 // magnitude `"0"` (or `"0s"` / `"0ms"`) round-trips losslessly
1565 // through `render_duration` (`render_duration(Duration::ZERO)`
1566 // emits `"0s"`) — the downstream semantic-zero gate
1567 // [`LimitsError::WallClockZero`] refuses zero-magnitude authoring
1568 // at the typed-validate layer above, so the single-byte `"0"`
1569 // stays in the accepted set at this codec layer and the
1570 // diagnostic partitioning between canonical-form drift (this arm)
1571 // and semantic-zero (the downstream gate) remains stable. Same
1572 // codec-layer / typed-validate-layer partition the peer codecs
1573 // preserve.
1574 //
1575 // Routed through the lifted
1576 // [`crate::render::is_leading_zero_padded_magnitude`] predicate —
1577 // the same source of truth the four peer typed-magnitude codec
1578 // sites share.
1579 if crate::render::is_leading_zero_padded_magnitude(num_trim) {
1580 return Err(LimitsError::LeadingZeroDurationMagnitude {
1581 value: num_trim.into(),
1582 });
1583 }
1584 // The digit-only gate guarantees every byte is `[0-9]`, and the
1585 // leading-zero arm above guarantees the magnitude is either the
1586 // single byte `"0"` or starts with `[1-9]`, so the only way
1587 // `u64::from_str` can fail here is overflow.
1588 let num: u64 = num_trim.parse::<u64>().map_err(|_| {
1589 LimitsError::BadDurationMagnitude(format!(
1590 "{num_trim} (digit-only magnitude overflows u64)"
1591 ))
1592 })?;
1593 // Multiply on u64 with overflow detection — every unit conversion
1594 // is integer-exact for an integer magnitude, so the codec drops
1595 // `Duration::from_secs_f64` entirely. Overflow surfaces at parse
1596 // time with a parser-shaped diagnostic naming the offending
1597 // magnitude × unit pair (matches `parse_byte_size`'s overflow arm).
1598 let unit_trim = unit.trim();
1599 let dur = match unit_trim {
1600 "ms" => Duration::from_millis(num),
1601 "s" | "" => Duration::from_secs(num),
1602 "m" => Duration::from_secs(num.checked_mul(60).ok_or_else(|| {
1603 LimitsError::BadDurationMagnitude(format!(
1604 "{num_trim}{unit_trim} overflows u64 (magnitude × 60 > 2^64-1)"
1605 ))
1606 })?),
1607 "h" => Duration::from_secs(num.checked_mul(3600).ok_or_else(|| {
1608 LimitsError::BadDurationMagnitude(format!(
1609 "{num_trim}{unit_trim} overflows u64 (magnitude × 3600 > 2^64-1)"
1610 ))
1611 })?),
1612 other => {
1613 return Err(LimitsError::UnknownDurationUnit { unit: other.into() });
1614 }
1615 };
1616 Ok(dur)
1617}
1618
1619fn render_duration(d: Duration) -> String {
1620 let total_ms = d.as_millis();
1621 if total_ms == 0 {
1622 return "0s".into();
1623 }
1624 if total_ms % (3600 * 1000) == 0 {
1625 return format!("{}h", total_ms / (3600 * 1000));
1626 }
1627 if total_ms % (60 * 1000) == 0 {
1628 return format!("{}m", total_ms / (60 * 1000));
1629 }
1630 if total_ms % 1000 == 0 {
1631 return format!("{}s", total_ms / 1000);
1632 }
1633 format!("{total_ms}ms")
1634}
1635
1636fn ser_duration<S: Serializer>(v: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
1637 match v {
1638 Some(d) => s.serialize_str(&render_duration(*d)),
1639 None => s.serialize_none(),
1640 }
1641}
1642
1643fn de_duration<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
1644 let opt: Option<String> = Option::deserialize(d)?;
1645 match opt {
1646 None => Ok(None),
1647 Some(s) => parse_duration(&s)
1648 .map(Some)
1649 .map_err(serde::de::Error::custom),
1650 }
1651}
1652
1653// ── millicores codec ───────────────────────────────────────────────────
1654
1655fn parse_millicores(s: &str) -> Result<u32, LimitsError> {
1656 // Whitespace-rejection arm — peer with the `parse_byte_size` (24a8ad4),
1657 // `parse_duration` (ebc3a75), `supervisor::duration_codec` (a7ae622),
1658 // and `rate_limit_codec` (1ad7755) whitespace-rejection arms on the
1659 // same canonical-form render-determinism axis. Until this gate landed
1660 // the parser silently tolerated leading / trailing / internal
1661 // whitespace via the top-level `s.trim()` at parse entry and the
1662 // per-part `magnitude.trim()` calls below, so every whitespace-carrying
1663 // shape (`" 500m"` — paste-from-aligned-doc / YAML-quoted-plain-scalar
1664 // leading-space; `"500m "` — paste-from-shell-history trailing-space;
1665 // `"500 m"` — paste-from-typography whitespace-between-magnitude-and-
1666 // unit; `"\t500m"` — paste-from-indented-doc / YAML-block-scalar tab
1667 // byte; `"500m\n"` — trailing newline from a multi-line paste) parsed
1668 // to the same 500 millicores and serde silently round-tripped to
1669 // `"500m"` on the next emit (a *different* canonical string) —
1670 // breaking the THEORY.md Part V render-determinism contract every
1671 // typed slot carries.
1672 //
1673 // The canonical author shape is `<integer>m` (or `<integer>` for the
1674 // bare-core shorthand) with no whitespace bytes anywhere — every
1675 // string [`render_millicores`] emits carries none, so the parser's
1676 // accepted set must match for serialize / deserialize to round-trip
1677 // losslessly. This gate makes the pre-existing `s.trim()` /
1678 // `magnitude.trim()` calls below strict no-ops on the accepted set
1679 // (every byte-position match they would perform is now already
1680 // trimmed away by the accepted set itself), while the arm surfaces
1681 // every rejected whitespace-carrying shape with a typed
1682 // `WhitespaceInMillicores` diagnostic naming the offending byte and
1683 // the canonical form the author intended, peer with every prior
1684 // canonical-form-drift arm on this codec (`NonIntegerMillicoreMagnitude`,
1685 // `LeadingZeroMillicoreMagnitude`).
1686 //
1687 // Routed through the lifted
1688 // [`crate::render::find_ascii_whitespace_byte`] predicate — the
1689 // same source of truth the four peer typed-magnitude codec sites
1690 // share. `u8::is_ascii_whitespace()` at the predicate covers the
1691 // five WhatWG-conformant ASCII whitespace bytes (space, tab, LF,
1692 // FF, CR); the "single lifted predicate" discipline the peer
1693 // non-ASCII arm below carries on the strictly-complementary
1694 // Unicode `White_Space` class extends here to the ASCII byte set
1695 // as well.
1696 if let Some(byte) = crate::render::find_ascii_whitespace_byte(s) {
1697 return Err(LimitsError::WhitespaceInMillicores {
1698 value: s.into(),
1699 byte,
1700 });
1701 }
1702 // Non-ASCII Unicode `White_Space` arm — the strictly-complementary
1703 // class the ASCII arm above cannot see. Same shape as the peer
1704 // `parse_byte_size` / `parse_duration` arms (1b75b38): `str::trim`
1705 // uses `char::is_whitespace` (Unicode `White_Space`, strictly wider
1706 // than the ASCII byte set), so an NBSP (`\u{00A0}`) / LINE SEPARATOR
1707 // (`\u{2028}`) / EM-SPACE (`\u{2003}`) survives the byte-scan, gets
1708 // silently stripped at parse entry, and round-trips through
1709 // `render_millicores` to a *different* canonical form on the next
1710 // emit — breaking the THEORY.md Part V render-determinism contract.
1711 // Closed here (`:limits :cpu`) through the shared
1712 // [`crate::render::find_non_ascii_whitespace_char`] predicate — the
1713 // "single lifted predicate across every typed-magnitude codec site"
1714 // trajectory 1b75b38 landed on the four peer codecs, extended here
1715 // to the fifth.
1716 if let Some(ch) = crate::render::find_non_ascii_whitespace_char(s) {
1717 return Err(LimitsError::NonAsciiWhitespaceInMillicores {
1718 value: s.into(),
1719 ch,
1720 codepoint: ch as u32,
1721 });
1722 }
1723 let s_trim = s.trim();
1724 if s_trim.is_empty() {
1725 return Err(LimitsError::BadMillicores(s.into()));
1726 }
1727 let (magnitude, has_m_suffix) = match s_trim.strip_suffix('m') {
1728 Some(stripped) => (stripped.trim(), true),
1729 None => (s_trim, false),
1730 };
1731 if magnitude.is_empty() {
1732 // Bare `"m"` (or `" m "`) — no magnitude was authored. The
1733 // canonical millicores authoring form requires a magnitude in
1734 // front of the unit (`"500m"`, not `"m"`). Surface as
1735 // `BadMillicores` so the existing narrower-arm wording stays
1736 // load-bearing for "no recognizable magnitude" inputs.
1737 return Err(LimitsError::BadMillicores(s.into()));
1738 }
1739 // The canonical authoring form for `:limits :cpu` is `<integer>m`
1740 // (Kubernetes millicores) or the bare-core shorthand `<integer>`
1741 // (`"2"` = 2000 millicores). Every magnitude `render_millicores`
1742 // emits is a non-negative integer (`format!("{m}m")`) — no decimal
1743 // point, no leading sign — so the parser's accepted set must match
1744 // for serialize/deserialize to round-trip without canonical-form
1745 // drift. Until this gate landed the parser accepted any
1746 // `u32::from_str`-shaped magnitude (`"+500m"` → 500, `"+2"` →
1747 // 2000) and serde silently round-tripped the value to a *different*
1748 // canonical string on the next emit (`"+500m"` → `"500m"`, `"+2"`
1749 // → `"2000m"`) — breaking the THEORY.md Part V render-determinism
1750 // contract every typed slot carries. Closes the sixth (and last)
1751 // typed-codec surface in caixa-core on the integer-magnitude
1752 // canonical-form axis, peer with the five duration / byte-size /
1753 // rate-limit codecs the prior trajectory (1c55a2a / 818dd38 /
1754 // d1fd67b / f479c41 / d53c922) covered.
1755 //
1756 // Strict canonical form: every byte of the magnitude is an ASCII
1757 // digit (no `.`, no `+`, no `-`). On current Rust `u32::from_str`
1758 // permissively accepts a leading `+` (`"+500"` → 500) — that's a
1759 // canonical-drift shape `render_millicores` never emits, so the
1760 // digit-only check is what closes the leading-sign class; relying
1761 // on `u32::from_str`'s strictness alone would silently admit it.
1762 // On non-digit-only inputs the gate distinguishes "non-canonical-
1763 // but-numeric" (parses as f64 or i64 — surfaced as the new
1764 // `NonIntegerMillicoreMagnitude` variant naming the offending
1765 // magnitude verbatim with the canonical-form remediation) from
1766 // "garbage" (parses as neither — surfaced as the existing
1767 // `BadMillicores` so its narrower diagnostic shape remains
1768 // load-bearing for the not-a-numeric-input class).
1769 //
1770 // Routed through the lifted
1771 // [`crate::render::is_digit_only_magnitude`] predicate — the same
1772 // source of truth the four peer typed-magnitude codec sites share.
1773 // The predicate carries a `!<var>.is_empty()` gate that is
1774 // strictly no-op here (the `magnitude.is_empty()` arm above
1775 // already surfaces an empty magnitude as
1776 // [`LimitsError::BadMillicores`] before this line is reached), so
1777 // the semantics are preserved verbatim: on every reachable input
1778 // the predicate returns `magnitude.bytes().all(|b|
1779 // b.is_ascii_digit())`, byte-for-byte what the removed inline
1780 // expression computed.
1781 let digit_only = crate::render::is_digit_only_magnitude(magnitude);
1782 if !digit_only {
1783 let numeric = magnitude.parse::<f64>().is_ok() || magnitude.parse::<i64>().is_ok();
1784 if numeric {
1785 return Err(LimitsError::NonIntegerMillicoreMagnitude {
1786 value: magnitude.into(),
1787 });
1788 }
1789 return Err(LimitsError::BadMillicores(s.into()));
1790 }
1791 // Leading-zero arm — peer with the `parse_byte_size` leading-zero
1792 // arm (cea9a78), the `parse_duration` leading-zero arm (39762d7),
1793 // the `supervisor::duration_codec` leading-zero arm (9178904) and
1794 // the `rate_limit_codec` leading-zero arm (4f46830) on the same
1795 // canonical-form render-determinism axis. The digit-only gate
1796 // accepts `"0500m"`, `"00m"`, `"02"`, `"01500m"` as `u32::from_str`
1797 // parses them losslessly (= 500, 0, 2, 1500), but `render_millicores`
1798 // emits the leading-zero-stripped form (`"500m"`, `"0m"`, `"2000m"`,
1799 // `"1500m"`) — a *different* canonical string on the next emit,
1800 // breaking the THEORY.md Part V render-determinism contract the
1801 // same way `"+500m"` did before the leading-`+` arm landed. The
1802 // single-byte magnitude `"0"` (or `"0m"`) round-trips losslessly
1803 // through `render_millicores` (`render_millicores(0)` emits `"0m"`)
1804 // — the downstream semantic-zero gate [`LimitsError::CpuZero`]
1805 // refuses zero-magnitude authoring at the typed-validate layer
1806 // above, so the single-byte `"0"` stays in the accepted set at this
1807 // codec layer and the diagnostic partitioning between canonical-
1808 // form drift (this arm) and semantic-zero (the downstream gate)
1809 // remains stable. Same codec-layer / typed-validate-layer partition
1810 // the peer codecs preserve. Closes the sixth (and last) typed
1811 // numeric-codec surface in caixa-core on the integer-magnitude
1812 // leading-zero axis — the trajectory the prior `parse_byte_size`
1813 // arm (cea9a78) explicitly named.
1814 //
1815 // Routed through the lifted
1816 // [`crate::render::is_leading_zero_padded_magnitude`] predicate —
1817 // the same source of truth the four peer typed-magnitude codec
1818 // sites share.
1819 if crate::render::is_leading_zero_padded_magnitude(magnitude) {
1820 return Err(LimitsError::LeadingZeroMillicoreMagnitude {
1821 value: magnitude.into(),
1822 });
1823 }
1824 // The digit-only gate guarantees every byte is `[0-9]`, and the
1825 // leading-zero arm above guarantees the magnitude is either the
1826 // single byte `"0"` or starts with `[1-9]`, so the only way
1827 // `u32::from_str` can fail here is overflow (the magnitude exceeds
1828 // `u32::MAX`). Surface that as `BadMillicores` with an overflow-
1829 // shaped wording so the diagnostic names the offending magnitude
1830 // verbatim rather than collapsing onto the non-canonical arm —
1831 // matches `parse_byte_size` / `parse_duration` / `rate_limit_codec`
1832 // overflow-arm shape on the peer typed codecs.
1833 let num: u32 = magnitude.parse::<u32>().map_err(|_| {
1834 LimitsError::BadMillicores(format!("{magnitude} (digit-only magnitude overflows u32)"))
1835 })?;
1836 if has_m_suffix {
1837 Ok(num)
1838 } else {
1839 // Bare-core shorthand: `"2"` = 2000 millicores. Use
1840 // `checked_mul` (not the prior `saturating_mul`) so a
1841 // magnitude that overflows u32 on the × 1000 conversion
1842 // surfaces a parser-shaped diagnostic at parse time rather
1843 // than silently saturating to `u32::MAX` (which would land
1844 // as the cap value far from the author's intent and bypass
1845 // any future validate-time upper-bound gate the `:cpu` axis
1846 // grows). Matches `parse_byte_size`'s overflow-arm shape on
1847 // the magnitude × unit multiply.
1848 num.checked_mul(1000).ok_or_else(|| {
1849 LimitsError::BadMillicores(format!(
1850 "{magnitude} cores × 1000 overflows u32 (write the value in millicores: max \"{}m\")",
1851 u32::MAX
1852 ))
1853 })
1854 }
1855}
1856
1857fn render_millicores(m: u32) -> String {
1858 format!("{m}m")
1859}
1860
1861fn ser_millicores<S: Serializer>(v: &Option<u32>, s: S) -> Result<S::Ok, S::Error> {
1862 match v {
1863 Some(m) => s.serialize_str(&render_millicores(*m)),
1864 None => s.serialize_none(),
1865 }
1866}
1867
1868fn de_millicores<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u32>, D::Error> {
1869 let opt: Option<String> = Option::deserialize(d)?;
1870 match opt {
1871 None => Ok(None),
1872 Some(s) => parse_millicores(&s)
1873 .map(Some)
1874 .map_err(serde::de::Error::custom),
1875 }
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880 use super::*;
1881
1882 #[test]
1883 fn parse_byte_size_known_units() {
1884 assert_eq!(parse_byte_size("64MiB").unwrap(), 64 * 1024 * 1024);
1885 assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
1886 assert_eq!(parse_byte_size("512KiB").unwrap(), 512 * 1024);
1887 assert_eq!(parse_byte_size("1KB").unwrap(), 1_000);
1888 assert_eq!(parse_byte_size("1024").unwrap(), 1024);
1889 }
1890
1891 #[test]
1892 fn parse_byte_size_rejects_unknown() {
1893 assert!(matches!(
1894 parse_byte_size("1YiB"),
1895 Err(LimitsError::UnknownByteUnit { .. })
1896 ));
1897 assert!(matches!(
1898 parse_byte_size("not-a-number"),
1899 Err(LimitsError::BadByteMagnitude(_))
1900 ));
1901 }
1902
1903 #[test]
1904 fn parse_duration_known_units() {
1905 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
1906 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
1907 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
1908 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
1909 }
1910
1911 #[test]
1912 fn parse_millicores_both_forms() {
1913 assert_eq!(parse_millicores("500m").unwrap(), 500);
1914 assert_eq!(parse_millicores("2").unwrap(), 2000);
1915 }
1916
1917 #[test]
1918 fn render_byte_size_canonical() {
1919 assert_eq!(render_byte_size(64 * 1024 * 1024), "64MiB");
1920 assert_eq!(render_byte_size(1024 * 1024 * 1024), "1GiB");
1921 assert_eq!(render_byte_size(1024), "1KiB");
1922 assert_eq!(render_byte_size(123), "123");
1923 }
1924
1925 #[test]
1926 fn render_duration_canonical() {
1927 assert_eq!(render_duration(Duration::from_secs(30)), "30s");
1928 assert_eq!(render_duration(Duration::from_millis(500)), "500ms");
1929 assert_eq!(render_duration(Duration::from_secs(120)), "2m");
1930 assert_eq!(render_duration(Duration::from_secs(3600)), "1h");
1931 }
1932
1933 #[test]
1934 fn limits_round_trip_through_json() {
1935 let limits = LimitsSpec {
1936 memory: Some(64 * 1024 * 1024),
1937 fuel: Some(1_000_000),
1938 wall_clock: Some(Duration::from_secs(30)),
1939 cpu: Some(500),
1940 };
1941 let json = serde_json::to_string(&limits).unwrap();
1942 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
1943 assert_eq!(limits, back);
1944 }
1945
1946 #[test]
1947 fn empty_limits_serialises_to_empty_object() {
1948 let limits = LimitsSpec::default();
1949 assert!(limits.is_empty());
1950 let json = serde_json::to_string(&limits).unwrap();
1951 assert_eq!(json, "{}");
1952 }
1953
1954 // ── drift-detection: serde-derive-to-M2_LIMITS_KEY_* identity ────────
1955
1956 #[test]
1957 fn limits_spec_serde_keys_match_lifted_m2_limits_key_consts() {
1958 // Load-bearing invariant: the four `M2_LIMITS_KEY_*` consts
1959 // (`M2_LIMITS_KEY_MEMORY` / `M2_LIMITS_KEY_FUEL` /
1960 // `M2_LIMITS_KEY_WALL_CLOCK` / `M2_LIMITS_KEY_CPU`) name the
1961 // exact camelCase JSON keys the `#[serde(rename_all = "camelCase")]`
1962 // attribute on `LimitsSpec` emits, and every test-side probe
1963 // across the caixa-core / caixa-flux / caixa-helm renderer test
1964 // fixtures navigates into the rendered `:limits` overlay
1965 // sub-block by consulting one of these four `&'static str`s.
1966 // Serialize a fully-populated LimitsSpec and pin that each
1967 // canonical byte-sequence appears verbatim in the JSON — a
1968 // future accidental `rename_all = "snake_case"` /
1969 // `"kebab-case"` / verbatim-field-name flip at the derive
1970 // attribute (any of which would silently break every test-side
1971 // probe that reaches for one of the four consts) surfaces here
1972 // as a build-time test failure at `limits.rs`, not as an
1973 // apply-time `.get(<stale-canonical-const>)` returning `None`
1974 // far from the derive-attr drift's commit. Same discipline the
1975 // sibling M3 `PlacementStrategy::as_str` lift (0a2f653)
1976 // established on the peer per-`:placement :estrategia` axis:
1977 // one canonical byte-string per typed sub-key axis, pinned to
1978 // the load-bearing serde derivation at the type itself.
1979 let limits = LimitsSpec {
1980 memory: Some(64 * 1024 * 1024),
1981 fuel: Some(1_000_000),
1982 wall_clock: Some(Duration::from_secs(30)),
1983 cpu: Some(500),
1984 };
1985 let json = serde_json::to_string(&limits).unwrap();
1986 for key in [
1987 crate::render::M2_LIMITS_KEY_MEMORY,
1988 crate::render::M2_LIMITS_KEY_FUEL,
1989 crate::render::M2_LIMITS_KEY_WALL_CLOCK,
1990 crate::render::M2_LIMITS_KEY_CPU,
1991 ] {
1992 let quoted = format!("\"{key}\"");
1993 assert!(
1994 json.contains("ed),
1995 "serialized LimitsSpec must carry the lifted \
1996 M2_LIMITS_KEY_* byte-sequence {quoted} verbatim in \
1997 the JSON emission (got: {json})",
1998 );
1999 }
2000 }
2001
2002 #[test]
2003 fn m2_limits_key_consts_are_pairwise_distinct() {
2004 // Cross-axis drift-detection pin: a future collapse of two
2005 // canonical sub-key byte-strings onto the same value (e.g. an
2006 // accidental copy-paste flip of `M2_LIMITS_KEY_CPU` to also
2007 // read `"memory"`) would silently reroute every test-side
2008 // probe on one axis onto the sibling axis's overlay entry and
2009 // pass every propagation-probe test that expected only the
2010 // stale axis's value. Peer of the sibling three-way distinct
2011 // pin on the `FLUX_GITREPOSITORY_REF_KEY_*` trio (7d40380).
2012 let all = [
2013 crate::render::M2_LIMITS_KEY_MEMORY,
2014 crate::render::M2_LIMITS_KEY_FUEL,
2015 crate::render::M2_LIMITS_KEY_WALL_CLOCK,
2016 crate::render::M2_LIMITS_KEY_CPU,
2017 ];
2018 for (i, a) in all.iter().enumerate() {
2019 for b in all.iter().skip(i + 1) {
2020 assert_ne!(
2021 a, b,
2022 "M2_LIMITS_KEY_* consts must be pairwise-distinct \
2023 canonical byte-sequences — got `{a}` == `{b}`",
2024 );
2025 }
2026 }
2027 }
2028
2029 #[test]
2030 fn m2_limits_key_consts_are_lower_camel_case_shape() {
2031 // Shape-pin: every `M2_LIMITS_KEY_*` const must be a
2032 // lowerCamelCase byte-sequence (no `snake_case` underscores,
2033 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
2034 // whitespace / colons / dots) — the canonical shape the
2035 // `#[serde(rename_all = "camelCase")]` derive produces on
2036 // `LimitsSpec`. A future flip to a non-camelCase attribute at
2037 // the derive surfaces both here (this test fails on the
2038 // stale-constant shape) and at
2039 // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
2040 // (that test fails on the mismatch between const and derive).
2041 for key in [
2042 crate::render::M2_LIMITS_KEY_MEMORY,
2043 crate::render::M2_LIMITS_KEY_FUEL,
2044 crate::render::M2_LIMITS_KEY_WALL_CLOCK,
2045 crate::render::M2_LIMITS_KEY_CPU,
2046 ] {
2047 assert!(
2048 !key.is_empty(),
2049 "M2_LIMITS_KEY_* must be non-empty (got {key:?})"
2050 );
2051 let first = key.chars().next().unwrap();
2052 assert!(
2053 first.is_ascii_lowercase(),
2054 "M2_LIMITS_KEY_* must lead with an ASCII-lowercase byte \
2055 (got {key:?}, leads with {first:?})",
2056 );
2057 assert!(
2058 key.chars().all(|c| c.is_ascii_alphanumeric()),
2059 "M2_LIMITS_KEY_* must be ASCII-alphanumeric only \
2060 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
2061 );
2062 }
2063 }
2064
2065 // ── value-shape: zero on any declared axis is rejected ────────────────
2066
2067 #[test]
2068 fn validate_accepts_default_unbounded_limits() {
2069 // Every axis None → "no bound declared" is the omit-the-slot
2070 // shape and stays valid. This is the pre-M2 default behaviour.
2071 LimitsSpec::default().validate().unwrap();
2072 }
2073
2074 #[test]
2075 fn validate_accepts_full_nonzero_limits() {
2076 let l = LimitsSpec {
2077 memory: Some(64 * 1024 * 1024),
2078 fuel: Some(1_000_000),
2079 wall_clock: Some(Duration::from_secs(30)),
2080 cpu: Some(500),
2081 };
2082 l.validate().unwrap();
2083 }
2084
2085 #[test]
2086 fn validate_rejects_zero_memory() {
2087 let l = LimitsSpec {
2088 memory: Some(0),
2089 ..Default::default()
2090 };
2091 assert_eq!(l.validate().unwrap_err(), LimitsError::MemoryZero);
2092 }
2093
2094 #[test]
2095 fn validate_rejects_zero_fuel() {
2096 let l = LimitsSpec {
2097 fuel: Some(0),
2098 ..Default::default()
2099 };
2100 assert_eq!(l.validate().unwrap_err(), LimitsError::FuelZero);
2101 }
2102
2103 #[test]
2104 fn validate_rejects_zero_wall_clock() {
2105 let l = LimitsSpec {
2106 wall_clock: Some(Duration::ZERO),
2107 ..Default::default()
2108 };
2109 assert_eq!(l.validate().unwrap_err(), LimitsError::WallClockZero);
2110 }
2111
2112 #[test]
2113 fn validate_rejects_zero_cpu() {
2114 let l = LimitsSpec {
2115 cpu: Some(0),
2116 ..Default::default()
2117 };
2118 assert_eq!(l.validate().unwrap_err(), LimitsError::CpuZero);
2119 }
2120
2121 #[test]
2122 fn validate_rejects_first_zero_axis_deterministically() {
2123 // Memory is checked first; with multiple zero axes, the
2124 // diagnostic names :memory rather than reporting some other
2125 // axis non-deterministically.
2126 let l = LimitsSpec {
2127 memory: Some(0),
2128 fuel: Some(0),
2129 wall_clock: Some(Duration::ZERO),
2130 cpu: Some(0),
2131 };
2132 assert_eq!(l.validate().unwrap_err(), LimitsError::MemoryZero);
2133 }
2134
2135 // ── value-shape: :memory upper bound — wasm32-wasip2 4 GiB ceiling ────
2136
2137 #[test]
2138 fn wasm32_memory_cap_matches_parsed_4_gib() {
2139 // The cap constant tracks the canonical "4 GiB" byte-size
2140 // codec output structurally — drift between the codec's
2141 // accepted magnitude for `"4GiB"` and the validate gate's
2142 // accepted upper bound would surface here, not as a silent
2143 // round-trip break at the renderer layer. Same single-source-
2144 // of-truth shape the is_canonical_rate_limit_window predicate
2145 // gives the rate-limit window set.
2146 assert_eq!(
2147 parse_byte_size("4GiB").unwrap(),
2148 LIMITS_MEMORY_WASM32_MAX_BYTES
2149 );
2150 assert_eq!(LIMITS_MEMORY_WASM32_MAX_BYTES, 4 * 1024 * 1024 * 1024);
2151 assert_eq!(LIMITS_MEMORY_WASM32_MAX_BYTES, 1u64 << 32);
2152 }
2153
2154 #[test]
2155 fn validate_accepts_memory_at_wasm32_cap() {
2156 // 4 GiB exactly is the wasm32 in-spec maximum — `2^16 pages ×
2157 // 2^16 bytes/page`. The validate gate is inclusive on the
2158 // upper end (mirrors the inclusive lower-end rejection: zero
2159 // is *out*, one is *in*; 4 GiB+1 is *out*, 4 GiB is *in*).
2160 let l = LimitsSpec {
2161 memory: Some(LIMITS_MEMORY_WASM32_MAX_BYTES),
2162 ..Default::default()
2163 };
2164 l.validate().unwrap();
2165 }
2166
2167 #[test]
2168 fn validate_rejects_memory_one_byte_above_wasm32_cap() {
2169 // Boundary case: exactly 1 byte past the cap. Catches a
2170 // future "strictly less than" half-measure and pins the
2171 // diagnostic to name the offending byte count verbatim.
2172 let bytes = LIMITS_MEMORY_WASM32_MAX_BYTES + 1;
2173 let l = LimitsSpec {
2174 memory: Some(bytes),
2175 ..Default::default()
2176 };
2177 assert_eq!(
2178 l.validate().unwrap_err(),
2179 LimitsError::MemoryExceedsWasm32Cap { bytes }
2180 );
2181 }
2182
2183 #[test]
2184 fn validate_rejects_memory_8_gib() {
2185 // The "obvious authoring footgun" case: a value the byte-size
2186 // codec accepts cleanly (`"8GiB"` → 8 * 1024^3 bytes) and
2187 // serde round-trips silently, but no wasm32 component can
2188 // honor. Until this gate landed `validate` accepted it.
2189 let bytes = parse_byte_size("8GiB").unwrap();
2190 let l = LimitsSpec {
2191 memory: Some(bytes),
2192 ..Default::default()
2193 };
2194 assert_eq!(
2195 l.validate().unwrap_err(),
2196 LimitsError::MemoryExceedsWasm32Cap { bytes }
2197 );
2198 }
2199
2200 #[test]
2201 fn validate_memory_zero_takes_precedence_over_cap_check() {
2202 // Memory zero is structurally meaningless under *any* wasm
2203 // engine (zero-cap traps the first allocation); above-cap is
2204 // wasm32-specific. The zero arm fires first so the canonical
2205 // "omit the slot for unbounded" remediation in the existing
2206 // MemoryZero diagnostic still leads — pinning this precedence
2207 // guards against a future re-ordering that would surface the
2208 // wasm32-specific message in the case where the simpler
2209 // zero-floor message is more actionable.
2210 let l = LimitsSpec {
2211 memory: Some(0),
2212 ..Default::default()
2213 };
2214 assert_eq!(l.validate().unwrap_err(), LimitsError::MemoryZero);
2215 }
2216
2217 #[test]
2218 fn validate_rejects_memory_cap_before_other_axes() {
2219 // With both an above-cap :memory and a zero :fuel, the
2220 // diagnostic names :memory rather than :fuel — peer of the
2221 // existing `validate_rejects_first_zero_axis_deterministically`
2222 // ordering pin.
2223 let bytes = LIMITS_MEMORY_WASM32_MAX_BYTES + 1024;
2224 let l = LimitsSpec {
2225 memory: Some(bytes),
2226 fuel: Some(0),
2227 wall_clock: Some(Duration::ZERO),
2228 cpu: Some(0),
2229 };
2230 assert_eq!(
2231 l.validate().unwrap_err(),
2232 LimitsError::MemoryExceedsWasm32Cap { bytes }
2233 );
2234 }
2235
2236 #[test]
2237 fn above_cap_value_still_round_trips_through_serde() {
2238 // The byte-size codec accepts the above-cap value (the cap
2239 // lives in the validate gate, not the codec). This pins that
2240 // the structural property is "above-cap is rejected by
2241 // validate" — not "above-cap is unparseable by the codec";
2242 // the latter would prevent the diagnostic from naming the
2243 // offending byte count at all, since deserialize would fail
2244 // first.
2245 let l = LimitsSpec {
2246 memory: Some(LIMITS_MEMORY_WASM32_MAX_BYTES + 1),
2247 ..Default::default()
2248 };
2249 let json = serde_json::to_string(&l).unwrap();
2250 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
2251 assert_eq!(l, back);
2252 assert!(back.validate().is_err());
2253 }
2254
2255 // ── value-shape: :memory lower bound — wasm32-wasip2 64 KiB page floor ─
2256
2257 #[test]
2258 fn wasm32_memory_page_matches_parsed_64_kib() {
2259 // The page-floor constant tracks the canonical "64 KiB"
2260 // byte-size codec output structurally — drift between the
2261 // codec's accepted magnitude for `"64KiB"` and the validate
2262 // gate's accepted lower bound would surface here, not as a
2263 // silent round-trip break at the renderer layer. Same single-
2264 // source-of-truth shape `wasm32_memory_cap_matches_parsed_4_gib`
2265 // pins on the peer upper-cap bound and
2266 // `is_canonical_rate_limit_window` gives the rate-limit window
2267 // set. The page-size identities (2^16, integer-divides the
2268 // upper cap exactly 2^16 times) are pinned alongside so a
2269 // future memory64-target opt-in raising one bound surfaces
2270 // here if the other bound's relationship to it drifts.
2271 assert_eq!(
2272 parse_byte_size("64KiB").unwrap(),
2273 LIMITS_MEMORY_WASM32_PAGE_BYTES
2274 );
2275 assert_eq!(LIMITS_MEMORY_WASM32_PAGE_BYTES, 64 * 1024);
2276 assert_eq!(LIMITS_MEMORY_WASM32_PAGE_BYTES, 1u64 << 16);
2277 assert_eq!(
2278 LIMITS_MEMORY_WASM32_MAX_BYTES / LIMITS_MEMORY_WASM32_PAGE_BYTES,
2279 1u64 << 16,
2280 "the wasm32 page count cap is 2^16 pages exactly",
2281 );
2282 assert_eq!(
2283 LIMITS_MEMORY_WASM32_MAX_BYTES % LIMITS_MEMORY_WASM32_PAGE_BYTES,
2284 0
2285 );
2286 }
2287
2288 #[test]
2289 fn validate_rejects_memory_below_wasm32_page() {
2290 // The fail-before-pass-after pin: until this gate landed a
2291 // `(:memory "32KiB")` (or any programmatic struct literal with
2292 // a sub-page byte count — `LimitsSpec { memory: Some(50000),
2293 // .. }`) silently passed validate, the byte-size codec
2294 // round-tripped cleanly through serde, and the wasm-engine
2295 // either refused instantiation (`memory minimum size of 1
2296 // pages exceeds memory limits` on any cdylib-shaped component
2297 // declaring `(memory 1)`) or trapped the first `memory.grow(1)`
2298 // far from the source caixa.lisp.
2299 let bytes = parse_byte_size("32KiB").unwrap();
2300 let l = LimitsSpec {
2301 memory: Some(bytes),
2302 ..Default::default()
2303 };
2304 assert_eq!(
2305 l.validate().unwrap_err(),
2306 LimitsError::MemoryBelowWasm32Page { bytes }
2307 );
2308 }
2309
2310 #[test]
2311 fn validate_rejects_memory_one_byte_below_page() {
2312 // Boundary case: exactly 1 byte below the page-size floor
2313 // (`LIMITS_MEMORY_WASM32_PAGE_BYTES - 1` = 65535 bytes). Pins
2314 // the inclusive-upper-end / strict-lower-end relationship on
2315 // the page-floor arm: 65535 is *out*, 65536 is *in*. Catches a
2316 // future "strictly greater than" half-measure and matches the
2317 // peer `validate_rejects_memory_one_byte_above_wasm32_cap`
2318 // shape on the top edge.
2319 let bytes = LIMITS_MEMORY_WASM32_PAGE_BYTES - 1;
2320 let l = LimitsSpec {
2321 memory: Some(bytes),
2322 ..Default::default()
2323 };
2324 assert_eq!(
2325 l.validate().unwrap_err(),
2326 LimitsError::MemoryBelowWasm32Page { bytes }
2327 );
2328 }
2329
2330 #[test]
2331 fn validate_rejects_memory_one_byte() {
2332 // The far-floor case: a `(:memory "1")` cap is non-zero (so
2333 // `MemoryZero` doesn't fire) but structurally cannot hold any
2334 // wasm linear memory page. The page-floor gate at this layer
2335 // surfaces a self-locating diagnostic naming the offending
2336 // byte count verbatim rather than a downstream wasm-engine
2337 // instantiation failure whose error message points at the
2338 // engine's internals, not the caixa.lisp `:memory` slot.
2339 let l = LimitsSpec {
2340 memory: Some(1),
2341 ..Default::default()
2342 };
2343 assert_eq!(
2344 l.validate().unwrap_err(),
2345 LimitsError::MemoryBelowWasm32Page { bytes: 1 }
2346 );
2347 }
2348
2349 #[test]
2350 fn validate_accepts_memory_at_wasm32_page() {
2351 // 64 KiB exactly is the wasm32 linear-memory page size — the
2352 // smallest cap that admits one wasm `(memory 1)` page. The
2353 // page-floor gate is inclusive on the lower end (mirrors the
2354 // inclusive upper-end acceptance: 4 GiB is *in*, 4 GiB+1 is
2355 // *out*; 64 KiB is *in*, 64 KiB-1 is *out*).
2356 let l = LimitsSpec {
2357 memory: Some(LIMITS_MEMORY_WASM32_PAGE_BYTES),
2358 ..Default::default()
2359 };
2360 l.validate().unwrap();
2361 }
2362
2363 #[test]
2364 fn validate_accepts_multi_page_memory() {
2365 // The positive-control sweep: every typed `:memory` cap that
2366 // admits at least one wasm linear memory page (i.e. ≥
2367 // `LIMITS_MEMORY_WASM32_PAGE_BYTES`) passes `validate`. Sweeps
2368 // single-page, two-page, the canonical 64 MiB / 1 GiB / 4 GiB
2369 // upper-bound boundary so a future tightening of either edge
2370 // surfaces here. Peer of
2371 // `validate_accepts_integer_millisecond_wall_clock_values` on
2372 // the sibling `:wall-clock` axis.
2373 for bytes in [
2374 LIMITS_MEMORY_WASM32_PAGE_BYTES,
2375 2 * LIMITS_MEMORY_WASM32_PAGE_BYTES,
2376 64 * 1024 * 1024,
2377 1024 * 1024 * 1024,
2378 LIMITS_MEMORY_WASM32_MAX_BYTES,
2379 ] {
2380 let l = LimitsSpec {
2381 memory: Some(bytes),
2382 ..Default::default()
2383 };
2384 l.validate()
2385 .unwrap_or_else(|e| panic!("multi-page {bytes} must validate, got {e:?}"));
2386 }
2387 }
2388
2389 #[test]
2390 fn validate_memory_zero_takes_precedence_over_page_floor() {
2391 // Cross-arm ordering pin: `Some(0)` would otherwise pass the
2392 // page-floor arm's `m < PAGE_BYTES` check (0 < 65536), but the
2393 // zero-floor arm strictly precedes the page-floor arm so the
2394 // more self-locating `MemoryZero` diagnostic (with its omit-
2395 // axis remediation directly named, applicable under *any* wasm
2396 // engine not just wasm32) leads. Same posture every peer
2397 // zero-then-shape gate uses on this surface
2398 // (`PolicyTimeoutZero` → `PolicyTimeoutNotCanonical`,
2399 // `PolicyBreakerZeroWindow` → `PolicyBreakerWindowNotCanonical`,
2400 // `WallClockZero` → `WallClockNotCanonical`).
2401 let l = LimitsSpec {
2402 memory: Some(0),
2403 ..Default::default()
2404 };
2405 assert_eq!(l.validate().unwrap_err(), LimitsError::MemoryZero);
2406 }
2407
2408 #[test]
2409 fn validate_memory_page_floor_takes_precedence_over_other_axes() {
2410 // With a sub-page `:memory` and zero values on every other
2411 // axis, the diagnostic names `:memory` rather than `:fuel` /
2412 // `:wall-clock` / `:cpu` — peer of the existing
2413 // `validate_rejects_first_zero_axis_deterministically` and
2414 // `validate_rejects_memory_cap_before_other_axes` ordering
2415 // pins. Memory is the first axis the validate cascade checks,
2416 // so a sub-page value surfaces before any other-axis
2417 // diagnostic regardless of how many other axes are
2418 // simultaneously invalid.
2419 let bytes = LIMITS_MEMORY_WASM32_PAGE_BYTES / 2;
2420 let l = LimitsSpec {
2421 memory: Some(bytes),
2422 fuel: Some(0),
2423 wall_clock: Some(Duration::ZERO),
2424 cpu: Some(0),
2425 };
2426 assert_eq!(
2427 l.validate().unwrap_err(),
2428 LimitsError::MemoryBelowWasm32Page { bytes }
2429 );
2430 }
2431
2432 #[test]
2433 fn memory_page_floor_diagnostic_carries_offending_bytes() {
2434 // Diagnostic-shape pin: the page-floor arm names the
2435 // offending byte count verbatim so the author's grep lands on
2436 // the field's value, not a generic "memory too small" message.
2437 // Same shape every other typed-cap arm on this surface
2438 // carries (`MemoryExceedsWasm32Cap` carries the offending byte
2439 // count verbatim, `WallClockNotCanonical` carries the
2440 // offending `Duration` verbatim, `PolicyRetriesExceedsCap`
2441 // carries the offending retry count verbatim).
2442 let l = LimitsSpec {
2443 memory: Some(50_000),
2444 ..Default::default()
2445 };
2446 let err = l.validate().unwrap_err();
2447 let msg = err.to_string();
2448 assert!(
2449 msg.contains("50000"),
2450 "diagnostic must carry the offending byte count verbatim (got {msg:?})"
2451 );
2452 assert!(
2453 msg.contains("64 KiB") || msg.contains("65536"),
2454 "diagnostic must name the page-size floor (got {msg:?})"
2455 );
2456 }
2457
2458 #[test]
2459 fn below_page_value_still_round_trips_through_serde() {
2460 // The byte-size codec accepts the sub-page value (the floor
2461 // lives in the validate gate, not the codec) — peer of
2462 // `above_cap_value_still_round_trips_through_serde` on the top
2463 // edge. Pins that the structural property is "sub-page is
2464 // rejected by validate" — not "sub-page is unparseable by the
2465 // codec"; the latter would prevent the diagnostic from naming
2466 // the offending byte count at all, since deserialize would
2467 // fail first.
2468 let l = LimitsSpec {
2469 memory: Some(LIMITS_MEMORY_WASM32_PAGE_BYTES - 1),
2470 ..Default::default()
2471 };
2472 let json = serde_json::to_string(&l).unwrap();
2473 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
2474 assert_eq!(l, back);
2475 assert!(back.validate().is_err());
2476 }
2477
2478 // ── value-shape: :memory page-multiple granularity gate ───────────────
2479
2480 #[test]
2481 fn validate_rejects_memory_one_byte_above_page() {
2482 // The fail-before-pass-after pin: until this gate landed a
2483 // `LimitsSpec { memory: Some(LIMITS_MEMORY_WASM32_PAGE_BYTES +
2484 // 1), .. }` (65537 bytes — one wasm32 page plus a 1-byte
2485 // unreachable residue) silently passed validate, the byte-size
2486 // codec round-tripped cleanly through serde (`render_byte_size`
2487 // falls through to `"65537"` on any non-power-of-1024 magnitude),
2488 // and wasmtime's `StoreLimits::memory_size` consumed the value
2489 // verbatim as a page-quantized ceiling — the engine grew at
2490 // most floor(65537 / 65536) = 1 page, and the byte at offset
2491 // 65536 became structural dead space the runtime cannot honor.
2492 let bytes = LIMITS_MEMORY_WASM32_PAGE_BYTES + 1;
2493 let l = LimitsSpec {
2494 memory: Some(bytes),
2495 ..Default::default()
2496 };
2497 assert_eq!(
2498 l.validate().unwrap_err(),
2499 LimitsError::MemoryNotPageMultiple { bytes }
2500 );
2501 }
2502
2503 #[test]
2504 fn validate_rejects_memory_just_below_two_pages() {
2505 // Boundary case: exactly 1 byte below two pages (`2 *
2506 // LIMITS_MEMORY_WASM32_PAGE_BYTES - 1` = 131071 bytes). Pins
2507 // the inclusive-page-boundary / strict-sub-page-residue
2508 // relationship on the page-multiple arm: 131071 is *out*
2509 // (sub-page residue), 131072 is *in* (exactly two pages).
2510 // Matches the peer `validate_rejects_memory_one_byte_below_page`
2511 // / `validate_rejects_memory_one_byte_above_wasm32_cap` shape
2512 // on the surrounding edges.
2513 let bytes = 2 * LIMITS_MEMORY_WASM32_PAGE_BYTES - 1;
2514 let l = LimitsSpec {
2515 memory: Some(bytes),
2516 ..Default::default()
2517 };
2518 assert_eq!(
2519 l.validate().unwrap_err(),
2520 LimitsError::MemoryNotPageMultiple { bytes }
2521 );
2522 }
2523
2524 #[test]
2525 fn validate_rejects_memory_100000_bytes() {
2526 // The "obvious authoring footgun" case: a magnitude the
2527 // byte-size codec accepts cleanly (`"100000"` → 100000 bytes
2528 // ≈ 97.65 KiB) and serde round-trips silently, but no wasm32
2529 // engine can honor as a meaningful ceiling — the engine grows
2530 // at most floor(100000 / 65536) = 1 page, and the 34464 bytes
2531 // between offsets 65536 and 100000 are structural dead space.
2532 // Until this gate landed `validate` accepted it. Peer of
2533 // `validate_rejects_memory_8_gib` on the cap arm.
2534 let bytes = parse_byte_size("100000").unwrap();
2535 let l = LimitsSpec {
2536 memory: Some(bytes),
2537 ..Default::default()
2538 };
2539 assert_eq!(
2540 l.validate().unwrap_err(),
2541 LimitsError::MemoryNotPageMultiple { bytes }
2542 );
2543 }
2544
2545 #[test]
2546 fn validate_accepts_every_page_aligned_value_through_serde() {
2547 // Positive-control sweep through the byte-size codec: every
2548 // canonical magnitude `render_byte_size` emits at or above
2549 // the page floor divides cleanly by the page size, so the
2550 // page-multiple gate accepts the entire canonical-output
2551 // domain at and above the page floor. The sweep walks
2552 // single-page (`"64KiB"`), two-page (`"128KiB"`), every
2553 // power-of-1024 unit (`"1MiB"`, `"64MiB"`, `"1GiB"`, `"4GiB"`),
2554 // and the cap (`"4GiB"`) — pinning that the codec's
2555 // emitted-canonical-form set is a structural subset of the
2556 // validate gate's accepted set. Drift between the codec's
2557 // emit alphabet and the validate gate would surface here
2558 // rather than at a future serializer round trip.
2559 for s in ["64KiB", "128KiB", "1MiB", "64MiB", "1GiB", "4GiB"] {
2560 let bytes = parse_byte_size(s).unwrap();
2561 assert_eq!(
2562 bytes % LIMITS_MEMORY_WASM32_PAGE_BYTES,
2563 0,
2564 "canonical byte-size codec output {s:?} ({bytes}) must be page-aligned",
2565 );
2566 let l = LimitsSpec {
2567 memory: Some(bytes),
2568 ..Default::default()
2569 };
2570 l.validate()
2571 .unwrap_or_else(|e| panic!("canonical {s:?} = {bytes} must validate, got {e:?}"));
2572 }
2573 }
2574
2575 #[test]
2576 fn validate_memory_below_page_takes_precedence_over_page_multiple() {
2577 // Cross-arm ordering pin: `Some(1)` would otherwise pass the
2578 // page-multiple arm's `m % PAGE_BYTES != 0` check (1 % 65536
2579 // == 1 ≠ 0), but the page-floor arm strictly precedes the
2580 // page-multiple arm so the more self-locating
2581 // `MemoryBelowWasm32Page` diagnostic (with its "single page
2582 // cannot fit" remediation, applicable to every sub-page
2583 // value uniformly) leads. Peer of `MemoryZero` →
2584 // `MemoryBelowWasm32Page` precedence on the zero edge:
2585 // every value `m` in the range `1..=PAGE_BYTES-1` satisfies
2586 // both `m < PAGE_BYTES` and `m % PAGE_BYTES != 0`, but the
2587 // structurally-narrower diagnostic (page-floor) leads.
2588 let l = LimitsSpec {
2589 memory: Some(1),
2590 ..Default::default()
2591 };
2592 assert_eq!(
2593 l.validate().unwrap_err(),
2594 LimitsError::MemoryBelowWasm32Page { bytes: 1 }
2595 );
2596 }
2597
2598 #[test]
2599 fn validate_memory_cap_takes_precedence_over_page_multiple() {
2600 // Cross-arm ordering pin: `LIMITS_MEMORY_WASM32_MAX_BYTES + 1`
2601 // (4 GiB + 1 byte) is *both* above-cap and not page-aligned.
2602 // The cap arm strictly precedes the page-multiple arm so the
2603 // more aggressive cap-shape diagnostic leads (the page-multiple
2604 // remediation would be misleading when the offending value
2605 // exceeds the wasm32 address-space ceiling anyway — the
2606 // canonical fix collapses both into "pin a page-aligned value
2607 // ≤ 4 GiB"). Peer of `WallClockNotCanonical` →
2608 // `WallClockExceedsCap` ordering on the sibling `:wall-clock`
2609 // axis (with the inverse polarity — there the granularity
2610 // gate leads because sub-millisecond residue breaks serde
2611 // round-trip; here the cap leads because both gates' offending
2612 // values round-trip cleanly through serde and the broader
2613 // magnitude constraint is the more aggressive one).
2614 let bytes = LIMITS_MEMORY_WASM32_MAX_BYTES + 1;
2615 let l = LimitsSpec {
2616 memory: Some(bytes),
2617 ..Default::default()
2618 };
2619 assert_eq!(
2620 l.validate().unwrap_err(),
2621 LimitsError::MemoryExceedsWasm32Cap { bytes }
2622 );
2623 }
2624
2625 #[test]
2626 fn validate_rejects_memory_page_multiple_before_other_axes() {
2627 // With a sub-page-residue `:memory` and zero values on every
2628 // other axis, the diagnostic names `:memory` rather than
2629 // `:fuel` / `:wall-clock` / `:cpu` — peer of the existing
2630 // `validate_memory_page_floor_takes_precedence_over_other_axes`
2631 // and `validate_rejects_memory_cap_before_other_axes` ordering
2632 // pins. Memory is the first axis the validate cascade checks,
2633 // so a sub-page-residue value surfaces before any other-axis
2634 // diagnostic regardless of how many other axes are
2635 // simultaneously invalid.
2636 let bytes = LIMITS_MEMORY_WASM32_PAGE_BYTES + 1;
2637 let l = LimitsSpec {
2638 memory: Some(bytes),
2639 fuel: Some(0),
2640 wall_clock: Some(Duration::ZERO),
2641 cpu: Some(0),
2642 };
2643 assert_eq!(
2644 l.validate().unwrap_err(),
2645 LimitsError::MemoryNotPageMultiple { bytes }
2646 );
2647 }
2648
2649 #[test]
2650 fn memory_page_multiple_diagnostic_carries_offending_bytes() {
2651 // Diagnostic-shape pin: the page-multiple arm names the
2652 // offending byte count verbatim so the author's grep lands on
2653 // the field's value, not a generic "memory not aligned"
2654 // message. Same shape every other typed-cap arm on this
2655 // surface carries (`MemoryExceedsWasm32Cap` carries the
2656 // offending byte count verbatim, `WallClockNotCanonical`
2657 // carries the offending `Duration` verbatim).
2658 let bytes = LIMITS_MEMORY_WASM32_PAGE_BYTES + 12345;
2659 let l = LimitsSpec {
2660 memory: Some(bytes),
2661 ..Default::default()
2662 };
2663 let err = l.validate().unwrap_err();
2664 let msg = err.to_string();
2665 assert!(
2666 msg.contains(&bytes.to_string()),
2667 "diagnostic must carry the offending byte count verbatim (got {msg:?})"
2668 );
2669 assert!(
2670 msg.contains("64 KiB") || msg.contains("65536") || msg.contains("page"),
2671 "diagnostic must name the page-size granularity (got {msg:?})"
2672 );
2673 }
2674
2675 #[test]
2676 fn sub_page_residue_value_still_round_trips_through_serde() {
2677 // The byte-size codec accepts the sub-page-residue value (the
2678 // page-multiple gate lives in validate, not in the codec) —
2679 // peer of `above_cap_value_still_round_trips_through_serde`
2680 // and `below_page_value_still_round_trips_through_serde`.
2681 // Pins that the structural property is "sub-page-residue is
2682 // rejected by validate" — not "sub-page-residue is
2683 // unparseable by the codec"; the latter would prevent the
2684 // diagnostic from naming the offending byte count at all,
2685 // since deserialize would fail first. The render-then-parse
2686 // round trip also pins the codec's flow-through-to-bytes
2687 // shape on non-power-of-1024 magnitudes: `render_byte_size`
2688 // falls through every `(mult, label)` arm whose `n % mult !=
2689 // 0` and emits the bare byte count.
2690 let bytes = LIMITS_MEMORY_WASM32_PAGE_BYTES + 1;
2691 let l = LimitsSpec {
2692 memory: Some(bytes),
2693 ..Default::default()
2694 };
2695 let json = serde_json::to_string(&l).unwrap();
2696 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
2697 assert_eq!(l, back);
2698 assert!(back.validate().is_err());
2699 }
2700
2701 #[test]
2702 fn validate_memory_axis_routes_through_quantum_multiple_bounded_helper() {
2703 // Byte-parity pin on the pre-lift `if self.memory() == Some(0)
2704 // { … } if let Some(m) = self.memory() { if m <
2705 // LIMITS_MEMORY_WASM32_PAGE_BYTES { … } } if let Some(m) =
2706 // self.memory() { if m > LIMITS_MEMORY_WASM32_MAX_BYTES { … } }
2707 // if let Some(m) = self.memory() && m %
2708 // LIMITS_MEMORY_WASM32_PAGE_BYTES != 0 { … }` four-sequential-
2709 // `if let` shape the `LimitsSpec::validate` `:memory` axis
2710 // routed through today via
2711 // `crate::render::require_positive_quantum_multiple_bounded_u64`.
2712 // Refuses a future accidental split between the helper's
2713 // four-arm ordering (zero → below-quantum → cap → not-multiple)
2714 // and the four typed `LimitsError::Memory*` variants each arm
2715 // threads its offending byte count into — a swap of any two
2716 // arms in the helper, or a partial widening (e.g. removing the
2717 // page-multiple arm), or a widening of the `on_below_quantum`
2718 // arm's closure to the `MemoryExceedsWasm32Cap` variant instead
2719 // of `MemoryBelowWasm32Page` — would break exactly one row of
2720 // this pin, matching the pre-lift shape the four consumer sites
2721 // route through today. Same shape as
2722 // `as_seq_body_partitions_the_same_arm_set_as_seq_delims` in
2723 // caixa-ast and the peer `require_positive_bounded_u64` tests
2724 // in the sibling render.rs test module.
2725 //
2726 // (Some(bytes) → expected LimitsError)
2727 let quantum = LIMITS_MEMORY_WASM32_PAGE_BYTES;
2728 let cap = LIMITS_MEMORY_WASM32_MAX_BYTES;
2729 let cases: &[(u64, LimitsError)] = &[
2730 (0, LimitsError::MemoryZero),
2731 (1, LimitsError::MemoryBelowWasm32Page { bytes: 1 }),
2732 (
2733 quantum - 1,
2734 LimitsError::MemoryBelowWasm32Page { bytes: quantum - 1 },
2735 ),
2736 (
2737 cap + 1,
2738 LimitsError::MemoryExceedsWasm32Cap { bytes: cap + 1 },
2739 ),
2740 (
2741 cap + quantum,
2742 LimitsError::MemoryExceedsWasm32Cap {
2743 bytes: cap + quantum,
2744 },
2745 ),
2746 (
2747 quantum + 1,
2748 LimitsError::MemoryNotPageMultiple { bytes: quantum + 1 },
2749 ),
2750 (
2751 quantum + 12_345,
2752 LimitsError::MemoryNotPageMultiple {
2753 bytes: quantum + 12_345,
2754 },
2755 ),
2756 ];
2757 for (bytes, expected) in cases {
2758 let l = LimitsSpec {
2759 memory: Some(*bytes),
2760 ..Default::default()
2761 };
2762 assert_eq!(
2763 l.validate().unwrap_err(),
2764 *expected,
2765 "memory={bytes} must surface the {expected:?} arm via the substrate helper",
2766 );
2767 }
2768 // Positive-control: every quantum-multiple in `quantum..=cap`
2769 // passes, closing the four-arm cascade with an `Ok(())` shape.
2770 for bytes in [quantum, quantum * 2, quantum * 100, cap] {
2771 let l = LimitsSpec {
2772 memory: Some(bytes),
2773 ..Default::default()
2774 };
2775 l.validate().unwrap();
2776 }
2777 }
2778
2779 // ── canonical-form: integer-magnitude byte-size codec gate ────────────
2780 //
2781 // Every magnitude `render_byte_size` emits is a non-negative integer
2782 // (no decimal point, no leading sign, no scientific notation). The
2783 // parser's accepted set must match for parse → render → parse to
2784 // round-trip without canonical-form drift. The tests below pin every
2785 // canonical-drift shape — fractional (`"1.5KiB"`), decimal-shaped-
2786 // integer (`"1.0MiB"`), half-unit (`"0.5GiB"`), leading-`+`
2787 // (`"+1024"`) — plus the scientific-notation dispatch path (caught
2788 // by `UnknownByteUnit` on a different arm), the two complement-side
2789 // pins (the integer happy paths the gate must continue to accept),
2790 // the round-trip convergence property (parse → render → parse must
2791 // converge on a single canonical form for every accepted input),
2792 // the BadByteMagnitude-precedence pin (genuinely unparseable inputs
2793 // keep their narrower diagnostic), the overflow-surface pin
2794 // (u64-overflow on magnitude × unit surfaces at parse time), and
2795 // the serde-path pin (the gate fires at deserialize, before any
2796 // validate gate runs).
2797
2798 #[test]
2799 fn parse_byte_size_rejects_fractional_kib() {
2800 // The fail-before-pass-after pin: `"1.5KiB"` parsed cleanly on
2801 // every pre-gate codebase (f64::parse accepts the decimal), the
2802 // codec produced 1536 bytes, and `render_byte_size(1536)`
2803 // emitted `"1536"` on the next serialize — silently drifting
2804 // the canonical form away from the author's intent. The new
2805 // gate surfaces the round-trip break at the parser layer with
2806 // a self-locating diagnostic (the offending magnitude verbatim,
2807 // the canonical-form remediation in the wording).
2808 let err = parse_byte_size("1.5KiB").unwrap_err();
2809 assert!(
2810 matches!(err, LimitsError::NonIntegerByteMagnitude { ref value } if value == "1.5"),
2811 "got {err:?}"
2812 );
2813 }
2814
2815 #[test]
2816 fn parse_byte_size_rejects_decimal_shaped_integer() {
2817 // The canonical-drift case where the *value* is integer but
2818 // the *form* carries a redundant decimal point — `"1.0MiB"`
2819 // parses to 1 MiB (integer), but the renderer emits `"1MiB"`
2820 // on the next serialize (no decimal point). The parse-shape
2821 // gate fires here too so the codec's accepted set is exactly
2822 // the renderer's emitted set — no `"1.0MiB"` ↔ `"1MiB"` drift
2823 // surviving a round-trip silently.
2824 let err = parse_byte_size("1.0MiB").unwrap_err();
2825 assert!(
2826 matches!(err, LimitsError::NonIntegerByteMagnitude { ref value } if value == "1.0"),
2827 "got {err:?}"
2828 );
2829 }
2830
2831 #[test]
2832 fn parse_byte_size_rejects_half_gib() {
2833 // `"0.5GiB"` parses to 536870912 bytes = 512MiB; the renderer
2834 // emits `"512MiB"` on the next serialize. Pin the round-trip
2835 // drift on the explicitly-fractional case sized to land on a
2836 // unit boundary, so the gate's coverage includes both the
2837 // "doesn't land on a boundary" (1.5KiB → 1536) and "lands on
2838 // a smaller-unit boundary" (0.5GiB → 512MiB) drift shapes.
2839 let err = parse_byte_size("0.5GiB").unwrap_err();
2840 assert!(
2841 matches!(err, LimitsError::NonIntegerByteMagnitude { ref value } if value == "0.5"),
2842 "got {err:?}"
2843 );
2844 }
2845
2846 #[test]
2847 fn parse_byte_size_rejects_scientific_notation_via_unit_arm() {
2848 // Scientific-notation magnitudes are canonical-form drift too
2849 // — the renderer never emits `"1e3KiB"` for any value. But
2850 // they're caught on a *different* arm than the fractional /
2851 // leading-`+` shapes: the parser's split-on-first-alphabetic-
2852 // byte heuristic reads the `e` as a unit prefix, so the input
2853 // falls into the existing `UnknownByteUnit { unit: "e3KiB" }`
2854 // diagnostic before the `NonIntegerByteMagnitude` gate is
2855 // consulted. Pin this dispatch path so a future relaxation of
2856 // the split heuristic (e.g. recognizing `e` as part of a
2857 // scientific-notation magnitude) surfaces here as a test
2858 // failure — at which point the `NonIntegerByteMagnitude` gate
2859 // would correctly take over, and this test would flip to that
2860 // arm with no other change required.
2861 let err = parse_byte_size("1e3KiB").unwrap_err();
2862 assert!(
2863 matches!(err, LimitsError::UnknownByteUnit { ref unit } if unit == "e3KiB"),
2864 "got {err:?}"
2865 );
2866 }
2867
2868 #[test]
2869 fn parse_byte_size_rejects_leading_plus() {
2870 // `"+1024"` parses through f64 as 1024 bytes; the renderer
2871 // emits `"1KiB"` on the next serialize. The leading `+` is
2872 // not a renderer-emitted shape, so it falls in the same
2873 // canonical-drift class as the fractional / scientific forms
2874 // — surfacing under the same diagnostic keeps the gate's
2875 // coverage uniform across every non-canonical-but-numeric
2876 // input shape the parser would otherwise accept.
2877 let err = parse_byte_size("+1024").unwrap_err();
2878 assert!(
2879 matches!(err, LimitsError::NonIntegerByteMagnitude { ref value } if value == "+1024"),
2880 "got {err:?}"
2881 );
2882 }
2883
2884 #[test]
2885 fn parse_byte_size_continues_to_accept_integer_magnitudes() {
2886 // The complement-side pin: every canonical integer-magnitude
2887 // form the renderer emits must continue to parse to the same
2888 // value the renderer produced. Sweep the five canonical
2889 // authoring shapes (unitless integer, KiB, MiB, GiB, KB) so a
2890 // future tightening of the parser surfaces here as a test
2891 // failure rather than a silent regression in the canonical
2892 // authoring set.
2893 assert_eq!(parse_byte_size("1024").unwrap(), 1024);
2894 assert_eq!(parse_byte_size("1KiB").unwrap(), 1024);
2895 assert_eq!(parse_byte_size("64MiB").unwrap(), 64 * 1024 * 1024);
2896 assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
2897 assert_eq!(parse_byte_size("1000KB").unwrap(), 1_000_000);
2898 }
2899
2900 #[test]
2901 fn parse_byte_size_round_trips_through_render_for_every_canonical_form() {
2902 // The structural property the gate makes load-bearing: every
2903 // value the parser accepts round-trips through `render_byte_size`
2904 // to a string the parser also accepts — and to the *same* value.
2905 // Sweep the values the renderer emits canonically (1024 / 1MiB
2906 // / 1GiB / 1536 / 64MiB) so a future codec change that breaks
2907 // round-trip convergence surfaces here, not at a downstream
2908 // renderer that double-emits a typed slot.
2909 for n in [1u64, 1023, 1024, 1536, 64 * 1024 * 1024, 1024 * 1024 * 1024] {
2910 let rendered = render_byte_size(n);
2911 let reparsed = parse_byte_size(&rendered)
2912 .unwrap_or_else(|e| panic!("render({n}) = {rendered:?} must reparse, got {e:?}"));
2913 assert_eq!(
2914 reparsed, n,
2915 "round-trip drift on {n}: rendered={rendered:?}, reparsed={reparsed}",
2916 );
2917 }
2918 }
2919
2920 #[test]
2921 fn parse_byte_size_keeps_bad_magnitude_for_unparseable_input() {
2922 // The precedence pin: the new `NonIntegerByteMagnitude` arm
2923 // distinguishes *non-canonical-but-numeric* (`"1.5"`, `"1.0"`,
2924 // `"+1024"`, `"-1"`) from *genuinely-unparseable* (`"abc"`,
2925 // `"--1"`) so the existing `BadByteMagnitude` diagnostic's
2926 // wording remains load-bearing for the latter class — the gate
2927 // is additive, not replacing. Pin both arms so a future
2928 // relaxation that collapses them surfaces here.
2929 let err = parse_byte_size("abc").unwrap_err();
2930 assert!(
2931 matches!(err, LimitsError::BadByteMagnitude(_)),
2932 "got {err:?}"
2933 );
2934 let err = parse_byte_size("--1").unwrap_err();
2935 assert!(
2936 matches!(err, LimitsError::BadByteMagnitude(_)),
2937 "got {err:?}"
2938 );
2939 }
2940
2941 #[test]
2942 fn parse_byte_size_overflow_surfaces_as_bad_magnitude() {
2943 // `u64::MAX KiB` overflows the u64 result; the parser surfaces
2944 // the overflow as a `BadByteMagnitude` (not as a saturated
2945 // `u64::MAX` value that the wasm32-cap validate gate then
2946 // catches), so the diagnostic names the offending magnitude ×
2947 // unit pair at parse time rather than as
2948 // `MemoryExceedsWasm32Cap { bytes: u64::MAX }` far from the
2949 // author's intent. (`u64::MAX` itself parses cleanly with no
2950 // unit since `u64::MAX × 1 = u64::MAX` fits.)
2951 let err = parse_byte_size("18446744073709551615KiB").unwrap_err();
2952 let LimitsError::BadByteMagnitude(reason) = err else {
2953 panic!("expected BadByteMagnitude(overflow), got other variant");
2954 };
2955 assert!(
2956 reason.contains("overflow"),
2957 "overflow diagnostic must mention overflow (got {reason:?})"
2958 );
2959 }
2960
2961 // ── canonical-form: leading-zero byte-size codec gate ─────────────────
2962 //
2963 // Direct successor to the `parse_duration` leading-zero arm (39762d7),
2964 // the `supervisor::duration_codec` leading-zero arm (9178904), and the
2965 // `rate_limit_codec` leading-zero arm (4f46830) — the same canonical-
2966 // form render-determinism axis applied to the last typed-numeric codec
2967 // that still admitted leading-zero magnitudes. The digit-only gate
2968 // immediately above accepts every `u64::from_str`-parseable magnitude
2969 // including leading-zero padding, but `render_byte_size` always emits
2970 // the stripped form (`64MiB`, never `064MiB`) — silently drifting the
2971 // canonical string across a parse/render round-trip. Pins each
2972 // canonical leading-zero shape across the unit-set the codec admits
2973 // (KB / MB / GB / KiB / MiB / GiB / bare-integer), the all-zero
2974 // degenerate case, the codec-vs-validate-layer partition (single-byte
2975 // `"0"` stays accepted at the codec because the typed-validate gate
2976 // `MemoryZero` refuses semantic-zero authoring), the complement-side
2977 // pin (`1`..=`9`-led magnitudes stay accepted), and the serde-path pin
2978 // (the gate fires at deserialize, before any validate gate runs).
2979
2980 #[test]
2981 fn parse_byte_size_rejects_leading_zero_magnitude() {
2982 // The fail-before-pass-after pin: `"064MiB"` parsed cleanly on
2983 // every pre-gate codebase (`u64::from_str` accepts the leading
2984 // zero), the codec produced 64 MiB, and
2985 // `render_byte_size(64*1024*1024)` emitted `"64MiB"` on the next
2986 // serialize — silently dropping the leading zero and drifting
2987 // the canonical form away from the author's intent. The new
2988 // gate surfaces the round-trip break at the parser layer with a
2989 // self-locating diagnostic, peer with
2990 // `parse_duration_rejects_leading_zero_magnitude` on the sibling
2991 // codec.
2992 let err = parse_byte_size("064MiB").unwrap_err();
2993 assert!(
2994 matches!(err, LimitsError::LeadingZeroByteMagnitude { ref value } if value == "064"),
2995 "got {err:?}"
2996 );
2997 }
2998
2999 #[test]
3000 fn parse_byte_size_rejects_multi_digit_zero_magnitude() {
3001 // `"00MiB"` is the degenerate leading-zero case — every byte is
3002 // `0`. `u64::from_str("00")` = 0, and the codec produces 0;
3003 // `render_byte_size(0)` emits `"0"` on the next serialize —
3004 // drift from `"00MiB"` to `"0"`. The leading-zero arm refuses
3005 // the drift class at the codec layer while leaving the
3006 // canonical single-byte `"0"` accepted. Peer with
3007 // `parse_duration_rejects_multi_digit_zero_magnitude` on the
3008 // sibling codec.
3009 let err = parse_byte_size("00MiB").unwrap_err();
3010 assert!(
3011 matches!(err, LimitsError::LeadingZeroByteMagnitude { ref value } if value == "00"),
3012 "got {err:?}"
3013 );
3014 }
3015
3016 #[test]
3017 fn parse_byte_size_rejects_leading_zero_in_gib_unit() {
3018 // `"01GiB"` parses to 1 GiB; the renderer emits `"1GiB"` on the
3019 // next serialize. The leading-zero class is a property of the
3020 // magnitude, not the unit — pin a per-GiB magnitude alongside
3021 // the per-MiB / per-KiB / bare-integer pins so the gate's
3022 // coverage is structural across every canonical unit suffix
3023 // the codec accepts. Mirrors the per-hour pin
3024 // `parse_duration_rejects_leading_zero_in_hour_window` carries
3025 // on the sibling codec.
3026 let err = parse_byte_size("01GiB").unwrap_err();
3027 assert!(
3028 matches!(err, LimitsError::LeadingZeroByteMagnitude { ref value } if value == "01"),
3029 "got {err:?}"
3030 );
3031 }
3032
3033 #[test]
3034 fn parse_byte_size_rejects_leading_zero_in_kib_unit() {
3035 // `"0512KiB"` parses to 512 KiB; the renderer emits `"512KiB"`
3036 // on the next serialize. Pin the per-KiB magnitude alongside
3037 // the per-MiB / per-GiB pins so the gate's coverage extends to
3038 // the smallest-unit power-of-1024 suffix the codec admits.
3039 let err = parse_byte_size("0512KiB").unwrap_err();
3040 assert!(
3041 matches!(err, LimitsError::LeadingZeroByteMagnitude { ref value } if value == "0512"),
3042 "got {err:?}"
3043 );
3044 }
3045
3046 #[test]
3047 fn parse_byte_size_rejects_leading_zero_in_decimal_units() {
3048 // `"0500MB"` parses to 500 MB (decimal-unit family — `KB` /
3049 // `MB` / `GB` powers of 1000, distinct from the `KiB` / `MiB` /
3050 // `GiB` powers-of-1024 family); the renderer emits the
3051 // appropriate canonical form on the next serialize. Pin the
3052 // decimal-unit family alongside the power-of-1024 family so the
3053 // gate's coverage is structural across both unit families the
3054 // codec admits.
3055 for (s, expected) in [("0500MB", "0500"), ("01KB", "01"), ("00GB", "00")] {
3056 let err = parse_byte_size(s).unwrap_err();
3057 assert!(
3058 matches!(err, LimitsError::LeadingZeroByteMagnitude { value: ref v } if v == expected),
3059 "got {err:?} for {s:?}"
3060 );
3061 }
3062 }
3063
3064 #[test]
3065 fn parse_byte_size_rejects_leading_zero_bare_integer() {
3066 // The bare-integer (no unit) shorthand inherits the leading-
3067 // zero arm: `"01024"` parses losslessly to 1024 bytes but
3068 // `render_byte_size(1024)` emits `"1KiB"` on the next serialize.
3069 // Pin the bare-integer path so a future relaxation that
3070 // special-cases the unitless shorthand surfaces here as a test
3071 // failure. Mirrors the bare-integer pin
3072 // `parse_duration_rejects_leading_zero_bare_integer_as_seconds`
3073 // carries on the sibling codec.
3074 let err = parse_byte_size("01024").unwrap_err();
3075 assert!(
3076 matches!(err, LimitsError::LeadingZeroByteMagnitude { ref value } if value == "01024"),
3077 "got {err:?}"
3078 );
3079 }
3080
3081 #[test]
3082 fn parse_byte_size_accepts_single_zero_magnitude_at_codec_layer() {
3083 // The codec-layer / typed-validate-layer boundary pin: the
3084 // single-byte `"0"` magnitude round-trips losslessly through
3085 // `render_byte_size` (`render_byte_size(0)` emits `"0"`), so it
3086 // stays accepted at this codec layer across every canonical
3087 // unit suffix. The downstream `LimitsError::MemoryZero` gate is
3088 // what refuses zero-magnitude authoring at the typed-validate
3089 // layer above — the partition keeps the canonical-form-drift
3090 // diagnostic (this arm) and the semantic-zero diagnostic (the
3091 // validate gate) disjoint. Mirrors the
3092 // `parse_duration_accepts_single_zero_magnitude_at_codec_layer`
3093 // partition pin on the sibling codec.
3094 assert_eq!(parse_byte_size("0").unwrap(), 0);
3095 assert_eq!(parse_byte_size("0B").unwrap(), 0);
3096 assert_eq!(parse_byte_size("0KiB").unwrap(), 0);
3097 assert_eq!(parse_byte_size("0MiB").unwrap(), 0);
3098 assert_eq!(parse_byte_size("0GiB").unwrap(), 0);
3099 assert_eq!(parse_byte_size("0KB").unwrap(), 0);
3100 }
3101
3102 #[test]
3103 fn parse_byte_size_accepts_canonical_magnitude_with_leading_one() {
3104 // The complement-side pin on the leading-zero arm: magnitudes
3105 // beginning with `1`..=`9` stay accepted across every canonical
3106 // unit suffix the codec accepts. Pin this so a future
3107 // tightening cannot drift into rejecting valid canonical
3108 // magnitudes — peer with the
3109 // `parse_duration_accepts_canonical_magnitude_with_leading_one`
3110 // pin on the sibling codec.
3111 assert_eq!(parse_byte_size("1").unwrap(), 1);
3112 assert_eq!(parse_byte_size("1KiB").unwrap(), 1024);
3113 assert_eq!(parse_byte_size("1MiB").unwrap(), 1024 * 1024);
3114 assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
3115 assert_eq!(parse_byte_size("64MiB").unwrap(), 64 * 1024 * 1024);
3116 assert_eq!(parse_byte_size("9").unwrap(), 9);
3117 }
3118
3119 #[test]
3120 fn de_byte_size_rejects_leading_zero_through_serde() {
3121 // The serde-path pin: a `:limits :memory` carrying a
3122 // leading-zero magnitude (`"064MiB"`) must fail at deserialize
3123 // time, not silently round-trip the value through the parser.
3124 // The gate fires at deserialize, before any validate gate runs
3125 // — peer with `de_duration_rejects_leading_zero_through_serde`
3126 // on the sibling codec.
3127 let json = r#"{"memory":"064MiB"}"#;
3128 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
3129 let msg = err.to_string();
3130 assert!(
3131 msg.contains("leading zero"),
3132 "serde diagnostic must surface the leading-zero reason verbatim (got {msg:?})"
3133 );
3134 }
3135
3136 // ── canonical-form: whitespace-rejection byte-size codec gate ─────────
3137 //
3138 // Direct successor to the `parse_duration` whitespace-rejection arm
3139 // (ebc3a75), the `supervisor::duration_codec` whitespace-rejection
3140 // arm (a7ae622), and the `rate_limit_codec` whitespace-rejection arm
3141 // (1ad7755) on the same canonical-form render-determinism axis. The
3142 // pre-gate top-level `s.trim()` at parse entry and the per-part
3143 // `num_part.trim()` / `unit.trim()` calls silently ate leading /
3144 // trailing / internal whitespace, so every whitespace-carrying
3145 // shape parsed to the same byte magnitude and round-tripped through
3146 // `render_byte_size` to a *different* canonical string on next
3147 // serialize — the same canonical-form-drift class the leading-`+` /
3148 // fractional / leading-zero arms already close on this codec.
3149 // `u8::is_ascii_whitespace` covers the five WhatWG-conformant ASCII
3150 // whitespace bytes (space `0x20`, tab `0x09`, LF `0x0A`, FF `0x0C`,
3151 // CR `0x0D`). Closes the whitespace-rejection axis across every
3152 // typed-magnitude codec in caixa-core.
3153
3154 #[test]
3155 fn parse_byte_size_rejects_leading_whitespace() {
3156 // The fail-before-pass-after pin: `" 64MiB"` — the canonical
3157 // paste-from-aligned-doc / paste-from-YAML-quoted-plain-scalar
3158 // footgun. Before this gate the top-level `s.trim()` at parse
3159 // entry silently ate the leading space and parsed the value to
3160 // 64 * 1024 * 1024 bytes, which then round-tripped through
3161 // `render_byte_size` to `"64MiB"` (a *different* canonical
3162 // string on the next emit) — the exact canonical-form-drift
3163 // class the leading-`+` / leading-zero arms already close,
3164 // extended to the whitespace-byte class. Peer with the sibling
3165 // `parse_duration_rejects_leading_whitespace` arm (ebc3a75) on
3166 // the shared canonical-form-drift trajectory.
3167 let err = parse_byte_size(" 64MiB").unwrap_err();
3168 assert!(
3169 matches!(err, LimitsError::WhitespaceInByteSize { ref value, byte } if value == " 64MiB" && byte == 0x20),
3170 "got {err:?}"
3171 );
3172 let msg = err.to_string();
3173 assert!(
3174 msg.contains("whitespace byte 0x20"),
3175 "diagnostic must surface the offending byte verbatim (got {msg:?})"
3176 );
3177 assert!(
3178 msg.contains("THEORY.md"),
3179 "diagnostic must cite the render-determinism contract (got {msg:?})"
3180 );
3181 }
3182
3183 #[test]
3184 fn parse_byte_size_rejects_trailing_whitespace() {
3185 // `"64MiB "` — the canonical shell-history / trailing-space
3186 // paste footgun. Before this gate the top-level `s.trim()`
3187 // silently ate the trailing space and parsed to 64 * 1024 *
3188 // 1024 bytes, round-tripping to `"64MiB"` on the next emit —
3189 // same canonical-form drift as the leading-space sibling,
3190 // closed on the same whitespace-byte arm.
3191 let err = parse_byte_size("64MiB ").unwrap_err();
3192 assert!(
3193 matches!(err, LimitsError::WhitespaceInByteSize { ref value, byte } if value == "64MiB " && byte == 0x20),
3194 "got {err:?}"
3195 );
3196 }
3197
3198 #[test]
3199 fn parse_byte_size_rejects_internal_whitespace_between_magnitude_and_unit() {
3200 // `"64 MiB"` — the canonical typographically-spaced author
3201 // shape (the same idiom every prose reference to a byte-size
3202 // renders as, mistakenly retained when the value is pasted
3203 // into a codec-shaped slot). Before this gate the per-part
3204 // `num_part.trim()` / `unit.trim()` calls silently ate the
3205 // whitespace between the magnitude and the unit and parsed the
3206 // value to 64 * 1024 * 1024 bytes, round-tripping to `"64MiB"`
3207 // — the codec's *internal* whitespace-tolerance vector,
3208 // orthogonal to the leading / trailing surface but the same
3209 // canonical-form-drift class. Pins the arm as strictly
3210 // stronger than the pre-existing top-level `s.trim()`
3211 // behavior: it fires on whitespace anywhere in the value, not
3212 // just at the string boundary.
3213 let err = parse_byte_size("64 MiB").unwrap_err();
3214 assert!(
3215 matches!(err, LimitsError::WhitespaceInByteSize { ref value, byte } if value == "64 MiB" && byte == 0x20),
3216 "got {err:?}"
3217 );
3218 }
3219
3220 #[test]
3221 fn parse_byte_size_rejects_tab_byte() {
3222 // `"\t64MiB"` — the canonical paste-from-indented-doc /
3223 // paste-from-YAML-block-scalar footgun where a tab byte leads
3224 // the magnitude. Pins that the gate covers tab (`0x09`) as
3225 // well as space (`0x20`) — both are `u8::is_ascii_whitespace`
3226 // members and both would be silently swallowed by `s.trim()`
3227 // pre-gate. The `is_ascii_whitespace` coverage extends beyond
3228 // space alone to the full ASCII-whitespace set (space `0x20`,
3229 // tab `0x09`, LF `0x0A`, FF `0x0C`, CR `0x0D`); this test pins
3230 // the tab arm as a representative of the non-space members.
3231 let err = parse_byte_size("\t64MiB").unwrap_err();
3232 assert!(
3233 matches!(err, LimitsError::WhitespaceInByteSize { ref value, byte } if value == "\t64MiB" && byte == 0x09),
3234 "got {err:?}"
3235 );
3236 }
3237
3238 #[test]
3239 fn parse_byte_size_rejects_trailing_newline() {
3240 // `"64MiB\n"` — the canonical multi-line-paste footgun where
3241 // a trailing LF byte survives the paste. Pins the LF member
3242 // (`0x0A`) of the `is_ascii_whitespace` set as a peer to the
3243 // space and tab pins above — every non-space non-tab
3244 // whitespace byte the WhatWG ASCII-whitespace set covers is
3245 // refused by the same arm.
3246 let err = parse_byte_size("64MiB\n").unwrap_err();
3247 assert!(
3248 matches!(err, LimitsError::WhitespaceInByteSize { ref value, byte } if value == "64MiB\n" && byte == 0x0a),
3249 "got {err:?}"
3250 );
3251 }
3252
3253 #[test]
3254 fn parse_byte_size_accepts_whitespace_free_canonical_forms() {
3255 // The complement-side pin: every canonical whitespace-free
3256 // authoring form the renderer emits stays accepted post-gate.
3257 // Sweep the canonical unit suffixes plus the bare-integer
3258 // shorthand so a future tightening of the whitespace arm that
3259 // over-fires on the accepted set surfaces here as a test
3260 // failure. Peer with the
3261 // `parse_duration_accepts_whitespace_free_canonical_forms` pin
3262 // on the sibling codec.
3263 assert_eq!(parse_byte_size("64MiB").unwrap(), 64 * 1024 * 1024);
3264 assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
3265 assert_eq!(parse_byte_size("512KiB").unwrap(), 512 * 1024);
3266 assert_eq!(parse_byte_size("1KB").unwrap(), 1_000);
3267 assert_eq!(parse_byte_size("1024").unwrap(), 1024);
3268 assert_eq!(parse_byte_size("0").unwrap(), 0);
3269 }
3270
3271 #[test]
3272 fn de_byte_size_rejects_whitespace_through_serde() {
3273 // The serde-path pin: a `:limits :memory` carrying a
3274 // whitespace-byte-carrying value (`" 64MiB"`) must fail at
3275 // deserialize time, not silently round-trip the value through
3276 // the pre-existing top-level `s.trim()`. The gate fires at
3277 // deserialize, before any validate gate runs — peer with the
3278 // existing `de_byte_size_rejects_leading_zero_through_serde` /
3279 // `de_duration_rejects_whitespace_through_serde` pins on the
3280 // same canonical-form-drift axis.
3281 let json = r#"{"memory":" 64MiB"}"#;
3282 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
3283 let msg = err.to_string();
3284 assert!(
3285 msg.contains("whitespace byte"),
3286 "serde diagnostic must surface the whitespace reason verbatim (got {msg:?})"
3287 );
3288 assert!(
3289 msg.contains("0x20"),
3290 "serde diagnostic must name the offending byte (got {msg:?})"
3291 );
3292
3293 // The whitespace-free complement — same author-side intent,
3294 // written in the canonical form the renderer would emit,
3295 // deserializes cleanly.
3296 let json = r#"{"memory":"64MiB"}"#;
3297 let l: LimitsSpec = serde_json::from_str(json).unwrap();
3298 assert_eq!(l.memory, Some(64 * 1024 * 1024));
3299 }
3300
3301 // ── canonical-form: non-ASCII Unicode `White_Space` byte-size gate ────
3302 //
3303 // Direct successor to the `parse_byte_size` ASCII-whitespace arm
3304 // (24a8ad4) — closes the strictly-complementary class the byte-scan
3305 // above cannot see. `str::trim` uses `char::is_whitespace` (Unicode
3306 // `White_Space`, strictly wider than the ASCII byte set); a leading /
3307 // trailing / internal NBSP (`\u{00A0}`) / LINE SEPARATOR (`\u{2028}`)
3308 // / EM-SPACE (`\u{2003}`) survives the byte-scan but is silently
3309 // stripped by the top-level trim, drifting to canonical `"64MiB"` on
3310 // round-trip. Pins the arm through the lifted
3311 // [`crate::render::find_non_ascii_whitespace_char`] predicate.
3312
3313 #[test]
3314 fn parse_byte_size_rejects_leading_nbsp() {
3315 // NBSP (`\u{00A0}` = UTF-8 `0xC2 0xA0`) — the canonical
3316 // paste-from-typography / paste-from-word-processor footgun.
3317 // Before this arm landed the byte-scan missed it (neither `0xC2`
3318 // nor `0xA0` is `is_ascii_whitespace`) and `str::trim` at parse
3319 // entry silently stripped it, yielding the same `64 * 1024 *
3320 // 1024` bytes as the whitespace-free canonical form and drifting
3321 // to `"64MiB"` on next serialize.
3322 let s = "\u{00A0}64MiB";
3323 let err = parse_byte_size(s).unwrap_err();
3324 assert!(
3325 matches!(err, LimitsError::NonAsciiWhitespaceInByteSize { ref value, ch, codepoint } if value == s && ch == '\u{00A0}' && codepoint == 0x00A0),
3326 "got {err:?}"
3327 );
3328 let msg = err.to_string();
3329 assert!(
3330 msg.contains("U+00A0"),
3331 "diagnostic must surface the codepoint verbatim (got {msg:?})"
3332 );
3333 assert!(
3334 msg.contains("THEORY.md"),
3335 "diagnostic must cite the render-determinism contract (got {msg:?})"
3336 );
3337 }
3338
3339 #[test]
3340 fn parse_byte_size_rejects_internal_line_separator() {
3341 // LINE SEPARATOR (`\u{2028}`) between magnitude and unit — the
3342 // canonical paste-from-web-doc footgun (many rendering engines
3343 // insert `\u{2028}` at soft-wrap boundaries in RTF/HTML → plain
3344 // text conversion). Pins the arm on a non-space non-NBSP Unicode
3345 // `White_Space` member.
3346 let s = "64\u{2028}MiB";
3347 let err = parse_byte_size(s).unwrap_err();
3348 assert!(
3349 matches!(err, LimitsError::NonAsciiWhitespaceInByteSize { ref value, ch, codepoint } if value == s && ch == '\u{2028}' && codepoint == 0x2028),
3350 "got {err:?}"
3351 );
3352 }
3353
3354 #[test]
3355 fn parse_byte_size_rejects_trailing_ideographic_space() {
3356 // IDEOGRAPHIC SPACE (`\u{3000}`) — the CJK-typography paste
3357 // footgun (canonical U+3000 is the full-width space that
3358 // Japanese / Chinese IMEs emit when input is auto-widened). Pins
3359 // the arm at the top edge of the `char::is_whitespace` set.
3360 let s = "64MiB\u{3000}";
3361 let err = parse_byte_size(s).unwrap_err();
3362 assert!(
3363 matches!(err, LimitsError::NonAsciiWhitespaceInByteSize { ref value, ch, codepoint } if value == s && ch == '\u{3000}' && codepoint == 0x3000),
3364 "got {err:?}"
3365 );
3366 }
3367
3368 #[test]
3369 fn parse_byte_size_accepts_ascii_only_canonical_forms_after_unicode_arm() {
3370 // Positive-control pin: every ASCII-only canonical form the
3371 // renderer emits stays accepted through the new arm — the
3372 // lifted predicate is a strict no-op on ASCII input.
3373 assert_eq!(parse_byte_size("64MiB").unwrap(), 64 * 1024 * 1024);
3374 assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
3375 assert_eq!(parse_byte_size("512KiB").unwrap(), 512 * 1024);
3376 assert_eq!(parse_byte_size("1024").unwrap(), 1024);
3377 }
3378
3379 // ── canonical-form: integer-magnitude duration codec gate ─────────────
3380 //
3381 // Direct successor to the `parse_byte_size` integer-magnitude gate on
3382 // the peer `:limits :memory` codec — every magnitude `render_duration`
3383 // emits is a non-negative integer (no decimal point, no leading sign,
3384 // no scientific notation). The parser's accepted set must match for
3385 // parse → render → parse to round-trip without canonical-form drift.
3386 // Pins every canonical-drift shape — fractional (`"1.5s"`),
3387 // decimal-shaped-integer (`"1.0s"`), half-unit (`"0.5m"`),
3388 // leading-`+` (`"+30s"`), leading-`-` (`"-30s"`) — plus the
3389 // complement-side pin (integer happy paths), the round-trip
3390 // convergence property, the BadDurationMagnitude-precedence pin
3391 // (genuinely unparseable inputs keep their narrower diagnostic), the
3392 // overflow-surface pin (u64-overflow on magnitude × unit surfaces at
3393 // parse time), and the serde-path pin (the gate fires at deserialize,
3394 // before any validate gate runs).
3395
3396 #[test]
3397 fn parse_duration_rejects_fractional_seconds() {
3398 // The fail-before-pass-after pin: `"1.5s"` parsed cleanly on
3399 // every pre-gate codebase (f64::parse accepts the decimal), the
3400 // codec produced 1500ms, and `render_duration(1500ms)` emitted
3401 // `"1500ms"` on the next serialize — silently drifting the
3402 // canonical form away from the author's intent. The new gate
3403 // surfaces the round-trip break at the parser layer with a
3404 // self-locating diagnostic.
3405 let err = parse_duration("1.5s").unwrap_err();
3406 assert!(
3407 matches!(err, LimitsError::NonIntegerDurationMagnitude { ref value } if value == "1.5"),
3408 "got {err:?}"
3409 );
3410 }
3411
3412 #[test]
3413 fn parse_duration_rejects_decimal_shaped_integer() {
3414 // The canonical-drift case where the *value* is integer but the
3415 // *form* carries a redundant decimal point — `"1.0s"` parses to
3416 // 1s (integer), but the renderer emits `"1s"` on the next
3417 // serialize (no decimal point). The parse-shape gate fires here
3418 // too so the codec's accepted set is exactly the renderer's
3419 // emitted set.
3420 let err = parse_duration("1.0s").unwrap_err();
3421 assert!(
3422 matches!(err, LimitsError::NonIntegerDurationMagnitude { ref value } if value == "1.0"),
3423 "got {err:?}"
3424 );
3425 }
3426
3427 #[test]
3428 fn parse_duration_rejects_half_minute() {
3429 // `"0.5m"` parses to 30s; the renderer emits `"30s"` on the
3430 // next serialize. Pin the round-trip drift on the explicitly-
3431 // fractional case sized to land on a smaller-unit boundary, so
3432 // the gate's coverage includes both the "doesn't land on a
3433 // boundary" (1.5s → 1500ms) and "lands on a smaller-unit
3434 // boundary" (0.5m → 30s) drift shapes — the same two-shape
3435 // pattern the byte-size gate covers (1.5KiB → 1536, 0.5GiB →
3436 // 512MiB).
3437 let err = parse_duration("0.5m").unwrap_err();
3438 assert!(
3439 matches!(err, LimitsError::NonIntegerDurationMagnitude { ref value } if value == "0.5"),
3440 "got {err:?}"
3441 );
3442 }
3443
3444 #[test]
3445 fn parse_duration_rejects_leading_plus() {
3446 // `"+30s"` parses through f64 as 30s; the renderer emits `"30s"`
3447 // on the next serialize. The leading `+` is not a renderer-
3448 // emitted shape, so it falls in the same canonical-drift class
3449 // as the fractional forms — surfacing under the same diagnostic
3450 // keeps the gate's coverage uniform across every non-canonical-
3451 // but-numeric input shape the parser would otherwise accept.
3452 let err = parse_duration("+30s").unwrap_err();
3453 assert!(
3454 matches!(err, LimitsError::NonIntegerDurationMagnitude { ref value } if value == "+30"),
3455 "got {err:?}"
3456 );
3457 }
3458
3459 #[test]
3460 fn parse_duration_rejects_negative_seconds_via_integer_gate() {
3461 // The negative-magnitude class — pre-gate the parser routed
3462 // negatives through the `num < 0.0` check to `BadDurationMagnitude`;
3463 // the new digit-only gate fires earlier and routes the same
3464 // input to `NonIntegerDurationMagnitude` (negatives are not
3465 // digit-only). Pin the new diagnostic so a future relaxation
3466 // that re-routes negatives back to the old arm surfaces here.
3467 let err = parse_duration("-30s").unwrap_err();
3468 assert!(
3469 matches!(err, LimitsError::NonIntegerDurationMagnitude { ref value } if value == "-30"),
3470 "got {err:?}"
3471 );
3472 }
3473
3474 #[test]
3475 fn parse_duration_continues_to_accept_integer_magnitudes() {
3476 // The complement-side pin: every canonical integer-magnitude
3477 // form the renderer emits must continue to parse to the same
3478 // value the renderer produced. Sweep the canonical authoring
3479 // shapes (ms, bare-s, s, m, h, and the bare-integer "0" zero-
3480 // shape) so a future tightening of the parser surfaces here as
3481 // a test failure rather than a silent regression.
3482 assert_eq!(parse_duration("0s").unwrap(), Duration::ZERO);
3483 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3484 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
3485 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
3486 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3487 assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
3488 }
3489
3490 #[test]
3491 fn parse_duration_round_trips_through_render_for_every_canonical_form() {
3492 // The structural property the gate makes load-bearing: every
3493 // value the parser accepts round-trips through `render_duration`
3494 // to a string the parser also accepts — and to the *same* value.
3495 // Sweep the values the renderer emits canonically (ms / s / m /
3496 // h boundaries plus a non-aligned millisecond) so a future
3497 // codec change that breaks round-trip convergence surfaces here.
3498 for d in [
3499 Duration::from_millis(1),
3500 Duration::from_millis(500),
3501 Duration::from_millis(1500),
3502 Duration::from_secs(1),
3503 Duration::from_secs(30),
3504 Duration::from_secs(60),
3505 Duration::from_secs(120),
3506 Duration::from_secs(3600),
3507 ] {
3508 let rendered = render_duration(d);
3509 let reparsed = parse_duration(&rendered)
3510 .unwrap_or_else(|e| panic!("render({d:?}) = {rendered:?} must reparse, got {e:?}"));
3511 assert_eq!(
3512 reparsed, d,
3513 "round-trip drift on {d:?}: rendered={rendered:?}, reparsed={reparsed:?}",
3514 );
3515 }
3516 }
3517
3518 #[test]
3519 fn parse_duration_keeps_bad_magnitude_for_unparseable_input() {
3520 // The precedence pin: the new `NonIntegerDurationMagnitude` arm
3521 // distinguishes *non-canonical-but-numeric* (`"1.5"`, `"+30"`,
3522 // `"-30"`) from *genuinely-unparseable* (`"abc"`, `"--1"`) so
3523 // the existing `BadDurationMagnitude` diagnostic's wording
3524 // remains load-bearing for the latter class — the gate is
3525 // additive, not replacing.
3526 let err = parse_duration("abcs").unwrap_err();
3527 assert!(
3528 matches!(err, LimitsError::BadDurationMagnitude(_)),
3529 "got {err:?}"
3530 );
3531 let err = parse_duration("--1s").unwrap_err();
3532 assert!(
3533 matches!(err, LimitsError::BadDurationMagnitude(_)),
3534 "got {err:?}"
3535 );
3536 }
3537
3538 #[test]
3539 fn parse_duration_overflow_surfaces_as_bad_magnitude() {
3540 // `u64::MAX h` overflows the seconds computation (magnitude ×
3541 // 3600); the parser surfaces the overflow as a
3542 // `BadDurationMagnitude` with an overflow-shaped wording so the
3543 // diagnostic names the offending magnitude × unit pair at parse
3544 // time. Matches `parse_byte_size`'s overflow-surface arm
3545 // structurally.
3546 let err = parse_duration("18446744073709551615h").unwrap_err();
3547 let LimitsError::BadDurationMagnitude(reason) = err else {
3548 panic!("expected BadDurationMagnitude(overflow), got other variant");
3549 };
3550 assert!(
3551 reason.contains("overflow"),
3552 "overflow diagnostic must mention overflow (got {reason:?})"
3553 );
3554 }
3555
3556 // ── canonical-form: leading-zero duration codec gate ─────────────────
3557 //
3558 // Direct successor to the `supervisor::duration_codec` leading-zero
3559 // arm (9178904) and the `rate_limit_codec` leading-zero arm (4f46830)
3560 // — closes the leading-zero canonical-form-drift class on the
3561 // `:limits :wall-clock` codec. Every magnitude `render_duration`
3562 // emits is a non-negative integer with no leading-zero padding; the
3563 // parser's accepted set must match for parse → render → parse to
3564 // round-trip without canonical-form drift. The single-byte `"0"`
3565 // round-trips losslessly (`render_duration(Duration::ZERO)` emits
3566 // `"0s"`) and the downstream [`LimitsError::WallClockZero`] gate
3567 // refuses zero-magnitude authoring at the typed-validate layer above
3568 // — the codec-layer / typed-validate-layer partition is what keeps
3569 // the diagnostic partitioning stable.
3570
3571 #[test]
3572 fn parse_duration_rejects_leading_zero_magnitude() {
3573 // The fail-before-pass-after pin: `"030s"` parsed cleanly on
3574 // every pre-gate codebase (`u64::from_str` accepts the leading
3575 // zero), the codec produced 30s, and `render_duration(30s)`
3576 // emitted `"30s"` on the next serialize — silently dropping
3577 // the leading zero and drifting the canonical form away from
3578 // the author's intent. The new gate surfaces the round-trip
3579 // break at the parser layer with a self-locating diagnostic.
3580 let err = parse_duration("030s").unwrap_err();
3581 assert!(
3582 matches!(err, LimitsError::LeadingZeroDurationMagnitude { ref value } if value == "030"),
3583 "got {err:?}"
3584 );
3585 }
3586
3587 #[test]
3588 fn parse_duration_rejects_multi_digit_zero_magnitude() {
3589 // `"00s"` is the degenerate leading-zero case — every byte is
3590 // `0`. `u64::from_str("00")` = 0, and the codec produces
3591 // `Duration::ZERO`; `render_duration(Duration::ZERO)` emits
3592 // `"0s"` on the next serialize — drift from `"00s"` to `"0s"`.
3593 // The leading-zero arm refuses the drift class at the codec
3594 // layer while leaving the canonical single-byte `"0s"` accepted.
3595 let err = parse_duration("00s").unwrap_err();
3596 assert!(
3597 matches!(err, LimitsError::LeadingZeroDurationMagnitude { ref value } if value == "00"),
3598 "got {err:?}"
3599 );
3600 }
3601
3602 #[test]
3603 fn parse_duration_rejects_leading_zero_in_hour_window() {
3604 // `"01h"` parses to 1h; the renderer emits `"1h"` on the next
3605 // serialize. The leading-zero class is a property of the
3606 // magnitude, not the unit — pin a per-hour magnitude alongside
3607 // the per-second / per-ms pins so the gate's coverage is
3608 // structural across every canonical unit suffix the codec
3609 // accepts. Mirrors the `_per_hour_window` pin the
3610 // `supervisor::duration_codec` and `rate_limit_codec` leading-
3611 // zero arms carry on the peer codecs.
3612 let err = parse_duration("01h").unwrap_err();
3613 assert!(
3614 matches!(err, LimitsError::LeadingZeroDurationMagnitude { ref value } if value == "01"),
3615 "got {err:?}"
3616 );
3617 }
3618
3619 #[test]
3620 fn parse_duration_rejects_leading_zero_bare_integer_as_seconds() {
3621 // The bare-integer-as-seconds shorthand (`"30"` → 30s, no unit
3622 // suffix because the parser routes the empty `unit` slot to
3623 // `Duration::from_secs`) inherits the leading-zero arm: `"030"`
3624 // parses losslessly to 30s but `render_duration(30s)` emits
3625 // `"30s"` on the next serialize. Pin the bare-integer path so a
3626 // future relaxation that special-cases the unitless shorthand
3627 // surfaces here as a test failure.
3628 let err = parse_duration("030").unwrap_err();
3629 assert!(
3630 matches!(err, LimitsError::LeadingZeroDurationMagnitude { ref value } if value == "030"),
3631 "got {err:?}"
3632 );
3633 }
3634
3635 #[test]
3636 fn parse_duration_accepts_single_zero_magnitude_at_codec_layer() {
3637 // The codec-layer / typed-validate-layer boundary pin: the
3638 // single-byte `"0"` magnitude round-trips losslessly through
3639 // `render_duration` (`render_duration(Duration::ZERO)` emits
3640 // `"0s"`), so it stays accepted at this codec layer across
3641 // every canonical unit suffix. The downstream
3642 // `LimitsError::WallClockZero` gate is what refuses
3643 // zero-magnitude authoring at the typed-validate layer above
3644 // — the partition keeps the canonical-form-drift diagnostic
3645 // (this arm) and the semantic-zero diagnostic (the validate
3646 // gate) disjoint.
3647 assert_eq!(parse_duration("0s").unwrap(), Duration::ZERO);
3648 assert_eq!(parse_duration("0ms").unwrap(), Duration::ZERO);
3649 assert_eq!(parse_duration("0m").unwrap(), Duration::ZERO);
3650 assert_eq!(parse_duration("0h").unwrap(), Duration::ZERO);
3651 assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
3652 }
3653
3654 #[test]
3655 fn parse_duration_accepts_canonical_magnitude_with_leading_one() {
3656 // The complement-side pin on the leading-zero arm: magnitudes
3657 // beginning with `1`..=`9` stay accepted across every canonical
3658 // unit suffix the codec accepts. Pin this so a future
3659 // tightening cannot drift into rejecting valid canonical
3660 // magnitudes — peer with the `_accepts_canonical_magnitude_with_leading_one`
3661 // pin the `supervisor::duration_codec` and `rate_limit_codec`
3662 // leading-zero arms carry.
3663 assert_eq!(parse_duration("1ms").unwrap(), Duration::from_millis(1));
3664 assert_eq!(parse_duration("1s").unwrap(), Duration::from_secs(1));
3665 assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
3666 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3667 assert_eq!(parse_duration("100ms").unwrap(), Duration::from_millis(100));
3668 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3669 }
3670
3671 // ── canonical-form: whitespace-rejection duration codec gate ─────────
3672 //
3673 // Direct successor to the `supervisor::duration_codec` whitespace-
3674 // rejection arm (a7ae622) and the `rate_limit_codec` whitespace-
3675 // rejection arm (1ad7755) on the same canonical-form
3676 // render-determinism axis. The pre-gate top-level `s.trim()` at
3677 // parse entry and the per-part `num_part.trim()` / `unit.trim()`
3678 // calls silently ate leading / trailing / internal whitespace, so
3679 // every whitespace-carrying shape parsed to the same integer
3680 // magnitude and round-tripped through `render_duration` to a
3681 // *different* canonical string on next serialize — the same
3682 // canonical-form-drift class the leading-`+` / fractional /
3683 // leading-zero arms already close on this codec. `u8::is_ascii_whitespace`
3684 // covers the five WhatWG-conformant ASCII whitespace bytes
3685 // (space `0x20`, tab `0x09`, LF `0x0A`, FF `0x0C`, CR `0x0D`).
3686
3687 #[test]
3688 fn parse_duration_rejects_leading_whitespace() {
3689 // The fail-before-pass-after pin: `" 30s"` — the canonical
3690 // paste-from-aligned-doc / paste-from-YAML-quoted-plain-scalar
3691 // footgun. Before this gate the top-level `s.trim()` at parse
3692 // entry silently ate the leading space and parsed the value to
3693 // `Duration::from_secs(30)`, which then round-tripped through
3694 // `render_duration` to `"30s"` (a *different* canonical string
3695 // on the next emit) — the exact canonical-form-drift class the
3696 // leading-`+` / leading-zero arms already close, extended to
3697 // the whitespace-byte class. Peer with the sibling
3698 // `supervisor::duration_codec` `parse_rejects_leading_whitespace`
3699 // arm (a7ae622) on the shared duration-codec trajectory.
3700 let err = parse_duration(" 30s").unwrap_err();
3701 assert!(
3702 matches!(err, LimitsError::WhitespaceInDuration { ref value, byte } if value == " 30s" && byte == 0x20),
3703 "got {err:?}"
3704 );
3705 let msg = err.to_string();
3706 assert!(
3707 msg.contains("whitespace byte 0x20"),
3708 "diagnostic must surface the offending byte verbatim (got {msg:?})"
3709 );
3710 assert!(
3711 msg.contains("THEORY.md"),
3712 "diagnostic must cite the render-determinism contract (got {msg:?})"
3713 );
3714 }
3715
3716 #[test]
3717 fn parse_duration_rejects_trailing_whitespace() {
3718 // `"30s "` — the canonical shell-history / trailing-space paste
3719 // footgun. Before this gate the top-level `s.trim()` silently
3720 // ate the trailing space and parsed to `Duration::from_secs(30)`,
3721 // round-tripping to `"30s"` on the next emit — same canonical-
3722 // form drift as the leading-space sibling, closed on the same
3723 // whitespace-byte arm.
3724 let err = parse_duration("30s ").unwrap_err();
3725 assert!(
3726 matches!(err, LimitsError::WhitespaceInDuration { ref value, byte } if value == "30s " && byte == 0x20),
3727 "got {err:?}"
3728 );
3729 }
3730
3731 #[test]
3732 fn parse_duration_rejects_internal_whitespace_between_magnitude_and_unit() {
3733 // `"30 s"` — the canonical typographically-spaced author shape
3734 // (the same idiom every prose reference to a duration renders as,
3735 // mistakenly retained when the value is pasted into a codec-
3736 // shaped slot). Before this gate the per-part `num_part.trim()`
3737 // / `unit.trim()` calls silently ate the whitespace between the
3738 // magnitude and the unit and parsed the value to
3739 // `Duration::from_secs(30)`, round-tripping to `"30s"` — the
3740 // codec's *internal* whitespace-tolerance vector, orthogonal
3741 // to the leading / trailing surface but the same canonical-
3742 // form-drift class. Pins the arm as strictly stronger than the
3743 // pre-existing top-level `s.trim()` behavior: it fires on
3744 // whitespace anywhere in the value, not just at the string
3745 // boundary.
3746 let err = parse_duration("30 s").unwrap_err();
3747 assert!(
3748 matches!(err, LimitsError::WhitespaceInDuration { ref value, byte } if value == "30 s" && byte == 0x20),
3749 "got {err:?}"
3750 );
3751 }
3752
3753 #[test]
3754 fn parse_duration_rejects_tab_byte() {
3755 // `"\t30s"` — the canonical paste-from-indented-doc /
3756 // paste-from-YAML-block-scalar footgun where a tab byte leads
3757 // the magnitude. Pins that the gate covers tab (`0x09`) as well
3758 // as space (`0x20`) — both are `u8::is_ascii_whitespace` members
3759 // and both would be silently swallowed by `s.trim()` pre-gate.
3760 // The `is_ascii_whitespace` coverage extends beyond space alone
3761 // to the full ASCII-whitespace set (space `0x20`, tab `0x09`,
3762 // LF `0x0A`, FF `0x0C`, CR `0x0D`); this test pins the tab arm
3763 // as a representative of the non-space members.
3764 let err = parse_duration("\t30s").unwrap_err();
3765 assert!(
3766 matches!(err, LimitsError::WhitespaceInDuration { ref value, byte } if value == "\t30s" && byte == 0x09),
3767 "got {err:?}"
3768 );
3769 }
3770
3771 #[test]
3772 fn parse_duration_rejects_trailing_newline() {
3773 // `"30s\n"` — the canonical multi-line-paste footgun where a
3774 // trailing LF byte survives the paste. Pins the LF member
3775 // (`0x0A`) of the `is_ascii_whitespace` set as a peer to the
3776 // space and tab pins above — every non-space non-tab whitespace
3777 // byte the WhatWG ASCII-whitespace set covers is refused by
3778 // the same arm.
3779 let err = parse_duration("30s\n").unwrap_err();
3780 assert!(
3781 matches!(err, LimitsError::WhitespaceInDuration { ref value, byte } if value == "30s\n" && byte == 0x0a),
3782 "got {err:?}"
3783 );
3784 }
3785
3786 #[test]
3787 fn parse_duration_accepts_whitespace_free_canonical_forms() {
3788 // The complement-side pin: every canonical whitespace-free
3789 // authoring form the renderer emits stays accepted post-gate.
3790 // Sweep the canonical unit suffixes plus the bare-integer
3791 // shorthand so a future tightening of the whitespace arm that
3792 // over-fires on the accepted set surfaces here as a test
3793 // failure. Peer with the `parse_duration_continues_to_accept_integer_magnitudes`
3794 // pin the fractional / leading-`+` gate carries.
3795 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
3796 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3797 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
3798 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3799 assert_eq!(parse_duration("0s").unwrap(), Duration::ZERO);
3800 assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
3801 }
3802
3803 #[test]
3804 fn de_duration_rejects_whitespace_through_serde() {
3805 // The serde-path pin: a `:limits :wall-clock` carrying a
3806 // whitespace-byte-carrying value (`" 30s"`) must fail at
3807 // deserialize time, not silently round-trip the value through
3808 // the pre-existing top-level `s.trim()`. The gate fires at
3809 // deserialize, before any validate gate runs — peer with the
3810 // existing `de_duration_rejects_leading_zero_through_serde` /
3811 // `de_duration_rejects_fractional_value_through_serde` pins on
3812 // the same canonical-form-drift axis.
3813 let json = r#"{"wallClock":" 30s"}"#;
3814 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
3815 let msg = err.to_string();
3816 assert!(
3817 msg.contains("whitespace byte"),
3818 "serde diagnostic must surface the whitespace reason verbatim (got {msg:?})"
3819 );
3820 assert!(
3821 msg.contains("0x20"),
3822 "serde diagnostic must name the offending byte (got {msg:?})"
3823 );
3824
3825 // The whitespace-free complement — same author-side intent,
3826 // written in the canonical form the renderer would emit,
3827 // deserializes cleanly.
3828 let json = r#"{"wallClock":"30s"}"#;
3829 let l: LimitsSpec = serde_json::from_str(json).unwrap();
3830 assert_eq!(l.wall_clock, Some(Duration::from_secs(30)));
3831 }
3832
3833 // ── canonical-form: non-ASCII Unicode `White_Space` duration gate ─────
3834 //
3835 // Successor to the `parse_duration` ASCII-whitespace arm (ebc3a75)
3836 // — closes the strictly-complementary class the byte-scan cannot
3837 // see, through the lifted
3838 // [`crate::render::find_non_ascii_whitespace_char`] predicate.
3839
3840 #[test]
3841 fn parse_duration_rejects_leading_nbsp() {
3842 // NBSP prefix — paste-from-typography footgun. Byte-scan misses,
3843 // `str::trim` strips silently, drifting to `"30s"` on next
3844 // emit.
3845 let s = "\u{00A0}30s";
3846 let err = parse_duration(s).unwrap_err();
3847 assert!(
3848 matches!(err, LimitsError::NonAsciiWhitespaceInDuration { ref value, ch, codepoint } if value == s && ch == '\u{00A0}' && codepoint == 0x00A0),
3849 "got {err:?}"
3850 );
3851 let msg = err.to_string();
3852 assert!(
3853 msg.contains("U+00A0"),
3854 "diagnostic must name codepoint (got {msg:?})"
3855 );
3856 }
3857
3858 #[test]
3859 fn parse_duration_rejects_internal_em_space() {
3860 // EM-SPACE (`\u{2003}`) between magnitude and unit — canonical
3861 // paste-from-typography footgun on the `<integer><unit>` shape.
3862 let s = "30\u{2003}s";
3863 let err = parse_duration(s).unwrap_err();
3864 assert!(
3865 matches!(err, LimitsError::NonAsciiWhitespaceInDuration { ref value, ch, codepoint } if value == s && ch == '\u{2003}' && codepoint == 0x2003),
3866 "got {err:?}"
3867 );
3868 }
3869
3870 #[test]
3871 fn parse_duration_accepts_ascii_only_canonical_forms_after_unicode_arm() {
3872 // Positive-control pin: every ASCII-only canonical form the
3873 // renderer emits stays accepted through the new arm.
3874 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
3875 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3876 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3877 }
3878
3879 #[test]
3880 fn de_duration_rejects_leading_zero_through_serde() {
3881 // The serde-path pin: a `:limits :wall-clock` carrying a
3882 // leading-zero magnitude (`"030s"`) must fail at deserialize
3883 // time, not silently round-trip the value through the parser.
3884 // The gate fires at deserialize, before any validate gate runs
3885 // — peer with the existing `de_duration_rejects_fractional_value_through_serde`
3886 // pin on the same canonical-form-drift axis.
3887 let json = r#"{"wallClock":"030s"}"#;
3888 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
3889 let msg = err.to_string();
3890 assert!(
3891 msg.contains("leading zero"),
3892 "serde diagnostic must surface the leading-zero reason verbatim (got {msg:?})"
3893 );
3894
3895 let json = r#"{"wallClock":"30s"}"#;
3896 let l: LimitsSpec = serde_json::from_str(json).unwrap();
3897 assert_eq!(l.wall_clock, Some(Duration::from_secs(30)));
3898 }
3899
3900 #[test]
3901 fn de_duration_rejects_fractional_value_through_serde() {
3902 // The serde-path pin: a `:limits :wall-clock` carrying a
3903 // fractional magnitude (`"1.5s"`) must fail at deserialize time,
3904 // not silently round-trip the value through the f64 parser. Pin
3905 // both the success-on-canonical path (the integer form
3906 // deserializes cleanly) and the failure-on-non-canonical path
3907 // (the fractional form is rejected by the codec before any
3908 // validate gate runs).
3909 let json = r#"{"wallClock":"1.5s"}"#;
3910 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
3911 let msg = err.to_string();
3912 assert!(
3913 msg.contains("non-negative integer"),
3914 "serde diagnostic must surface the integer-magnitude reason verbatim \
3915 (got {msg:?})"
3916 );
3917
3918 // The integer-form complement — same author-side intent
3919 // (1.5s = 1500ms), written in the canonical form the renderer
3920 // would emit, deserializes cleanly.
3921 let json = r#"{"wallClock":"1500ms"}"#;
3922 let l: LimitsSpec = serde_json::from_str(json).unwrap();
3923 assert_eq!(l.wall_clock, Some(Duration::from_millis(1500)));
3924 }
3925
3926 #[test]
3927 fn de_byte_size_rejects_fractional_value_through_serde() {
3928 // The serde-path pin: a `:limits :memory` carrying a fractional
3929 // magnitude (`"1.5KiB"`) must fail at deserialize time, not
3930 // silently round-trip the value through the f64 parser. Pin
3931 // both the success-on-canonical path (the integer form
3932 // deserializes cleanly) and the failure-on-non-canonical path
3933 // (the fractional form is rejected by the codec before any
3934 // validate gate runs).
3935 let json = r#"{"memory":"1.5KiB"}"#;
3936 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
3937 let msg = err.to_string();
3938 assert!(
3939 msg.contains("non-negative integer"),
3940 "serde diagnostic must surface the integer-magnitude reason verbatim (got {msg:?})"
3941 );
3942
3943 // The integer-form complement — same author-side intent
3944 // (1.5KiB = 1536 bytes), written in the canonical form the
3945 // renderer would emit, deserializes cleanly.
3946 let json = r#"{"memory":"1536"}"#;
3947 let l: LimitsSpec = serde_json::from_str(json).unwrap();
3948 assert_eq!(l.memory, Some(1536));
3949 }
3950
3951 // ── canonical-form: integer-magnitude millicores codec gate ───────────
3952 //
3953 // Direct successor to the `parse_byte_size` / `parse_duration` /
3954 // shared `supervisor::duration_codec` / `rate_limit_codec`
3955 // integer-magnitude gates on the four peer typed codecs in
3956 // caixa-core — closes the sixth (and last) typed-codec surface in
3957 // the crate. Every magnitude `render_millicores` emits is a
3958 // non-negative integer (`format!("{m}m")`) — no decimal point, no
3959 // leading sign, no scientific notation. The parser's accepted set
3960 // must match for parse → render → parse to round-trip without
3961 // canonical-form drift. Pins every canonical-drift shape —
3962 // leading-`+` (`"+500m"` / `"+2"`, the load-bearing class the
3963 // digit-only gate closes beyond `u32::from_str` strictness),
3964 // leading-`-` (`"-100m"`), fractional (`"1.5"`), decimal-shaped-
3965 // integer on both authoring paths (`"500.0m"` / `"2.0"`), the
3966 // bare-`m`-with-no-magnitude pin, the empty-string pin, the
3967 // garbage-precedence pin (genuinely unparseable inputs keep the
3968 // narrower `BadMillicores` diagnostic), the u32-overflow surface
3969 // pin on both the `m`-suffix and bare-core multiply paths, the
3970 // complement-side pin (every integer happy path the gate must
3971 // continue to accept), the round-trip convergence property, and
3972 // the serde-path pin (the gate fires at deserialize, before any
3973 // validate gate runs).
3974
3975 #[test]
3976 fn parse_millicores_rejects_fractional_magnitude() {
3977 // The fail-before-pass-after pin on the bare-core path:
3978 // `"1.5"` parsed cleanly on no pre-gate codebase (`u32::from_str`
3979 // rejects the decimal), but the diagnostic was value-laundered
3980 // (the bare `BadMillicores("1.5")` wording didn't name the
3981 // canonical-form remediation or the round-trip drift the next
3982 // emit would produce — `1.5 cores × 1000 = 1500 millicores` →
3983 // `"1500m"` on the renderer). The gate routes the same input to
3984 // `NonIntegerMillicoreMagnitude` with the canonical-form wording.
3985 let err = parse_millicores("1.5").unwrap_err();
3986 assert!(
3987 matches!(err, LimitsError::NonIntegerMillicoreMagnitude { ref value } if value == "1.5"),
3988 "got {err:?}"
3989 );
3990 }
3991
3992 #[test]
3993 fn parse_millicores_rejects_decimal_shaped_integer_with_suffix() {
3994 // The canonical-drift case on the `m`-suffix path where the
3995 // *value* is integer but the *form* carries a redundant decimal
3996 // point — `"500.0m"` parses to 500 millicores (integer), but
3997 // the renderer emits `"500m"` on the next serialize (no decimal
3998 // point). The parse-shape gate fires here too so the codec's
3999 // accepted set is exactly the renderer's emitted set — same
4000 // shape as `parse_byte_size`'s `"1.0MiB"` case.
4001 let err = parse_millicores("500.0m").unwrap_err();
4002 assert!(
4003 matches!(err, LimitsError::NonIntegerMillicoreMagnitude { ref value } if value == "500.0"),
4004 "got {err:?}"
4005 );
4006 }
4007
4008 #[test]
4009 fn parse_millicores_rejects_decimal_shaped_integer_bare_core() {
4010 // The decimal-shaped-integer pin on the bare-core path —
4011 // `"2.0"` would be 2000 millicores (the canonical `"2000m"`),
4012 // but the redundant decimal point is not a renderer-emitted
4013 // shape. Surfaces under the same diagnostic as the `m`-suffix
4014 // path so the gate's coverage is uniform across both authoring
4015 // paths.
4016 let err = parse_millicores("2.0").unwrap_err();
4017 assert!(
4018 matches!(err, LimitsError::NonIntegerMillicoreMagnitude { ref value } if value == "2.0"),
4019 "got {err:?}"
4020 );
4021 }
4022
4023 #[test]
4024 fn parse_millicores_rejects_leading_plus_sign_with_suffix() {
4025 // The load-bearing class the digit-only gate closes beyond
4026 // `u32::from_str`'s strictness: current Rust `u32::from_str`
4027 // permissively accepts `"+500"` → 500, so `"+500m"` parsed
4028 // cleanly through the pre-gate codec to `RateLimit`-shaped
4029 // 500 millicores and serde silently round-tripped to `"500m"`
4030 // on the next emit — a *different* canonical string. Same
4031 // shape as `parse_byte_size`'s `"+1024"` (875 commit) and
4032 // `parse_duration`'s `"+30s"` (1027 commit) cases on the peer
4033 // codecs.
4034 let err = parse_millicores("+500m").unwrap_err();
4035 assert!(
4036 matches!(err, LimitsError::NonIntegerMillicoreMagnitude { ref value } if value == "+500"),
4037 "got {err:?}"
4038 );
4039 }
4040
4041 #[test]
4042 fn parse_millicores_rejects_leading_plus_sign_bare_core() {
4043 // The leading-`+` pin on the bare-core path — `"+2"` parsed
4044 // through `u32::from_str` as 2 → 2000 millicores → `"2000m"`
4045 // on the renderer; canonical-drift. The digit-only gate routes
4046 // the same input to `NonIntegerMillicoreMagnitude`, peer with
4047 // the `m`-suffix path.
4048 let err = parse_millicores("+2").unwrap_err();
4049 assert!(
4050 matches!(err, LimitsError::NonIntegerMillicoreMagnitude { ref value } if value == "+2"),
4051 "got {err:?}"
4052 );
4053 }
4054
4055 #[test]
4056 fn parse_millicores_rejects_leading_minus_sign() {
4057 // The negative-magnitude class — pre-gate `u32::from_str`
4058 // rejected negatives but the diagnostic collapsed onto the
4059 // opaque `BadMillicores("-100m")` wording. The digit-only gate
4060 // fires earlier and routes the same input to
4061 // `NonIntegerMillicoreMagnitude` (negatives are not digit-only,
4062 // and `i64::from_str` accepts the leading sign so the numeric
4063 // arm matches). Pin the new diagnostic so a future relaxation
4064 // that re-routes negatives back to the old arm surfaces here.
4065 let err = parse_millicores("-100m").unwrap_err();
4066 assert!(
4067 matches!(err, LimitsError::NonIntegerMillicoreMagnitude { ref value } if value == "-100"),
4068 "got {err:?}"
4069 );
4070 }
4071
4072 #[test]
4073 fn parse_millicores_rejects_empty_string() {
4074 // The empty-input pin — `""` is not a magnitude at all. Pre-
4075 // gate this fell through to `s.parse::<u32>()` and surfaced as
4076 // a generic parse failure with the same `BadMillicores("")`
4077 // wording; the explicit empty-check at the top of the codec
4078 // surfaces the same diagnostic earlier and makes the empty-
4079 // input class structurally distinct from the digit-only /
4080 // numeric / garbage arms below.
4081 let err = parse_millicores("").unwrap_err();
4082 assert!(matches!(err, LimitsError::BadMillicores(_)), "got {err:?}");
4083 }
4084
4085 #[test]
4086 fn parse_millicores_rejects_bare_unit_with_no_magnitude() {
4087 // The bare-`m`-with-no-magnitude pin — `"m"` strips to `""`,
4088 // which is not a magnitude at all. The canonical millicores
4089 // authoring form requires a magnitude in front of the unit
4090 // (`"500m"`, not `"m"`). Surface as `BadMillicores` so the
4091 // narrower-arm wording stays load-bearing for this class.
4092 let err = parse_millicores("m").unwrap_err();
4093 assert!(matches!(err, LimitsError::BadMillicores(_)), "got {err:?}");
4094 }
4095
4096 #[test]
4097 fn parse_millicores_garbage_still_falls_through_to_bad_millicores() {
4098 // The precedence pin: the new `NonIntegerMillicoreMagnitude`
4099 // arm distinguishes *non-canonical-but-numeric* (`"1.5"`,
4100 // `"+500m"`, `"-100m"`, `"500.0m"`) from *genuinely-
4101 // unparseable* (`"abc"`, `"--1m"`, `"foo"`) so the existing
4102 // `BadMillicores` diagnostic's wording remains load-bearing
4103 // for the latter class — the gate is additive, not replacing.
4104 // Pin both arms so a future relaxation that collapses them
4105 // surfaces here.
4106 let err = parse_millicores("abc").unwrap_err();
4107 assert!(matches!(err, LimitsError::BadMillicores(_)), "got {err:?}");
4108 let err = parse_millicores("--1m").unwrap_err();
4109 assert!(matches!(err, LimitsError::BadMillicores(_)), "got {err:?}");
4110 let err = parse_millicores("foo").unwrap_err();
4111 assert!(matches!(err, LimitsError::BadMillicores(_)), "got {err:?}");
4112 }
4113
4114 #[test]
4115 fn parse_millicores_u32_overflow_with_suffix_surfaces_as_overflow() {
4116 // The u32-overflow surface pin on the `m`-suffix path: a
4117 // magnitude exceeding `u32::MAX` (4294967296 = u32::MAX + 1)
4118 // surfaces as `BadMillicores` with an overflow-shaped wording
4119 // naming the offending magnitude verbatim. The digit-only
4120 // guard guarantees every byte is `[0-9]`, so overflow is the
4121 // only remaining `u32::from_str` failure mode — the overflow
4122 // arm is no longer in unreachable-by-prior-gate territory.
4123 // Matches the overflow-arm shape on `parse_byte_size` /
4124 // `parse_duration` / `rate_limit_codec`.
4125 let err = parse_millicores("4294967296m").unwrap_err();
4126 let LimitsError::BadMillicores(reason) = err else {
4127 panic!("expected BadMillicores(overflow), got other variant");
4128 };
4129 assert!(
4130 reason.contains("overflow"),
4131 "overflow diagnostic must mention overflow (got {reason:?})"
4132 );
4133 }
4134
4135 #[test]
4136 fn parse_millicores_bare_core_overflow_surfaces_as_overflow() {
4137 // The u32-overflow surface pin on the bare-core path: a
4138 // magnitude that fits u32 on its own but overflows on the
4139 // `× 1000` conversion to millicores surfaces as
4140 // `BadMillicores` with an overflow-shaped wording. Pre-gate
4141 // the codec used `saturating_mul(1000)` which silently
4142 // saturated the result at `u32::MAX` — landing as the cap
4143 // value far from the author's intent and bypassing any
4144 // future validate-time upper-bound gate the `:cpu` axis
4145 // grows. The `checked_mul` rewrite surfaces the overflow at
4146 // parse time. (4294968 cores × 1000 = 4294968000 > u32::MAX
4147 // = 4294967295 — the smallest digit-string that overflows
4148 // u32 on the × 1000 multiply while fitting u32 on its own.)
4149 let err = parse_millicores("4294968").unwrap_err();
4150 let LimitsError::BadMillicores(reason) = err else {
4151 panic!("expected BadMillicores(× 1000 overflow), got other variant");
4152 };
4153 assert!(
4154 reason.contains("overflow"),
4155 "× 1000 overflow diagnostic must mention overflow (got {reason:?})"
4156 );
4157 }
4158
4159 #[test]
4160 fn parse_millicores_continues_to_accept_canonical_forms() {
4161 // The complement-side pin: every canonical integer-magnitude
4162 // form the renderer emits must continue to parse to the same
4163 // value the renderer produced. Sweep the canonical authoring
4164 // shapes on both paths (the `m`-suffix path: `"0m"`, `"500m"`,
4165 // `"2000m"`; the bare-core shorthand: `"0"`, `"2"`, `"4"`) so
4166 // a future tightening of the parser surfaces here as a test
4167 // failure rather than a silent regression. The `0` case is at
4168 // the codec layer only; `validate_rejects_zero_cpu` rejects
4169 // `Some(0)` one level up.
4170 assert_eq!(parse_millicores("0m").unwrap(), 0);
4171 assert_eq!(parse_millicores("500m").unwrap(), 500);
4172 assert_eq!(parse_millicores("1500m").unwrap(), 1500);
4173 assert_eq!(parse_millicores("2000m").unwrap(), 2000);
4174 assert_eq!(parse_millicores("0").unwrap(), 0);
4175 assert_eq!(parse_millicores("2").unwrap(), 2000);
4176 assert_eq!(parse_millicores("4").unwrap(), 4000);
4177 }
4178
4179 #[test]
4180 fn parse_millicores_round_trips_through_render_for_every_canonical_form() {
4181 // The structural property the gate makes load-bearing: every
4182 // value the parser accepts round-trips through
4183 // `render_millicores` to a string the parser also accepts —
4184 // and to the *same* value. Sweep the values the renderer emits
4185 // canonically (zero, sub-core, single-core boundary, multi-
4186 // core, and a non-1000-multiple millicore value) so a future
4187 // codec change that breaks round-trip convergence surfaces
4188 // here, not at a downstream renderer that double-emits a
4189 // typed slot.
4190 for m in [0u32, 1, 100, 500, 1000, 1500, 2000, 12345] {
4191 let rendered = render_millicores(m);
4192 let reparsed = parse_millicores(&rendered)
4193 .unwrap_or_else(|e| panic!("render({m}) = {rendered:?} must reparse, got {e:?}"));
4194 assert_eq!(
4195 reparsed, m,
4196 "round-trip drift on {m}: rendered={rendered:?}, reparsed={reparsed}",
4197 );
4198 }
4199 }
4200
4201 #[test]
4202 fn de_millicores_rejects_leading_plus_through_serde() {
4203 // The serde-path pin: a `:limits :cpu` carrying a leading-`+`
4204 // magnitude (`"+500m"`) must fail at deserialize time, not
4205 // silently round-trip the value through `u32::from_str`'s
4206 // permissive sign-acceptance. Pin both the success-on-canonical
4207 // path (the integer form deserializes cleanly) and the
4208 // failure-on-non-canonical path (the leading-`+` form is
4209 // rejected by the codec before any validate gate runs).
4210 let json = r#"{"cpu":"+500m"}"#;
4211 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
4212 let msg = err.to_string();
4213 assert!(
4214 msg.contains("non-negative integer"),
4215 "serde diagnostic must surface the integer-magnitude reason verbatim \
4216 (got {msg:?})"
4217 );
4218
4219 // The integer-form complement — same author-side intent
4220 // (500 millicores), written in the canonical form the renderer
4221 // would emit, deserializes cleanly.
4222 let json = r#"{"cpu":"500m"}"#;
4223 let l: LimitsSpec = serde_json::from_str(json).unwrap();
4224 assert_eq!(l.cpu, Some(500));
4225 }
4226
4227 // ── canonical-form: leading-zero millicores codec gate ────────────────
4228 //
4229 // Direct successor to the `parse_byte_size` / `parse_duration` /
4230 // `supervisor::duration_codec` / `rate_limit_codec` leading-zero
4231 // arms (cea9a78 / 39762d7 / 9178904 / 4f46830) — closes the sixth
4232 // (and last) typed numeric-codec surface in caixa-core on the
4233 // integer-magnitude leading-zero axis. Every magnitude
4234 // `render_millicores` emits is the leading-zero-stripped form
4235 // (`format!("{m}m")` — no leading-zero padding), so a digit-only-
4236 // but-leading-zero magnitude parses losslessly through `u32::from_str`
4237 // and serde silently round-trips the value to a *different*
4238 // canonical string on the next emit. Pins every canonical-drift
4239 // shape on the `m`-suffix and bare-core paths, the codec-vs-
4240 // typed-validate-layer boundary (the single-byte `"0"` stays in the
4241 // codec's accepted set; `CpuZero` refuses it at validate), the
4242 // complement-side pin (every canonical leading-`[1-9]` magnitude
4243 // continues to parse cleanly), and the serde-path pin.
4244
4245 #[test]
4246 fn parse_millicores_rejects_leading_zero_magnitude_with_suffix() {
4247 // The fail-before-pass-after pin on the `m`-suffix path:
4248 // `"0500m"` parsed cleanly on no pre-gate codebase
4249 // (`u32::from_str` accepts `"0500"` → 500), then `render_millicores`
4250 // emitted `"500m"` on the next serialize — canonical-form drift.
4251 // The leading-zero arm routes the same input to
4252 // `LeadingZeroMillicoreMagnitude` with the canonical-form
4253 // remediation wording. Peer with the `parse_byte_size` `"064MiB"`
4254 // case and the `parse_duration` `"030s"` case.
4255 let err = parse_millicores("0500m").unwrap_err();
4256 assert!(
4257 matches!(err, LimitsError::LeadingZeroMillicoreMagnitude { ref value } if value == "0500"),
4258 "got {err:?}"
4259 );
4260 }
4261
4262 #[test]
4263 fn parse_millicores_rejects_multi_digit_zero_magnitude_with_suffix() {
4264 // The multi-zero pin on the `m`-suffix path: `"00m"` parses to 0
4265 // millicores at the codec, but the renderer emits `"0m"` on the
4266 // next serialize — the single canonical zero form on this axis.
4267 // The leading-zero arm rejects multi-byte leading-zero shapes
4268 // even when the value is zero; the single-byte `"0m"` /
4269 // bare-`"0"` stays in the codec's accepted set per the boundary
4270 // pin below. Peer with the `parse_byte_size` `"00MiB"` case and
4271 // the `parse_duration` `"00s"` case.
4272 let err = parse_millicores("00m").unwrap_err();
4273 assert!(
4274 matches!(err, LimitsError::LeadingZeroMillicoreMagnitude { ref value } if value == "00"),
4275 "got {err:?}"
4276 );
4277 }
4278
4279 #[test]
4280 fn parse_millicores_rejects_leading_zero_bare_core() {
4281 // The leading-zero pin on the bare-core path: `"02"` parsed to
4282 // 2 cores → 2000 millicores at the codec, but `render_millicores`
4283 // emits `"2000m"` on the next serialize — canonical-form drift.
4284 // The bare-core shorthand carries the same leading-zero discipline
4285 // as the `m`-suffix path; both authoring paths converge to the
4286 // same gate. Peer with the `parse_byte_size` bare-integer
4287 // `"01024"` case.
4288 let err = parse_millicores("02").unwrap_err();
4289 assert!(
4290 matches!(err, LimitsError::LeadingZeroMillicoreMagnitude { ref value } if value == "02"),
4291 "got {err:?}"
4292 );
4293 }
4294
4295 #[test]
4296 fn parse_millicores_rejects_leading_zero_multi_digit_with_suffix() {
4297 // The multi-digit leading-zero pin on the `m`-suffix path:
4298 // `"01500m"` parses to 1500 millicores at the codec, but the
4299 // renderer emits `"1500m"` on the next serialize — canonical-form
4300 // drift on a non-zero magnitude. Sweeps a different magnitude
4301 // shape than the `"0500m"` case so a future tightening that
4302 // misses the multi-digit-leading-zero class surfaces here.
4303 let err = parse_millicores("01500m").unwrap_err();
4304 assert!(
4305 matches!(err, LimitsError::LeadingZeroMillicoreMagnitude { ref value } if value == "01500"),
4306 "got {err:?}"
4307 );
4308 }
4309
4310 #[test]
4311 fn parse_millicores_accepts_single_zero_magnitude_at_codec_layer() {
4312 // The codec-layer / typed-validate-layer boundary pin: the
4313 // single-byte magnitude `"0"` (bare) and `"0m"` (with suffix)
4314 // round-trip losslessly through `render_millicores` (which
4315 // emits `"0m"` for 0 millicores), so they stay in the codec's
4316 // accepted set. The downstream `CpuZero` gate refuses
4317 // semantic-zero authoring at the typed-validate layer above —
4318 // the diagnostic partitioning between canonical-form drift
4319 // (the leading-zero arm) and semantic-zero (the `CpuZero` gate)
4320 // remains stable. Same codec-layer / typed-validate-layer
4321 // partition the peer codecs preserve.
4322 assert_eq!(parse_millicores("0").unwrap(), 0);
4323 assert_eq!(parse_millicores("0m").unwrap(), 0);
4324 }
4325
4326 #[test]
4327 fn parse_millicores_accepts_canonical_magnitude_with_leading_one() {
4328 // The complement-side pin: every canonical leading-`[1-9]`
4329 // magnitude continues to parse cleanly through the leading-zero
4330 // arm, on both the `m`-suffix and bare-core paths. Sweep the
4331 // canonical values the renderer emits across the unit-multiplier
4332 // boundary (sub-core, single-core, multi-core) so a future
4333 // tightening cannot drift into rejecting valid canonical
4334 // magnitudes. Same complement-side discipline the peer
4335 // `parse_byte_size_accepts_canonical_magnitude_with_leading_one`
4336 // and `parse_duration_accepts_canonical_magnitude_with_leading_one`
4337 // pins enforce on the sibling codecs.
4338 assert_eq!(parse_millicores("1m").unwrap(), 1);
4339 assert_eq!(parse_millicores("500m").unwrap(), 500);
4340 assert_eq!(parse_millicores("1500m").unwrap(), 1500);
4341 assert_eq!(parse_millicores("9000m").unwrap(), 9000);
4342 assert_eq!(parse_millicores("1").unwrap(), 1000);
4343 assert_eq!(parse_millicores("2").unwrap(), 2000);
4344 assert_eq!(parse_millicores("9").unwrap(), 9000);
4345 }
4346
4347 #[test]
4348 fn de_millicores_rejects_leading_zero_through_serde() {
4349 // The serde-path pin: a `:limits :cpu` carrying a leading-zero
4350 // magnitude (`"0500m"`) must fail at deserialize time, not
4351 // silently round-trip the value through `u32::from_str`'s
4352 // leading-zero-permissive accepting. Pin both the success-on-
4353 // canonical path (the leading-zero-stripped form deserializes
4354 // cleanly) and the failure-on-non-canonical path (the leading-
4355 // zero form is rejected by the codec before any validate gate
4356 // runs). Peer with the
4357 // `de_byte_size_rejects_leading_zero_through_serde` and
4358 // `de_duration_rejects_leading_zero_through_serde` pins on the
4359 // sibling codecs.
4360 let json = r#"{"cpu":"0500m"}"#;
4361 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
4362 let msg = err.to_string();
4363 assert!(
4364 msg.contains("leading zero"),
4365 "serde diagnostic must surface the leading-zero reason verbatim \
4366 (got {msg:?})"
4367 );
4368
4369 // The integer-form complement — same author-side intent
4370 // (500 millicores), written in the canonical form the renderer
4371 // would emit, deserializes cleanly.
4372 let json = r#"{"cpu":"500m"}"#;
4373 let l: LimitsSpec = serde_json::from_str(json).unwrap();
4374 assert_eq!(l.cpu, Some(500));
4375 }
4376
4377 // ── canonical-form: whitespace-rejection millicores codec gate ────────
4378 //
4379 // Direct successor to the `parse_byte_size` (24a8ad4), `parse_duration`
4380 // (ebc3a75), `supervisor::duration_codec` (a7ae622), and
4381 // `rate_limit_codec` (1ad7755) whitespace-rejection arms — closes the
4382 // fifth (and last) typed-magnitude codec surface in caixa-core on the
4383 // ASCII-whitespace axis. The pre-gate top-level `s.trim()` at parse
4384 // entry and the per-part `magnitude.trim()` calls silently ate leading
4385 // / trailing / internal whitespace, so every whitespace-carrying shape
4386 // parsed to the same millicore value and round-tripped through
4387 // `render_millicores` to a *different* canonical string on next
4388 // serialize — the same canonical-form-drift class the leading-`+` /
4389 // fractional / leading-zero arms already close on this codec.
4390
4391 #[test]
4392 fn parse_millicores_rejects_leading_whitespace() {
4393 // `" 500m"` — the canonical paste-from-aligned-doc / YAML-quoted-
4394 // plain-scalar footgun. Before this gate the top-level `s.trim()`
4395 // at parse entry silently ate the leading space and parsed the
4396 // value to 500 millicores, round-tripping to `"500m"` on next
4397 // serialize.
4398 let err = parse_millicores(" 500m").unwrap_err();
4399 assert!(
4400 matches!(err, LimitsError::WhitespaceInMillicores { ref value, byte } if value == " 500m" && byte == 0x20),
4401 "got {err:?}"
4402 );
4403 let msg = err.to_string();
4404 assert!(
4405 msg.contains("whitespace byte 0x20"),
4406 "diagnostic must surface the offending byte verbatim (got {msg:?})"
4407 );
4408 assert!(
4409 msg.contains("THEORY.md"),
4410 "diagnostic must cite the render-determinism contract (got {msg:?})"
4411 );
4412 }
4413
4414 #[test]
4415 fn parse_millicores_rejects_trailing_whitespace() {
4416 // `"500m "` — the canonical shell-history trailing-space footgun.
4417 let err = parse_millicores("500m ").unwrap_err();
4418 assert!(
4419 matches!(err, LimitsError::WhitespaceInMillicores { ref value, byte } if value == "500m " && byte == 0x20),
4420 "got {err:?}"
4421 );
4422 }
4423
4424 #[test]
4425 fn parse_millicores_rejects_internal_whitespace_between_magnitude_and_unit() {
4426 // `"500 m"` — the typographically-spaced author shape (the same
4427 // idiom every prose reference to millicores renders as). Before
4428 // this gate the per-part `magnitude.trim()` silently ate the
4429 // internal space and parsed the value to 500 millicores.
4430 let err = parse_millicores("500 m").unwrap_err();
4431 assert!(
4432 matches!(err, LimitsError::WhitespaceInMillicores { ref value, byte } if value == "500 m" && byte == 0x20),
4433 "got {err:?}"
4434 );
4435 }
4436
4437 #[test]
4438 fn parse_millicores_rejects_tab_byte() {
4439 // `"\t500m"` — the paste-from-indented-doc / YAML-block-scalar tab
4440 // footgun. Pins the tab (`0x09`) arm alongside the space arm above.
4441 let err = parse_millicores("\t500m").unwrap_err();
4442 assert!(
4443 matches!(err, LimitsError::WhitespaceInMillicores { ref value, byte } if value == "\t500m" && byte == 0x09),
4444 "got {err:?}"
4445 );
4446 }
4447
4448 #[test]
4449 fn parse_millicores_rejects_trailing_newline() {
4450 // `"500m\n"` — the multi-line-paste footgun where a trailing LF
4451 // byte survives the paste. Pins the LF member (`0x0A`) of the
4452 // `is_ascii_whitespace` set.
4453 let err = parse_millicores("500m\n").unwrap_err();
4454 assert!(
4455 matches!(err, LimitsError::WhitespaceInMillicores { ref value, byte } if value == "500m\n" && byte == 0x0a),
4456 "got {err:?}"
4457 );
4458 }
4459
4460 #[test]
4461 fn parse_millicores_accepts_whitespace_free_canonical_forms() {
4462 // The complement-side pin: every canonical whitespace-free
4463 // authoring form the renderer emits stays accepted post-gate.
4464 // Sweep the canonical `m`-suffix path plus the bare-core shorthand
4465 // so a future tightening of the whitespace arm that over-fires on
4466 // the accepted set surfaces here as a test failure.
4467 assert_eq!(parse_millicores("500m").unwrap(), 500);
4468 assert_eq!(parse_millicores("2000m").unwrap(), 2000);
4469 assert_eq!(parse_millicores("1m").unwrap(), 1);
4470 assert_eq!(parse_millicores("0m").unwrap(), 0);
4471 assert_eq!(parse_millicores("2").unwrap(), 2000);
4472 assert_eq!(parse_millicores("0").unwrap(), 0);
4473 }
4474
4475 #[test]
4476 fn de_millicores_rejects_whitespace_through_serde() {
4477 // The serde-path pin: a `:limits :cpu` carrying a whitespace-byte-
4478 // carrying value (`" 500m"`) must fail at deserialize time, not
4479 // silently round-trip the value through the pre-existing top-level
4480 // `s.trim()`. Peer with the
4481 // `de_byte_size_rejects_whitespace_through_serde` and
4482 // `de_duration_rejects_whitespace_through_serde` pins on the
4483 // sibling codecs.
4484 let json = r#"{"cpu":" 500m"}"#;
4485 let err = serde_json::from_str::<LimitsSpec>(json).unwrap_err();
4486 let msg = err.to_string();
4487 assert!(
4488 msg.contains("whitespace byte"),
4489 "serde diagnostic must surface the whitespace reason verbatim (got {msg:?})"
4490 );
4491 assert!(
4492 msg.contains("0x20"),
4493 "serde diagnostic must name the offending byte (got {msg:?})"
4494 );
4495
4496 // The whitespace-free complement — same author-side intent,
4497 // written in the canonical form the renderer would emit,
4498 // deserializes cleanly.
4499 let json = r#"{"cpu":"500m"}"#;
4500 let l: LimitsSpec = serde_json::from_str(json).unwrap();
4501 assert_eq!(l.cpu, Some(500));
4502 }
4503
4504 // ── canonical-form: non-ASCII Unicode `White_Space` millicores gate ───
4505 //
4506 // Direct successor to the ASCII-whitespace arm above — closes the
4507 // strictly-complementary class the byte-scan cannot see. `str::trim`
4508 // uses `char::is_whitespace` (Unicode `White_Space`, strictly wider
4509 // than the ASCII byte set); a leading / trailing / internal NBSP
4510 // (`\u{00A0}`) / LINE SEPARATOR (`\u{2028}`) / EM-SPACE (`\u{2003}`)
4511 // survives the byte-scan but is silently stripped by the top-level
4512 // trim, drifting to canonical `"500m"` on round-trip. Pins the arm
4513 // through the lifted [`crate::render::find_non_ascii_whitespace_char`]
4514 // predicate — the same shared predicate 1b75b38 landed on the four
4515 // peer typed-magnitude codecs, extended here to the fifth.
4516
4517 #[test]
4518 fn parse_millicores_rejects_leading_nbsp() {
4519 // NBSP (`\u{00A0}` = UTF-8 `0xC2 0xA0`) — the paste-from-typography
4520 // / paste-from-word-processor footgun. Before this arm landed the
4521 // byte-scan missed it (neither `0xC2` nor `0xA0` is
4522 // `is_ascii_whitespace`) and `str::trim` at parse entry silently
4523 // stripped it, yielding the same 500 millicores as the whitespace-
4524 // free canonical form and drifting to `"500m"` on next serialize.
4525 let s = "\u{00A0}500m";
4526 let err = parse_millicores(s).unwrap_err();
4527 assert!(
4528 matches!(err, LimitsError::NonAsciiWhitespaceInMillicores { ref value, ch, codepoint } if value == s && ch == '\u{00A0}' && codepoint == 0x00A0),
4529 "got {err:?}"
4530 );
4531 let msg = err.to_string();
4532 assert!(
4533 msg.contains("U+00A0"),
4534 "diagnostic must surface the codepoint verbatim (got {msg:?})"
4535 );
4536 assert!(
4537 msg.contains("THEORY.md"),
4538 "diagnostic must cite the render-determinism contract (got {msg:?})"
4539 );
4540 }
4541
4542 #[test]
4543 fn parse_millicores_rejects_internal_em_space() {
4544 // EM-SPACE (`\u{2003}`) between magnitude and unit — pins the arm
4545 // on an internal-position non-NBSP Unicode `White_Space` member.
4546 let s = "500\u{2003}m";
4547 let err = parse_millicores(s).unwrap_err();
4548 assert!(
4549 matches!(err, LimitsError::NonAsciiWhitespaceInMillicores { ref value, ch, codepoint } if value == s && ch == '\u{2003}' && codepoint == 0x2003),
4550 "got {err:?}"
4551 );
4552 }
4553
4554 #[test]
4555 fn parse_millicores_rejects_trailing_line_separator() {
4556 // LINE SEPARATOR (`\u{2028}`) — the canonical paste-from-web-doc
4557 // footgun (many rendering engines insert `\u{2028}` at soft-wrap
4558 // boundaries in RTF/HTML → plain text conversion). Pins the arm on
4559 // a trailing-position Unicode `White_Space` member.
4560 let s = "500m\u{2028}";
4561 let err = parse_millicores(s).unwrap_err();
4562 assert!(
4563 matches!(err, LimitsError::NonAsciiWhitespaceInMillicores { ref value, ch, codepoint } if value == s && ch == '\u{2028}' && codepoint == 0x2028),
4564 "got {err:?}"
4565 );
4566 }
4567
4568 #[test]
4569 fn parse_millicores_accepts_ascii_only_canonical_forms_after_unicode_arm() {
4570 // Positive-control pin: every ASCII-only canonical form the
4571 // renderer emits stays accepted through the new arm — the lifted
4572 // predicate is a strict no-op on ASCII input.
4573 assert_eq!(parse_millicores("500m").unwrap(), 500);
4574 assert_eq!(parse_millicores("2000m").unwrap(), 2000);
4575 assert_eq!(parse_millicores("1m").unwrap(), 1);
4576 assert_eq!(parse_millicores("2").unwrap(), 2000);
4577 }
4578
4579 // ── canonical-form: integer-millisecond :wall-clock gate ──────────────
4580 //
4581 // The peer typed-`Duration` axes routed through
4582 // `supervisor::duration_codec` (`:politicas :timeout` a4ae535,
4583 // `:circuit-breaker :window` a4ae535) already gate on
4584 // `is_integer_millisecond_duration` because the codec's `render`
4585 // truncates to `as_millis()` and parses with integer-ms granularity;
4586 // this crate's in-module `render_duration` / `parse_duration` pair
4587 // carries the same `as_millis()`-truncation shape, so the same sub-
4588 // millisecond-residue footgun lived on this axis until this gate
4589 // landed. The tests below pin the fail-before-pass-after boundary,
4590 // the diagnostic shape, the cross-arm zero-then-canonical ordering
4591 // matching the `:politicas` peer, the integer-ms happy-path sweep,
4592 // and the codec round-trip property (every validated `wall_clock`
4593 // survives serialize → deserialize equality).
4594
4595 #[test]
4596 fn validate_rejects_sub_millisecond_wall_clock() {
4597 // The fail-before-pass-after pin: a programmatic
4598 // `Duration::from_micros(1500)` (= 1_500_000 ns) silently passed
4599 // validate on every pre-gate codebase, then truncated to
4600 // `as_millis() == 1` on first serialize — `render_duration`
4601 // emits `"1ms"`, the codec parses it back to
4602 // `Duration::from_millis(1)` = 1_000_000 ns, the typed
4603 // `wall_clock` no longer matches its rendered form.
4604 let l = LimitsSpec {
4605 wall_clock: Some(Duration::from_micros(1500)),
4606 ..Default::default()
4607 };
4608 match l.validate().unwrap_err() {
4609 LimitsError::WallClockNotCanonical { wall_clock } => {
4610 assert_eq!(wall_clock, Duration::from_micros(1500));
4611 }
4612 other => panic!("expected WallClockNotCanonical, got {other:?}"),
4613 }
4614 }
4615
4616 #[test]
4617 fn validate_rejects_one_nanosecond_wall_clock() {
4618 // The far-sub-ms case: `Duration::from_nanos(1)` is non-zero
4619 // (so `WallClockZero` doesn't fire) but `as_millis() == 0`, so
4620 // `render_duration` emits the literal `"0s"` — the next serde
4621 // round-trip would parse back to `Duration::ZERO`, which the
4622 // `WallClockZero` arm then rejects on re-validate. The
4623 // canonical-form gate at this layer surfaces a self-locating
4624 // diagnostic naming the offending Duration verbatim rather
4625 // than a downstream `WallClockZero` whose remediation points
4626 // at omitting the slot.
4627 let l = LimitsSpec {
4628 wall_clock: Some(Duration::from_nanos(1)),
4629 ..Default::default()
4630 };
4631 match l.validate().unwrap_err() {
4632 LimitsError::WallClockNotCanonical { wall_clock } => {
4633 assert_eq!(wall_clock, Duration::from_nanos(1));
4634 }
4635 other => panic!("expected WallClockNotCanonical, got {other:?}"),
4636 }
4637 }
4638
4639 #[test]
4640 fn validate_rejects_nanosecond_past_canonical_boundary() {
4641 // The 1-ns-past-1ms boundary case: a `Duration` carrying
4642 // 1_000_001 ns is structurally past the integer-ms granularity
4643 // floor — `subsec_nanos() % 1_000_000 == 1`. The codec
4644 // round-trip would truncate to `1ms` and the consumer would
4645 // observe a 1-ns drift on every emit. Same boundary the peer
4646 // `is_integer_millisecond_duration_predicate_tracks_codec` test
4647 // in aplicacao.rs pins for the `:politicas` axes.
4648 let w = Duration::from_nanos(1_000_001);
4649 let l = LimitsSpec {
4650 wall_clock: Some(w),
4651 ..Default::default()
4652 };
4653 assert_eq!(
4654 l.validate().unwrap_err(),
4655 LimitsError::WallClockNotCanonical { wall_clock: w }
4656 );
4657 }
4658
4659 #[test]
4660 fn validate_accepts_integer_millisecond_wall_clock_values() {
4661 // The positive-control sweep: every `Duration` the codec can
4662 // round-trip losslessly — the canonical `<integer>{ms,s,m,h}`
4663 // set the `render_duration` / `parse_duration` pair emits and
4664 // accepts — passes `validate` without surfacing the new
4665 // canonical-form arm. Mirrors
4666 // `accepts_policy_retries_typical_values` /
4667 // `accepts_circuit_breaker_max_failures_typical_values` on
4668 // sibling axes.
4669 for w in [
4670 Duration::from_millis(1),
4671 Duration::from_millis(500),
4672 Duration::from_millis(1500),
4673 Duration::from_secs(1),
4674 Duration::from_secs(30),
4675 Duration::from_secs(60),
4676 Duration::from_secs(120),
4677 Duration::from_secs(3600),
4678 ] {
4679 let l = LimitsSpec {
4680 wall_clock: Some(w),
4681 ..Default::default()
4682 };
4683 l.validate()
4684 .unwrap_or_else(|e| panic!("integer-ms {w:?} must validate, got {e:?}"));
4685 }
4686 }
4687
4688 #[test]
4689 fn validate_wall_clock_zero_takes_precedence_over_canonical_gate() {
4690 // Cross-arm ordering pin: `Duration::ZERO` has
4691 // `subsec_nanos() == 0` and would otherwise pass the
4692 // canonical-form arm — the zero-floor arm must fire first so
4693 // the more self-locating `WallClockZero` diagnostic (with its
4694 // omit-axis remediation directly named) leads. Same posture
4695 // every peer zero-then-shape gate uses
4696 // (`PolicyTimeoutZero` → `PolicyTimeoutNotCanonical`,
4697 // `PolicyBreakerZeroWindow` → `PolicyBreakerWindowNotCanonical`).
4698 let l = LimitsSpec {
4699 wall_clock: Some(Duration::ZERO),
4700 ..Default::default()
4701 };
4702 assert_eq!(l.validate().unwrap_err(), LimitsError::WallClockZero);
4703 }
4704
4705 #[test]
4706 fn wall_clock_canonical_diagnostic_carries_offending_duration() {
4707 // Diagnostic-shape pin: the canonical-form arm names the
4708 // offending `Duration` verbatim so the author's grep lands on
4709 // the field's value, not a generic "duration not canonical"
4710 // message. Same shape every other typed-cap arm on this
4711 // surface carries (`MemoryExceedsWasm32Cap` carries the
4712 // offending byte count verbatim, `PolicyRetriesExceedsCap`
4713 // carries the offending retry count verbatim,
4714 // `PolicyBreakerMaxFailuresExceedsCap` carries the offending
4715 // u32 verbatim).
4716 let w = Duration::from_micros(500);
4717 let l = LimitsSpec {
4718 wall_clock: Some(w),
4719 ..Default::default()
4720 };
4721 let err = l.validate().unwrap_err();
4722 let msg = err.to_string();
4723 assert!(
4724 msg.contains("500"),
4725 "diagnostic must carry the offending magnitude verbatim (got {msg:?})"
4726 );
4727 }
4728
4729 #[test]
4730 fn wall_clock_validated_value_round_trips_through_codec() {
4731 // The structural property the canonical-ms gate enforces:
4732 // every `LimitsSpec::wall_clock` past `LimitsSpec::validate`
4733 // round-trips losslessly through the in-module duration codec
4734 // (serialize → string → deserialize → equal value). Pin this
4735 // end-to-end so a future change to either side (the validate
4736 // gate's accepted granularity, the codec's parse/render unit
4737 // set) that breaks the alignment surfaces here. Peer of
4738 // `policy_timeout_validated_value_round_trips_through_codec` /
4739 // `circuit_breaker_window_validated_value_round_trips_through_codec`
4740 // on the sibling `:politicas` axes.
4741 for w in [
4742 Duration::from_millis(1),
4743 Duration::from_millis(1500),
4744 Duration::from_secs(30),
4745 Duration::from_secs(3600),
4746 ] {
4747 let l = LimitsSpec {
4748 wall_clock: Some(w),
4749 ..Default::default()
4750 };
4751 l.validate().unwrap();
4752 let json = serde_json::to_string(&l).unwrap();
4753 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
4754 assert_eq!(
4755 back.wall_clock, l.wall_clock,
4756 "every validated :wall-clock must round-trip losslessly through the codec"
4757 );
4758 }
4759 }
4760
4761 // ── value-shape: :wall-clock upper bound — 1h ceiling ──────────────────
4762 //
4763 // The third typed-`Duration` axis brought to the uniform top edge
4764 // `LIMITS_WALL_CLOCK_MAX` = 1h established by the prior cap lifts
4765 // on `:politicas :timeout` (POLICY_TIMEOUT_MAX) and
4766 // `:politicas :circuit-breaker :window` (POLICY_BREAKER_WINDOW_MAX).
4767 // Mirrors the test discipline those peers carry: the
4768 // fail-before-pass-after pin, the 1ms-boundary pin, the
4769 // far-above-cap sweep (24h / 7d / ~11.5d — the values a
4770 // `(:wall-clock "24h")` typo or copy-paste typically lands), the
4771 // inclusive-at-cap positive control, the production-band positive-
4772 // control sweep, the cross-arm zero-then-cap and
4773 // canonical-then-cap ordering pins, the diagnostic-shape pin
4774 // carrying the offending `Duration` verbatim, and the cap-value
4775 // literal-identity + codec-round-trip pins anchoring the constant
4776 // to the codec's largest emitted unit and to its peer constants.
4777
4778 #[test]
4779 fn validate_rejects_wall_clock_above_cap() {
4780 // The fail-before-pass-after pin: 3601s = 1h + 1s is
4781 // structurally one canonical-tick past the
4782 // [`LIMITS_WALL_CLOCK_MAX`] ceiling (1h = 3600s) — an
4783 // integer-millisecond magnitude the canonical-form arm above
4784 // accepts cleanly, that the in-module duration codec
4785 // round-trips losslessly as `"3601s"`, and that silently
4786 // passed validate on every pre-gate codebase because the typed
4787 // slot's only checks were the zero-floor and canonical-form
4788 // arms. The wasm-engine consuming the value (the M2.5
4789 // `wasm-engine`'s epoch-deadline cancellation hook, the future
4790 // caixa-helm `pleme-computeunit` chart's `:limits` value
4791 // mapping) reaches for a `Duration` so long no realistic
4792 // synchronous wasm call hits it, far from the source
4793 // caixa.lisp.
4794 let w = LIMITS_WALL_CLOCK_MAX + Duration::from_secs(1);
4795 let l = LimitsSpec {
4796 wall_clock: Some(w),
4797 ..Default::default()
4798 };
4799 assert_eq!(
4800 l.validate().unwrap_err(),
4801 LimitsError::WallClockExceedsCap { wall_clock: w }
4802 );
4803 }
4804
4805 #[test]
4806 fn validate_rejects_wall_clock_one_millisecond_above_cap() {
4807 // Boundary case: exactly 1ms past the cap (the granularity the
4808 // canonical-form gate enforces). Catches a future "strictly
4809 // less than" half-measure and pins the diagnostic to name the
4810 // offending `Duration` verbatim. Peer of
4811 // `rejects_policy_timeout_one_millisecond_above_cap` /
4812 // `rejects_circuit_breaker_window_one_millisecond_above_cap`
4813 // on the sibling typed-`Duration` axes' top edges.
4814 let w = LIMITS_WALL_CLOCK_MAX + Duration::from_millis(1);
4815 let l = LimitsSpec {
4816 wall_clock: Some(w),
4817 ..Default::default()
4818 };
4819 assert_eq!(
4820 l.validate().unwrap_err(),
4821 LimitsError::WallClockExceedsCap { wall_clock: w }
4822 );
4823 }
4824
4825 #[test]
4826 fn validate_rejects_wall_clock_far_above_cap() {
4827 // The "obvious authoring footgun" case: a `(:wall-clock "24h")`
4828 // or `(:wall-clock "7d")` — values the canonical-form arm
4829 // accepts as integer-millisecond magnitudes, the codec
4830 // round-trips losslessly through serde, but the wasm-engine
4831 // cannot honor as a meaningful per-call deadline. Until this
4832 // gate landed validate accepted them. Pin the common
4833 // above-cap values (24h, 7d, ~11.5d) so a future relaxation
4834 // that drops the upper bound surfaces here.
4835 for w in [
4836 Duration::from_secs(86_400), // 24h
4837 Duration::from_secs(604_800), // 7d
4838 Duration::from_secs(1_000_000), // ~11.5 days
4839 ] {
4840 let l = LimitsSpec {
4841 wall_clock: Some(w),
4842 ..Default::default()
4843 };
4844 assert_eq!(
4845 l.validate().unwrap_err(),
4846 LimitsError::WallClockExceedsCap { wall_clock: w }
4847 );
4848 }
4849 }
4850
4851 #[test]
4852 fn validate_accepts_wall_clock_at_cap() {
4853 // The boundary value — exactly [`LIMITS_WALL_CLOCK_MAX`] (1h)
4854 // — must validate. The cap is inclusive on the top edge,
4855 // matching the [`crate::POLICY_TIMEOUT_MAX`] /
4856 // [`crate::POLICY_BREAKER_WINDOW_MAX`] /
4857 // [`LIMITS_MEMORY_WASM32_MAX_BYTES`] discipline on the sibling
4858 // capped axes. Pin the boundary explicitly so a future
4859 // off-by-one tightening (`>= LIMITS_WALL_CLOCK_MAX` instead of
4860 // `>`) surfaces here as a test failure rather than a silent
4861 // contract narrowing.
4862 let l = LimitsSpec {
4863 wall_clock: Some(LIMITS_WALL_CLOCK_MAX),
4864 ..Default::default()
4865 };
4866 l.validate()
4867 .expect("wall_clock == LIMITS_WALL_CLOCK_MAX must validate");
4868 }
4869
4870 #[test]
4871 fn validate_accepts_wall_clock_typical_values() {
4872 // The documented per-request production-playbook band positive-
4873 // control sweep — every value Envoy / Istio / Linkerd / AWS
4874 // App Mesh / Kubernetes ingress-nginx recommend
4875 // (1ms..=3600s) must pass, plus a sweep through the
4876 // long-running-workflow band (5m, 15m, 30m, 1h) the cap
4877 // accepts. Mirrors `accepts_policy_timeout_typical_values` on
4878 // the sibling `:politicas :timeout` axis.
4879 for w in [
4880 Duration::from_millis(1),
4881 Duration::from_millis(500),
4882 Duration::from_secs(1),
4883 Duration::from_secs(10),
4884 Duration::from_secs(15), // Envoy default
4885 Duration::from_secs(30),
4886 Duration::from_secs(60), // AWS App Mesh typical
4887 Duration::from_secs(300), // 5m
4888 Duration::from_secs(900), // 15m
4889 Duration::from_secs(1800),
4890 Duration::from_secs(3600), // exactly 1h, the cap
4891 ] {
4892 let l = LimitsSpec {
4893 wall_clock: Some(w),
4894 ..Default::default()
4895 };
4896 l.validate()
4897 .unwrap_or_else(|e| panic!("wall_clock={w:?} must validate; got {e:?}"));
4898 }
4899 }
4900
4901 #[test]
4902 fn wall_clock_zero_takes_precedence_over_cap() {
4903 // The cross-arm ordering pin: `Duration::ZERO` is structurally
4904 // outside both `>= 1ms` (zero-floor) and `<= LIMITS_WALL_CLOCK_MAX`
4905 // (cap), but the zero-floor diagnostic is the more
4906 // self-locating one (it directly names the omit-axis
4907 // remediation), so the validate gate must fire on zero first.
4908 // Same shape every other zero-then-shape ordering on this
4909 // surface uses (`MemoryZero` then `MemoryExceedsWasm32Cap`,
4910 // `PolicyTimeoutZero` then `PolicyTimeoutExceedsCap`).
4911 let l = LimitsSpec {
4912 wall_clock: Some(Duration::ZERO),
4913 ..Default::default()
4914 };
4915 assert_eq!(
4916 l.validate().unwrap_err(),
4917 LimitsError::WallClockZero,
4918 "Duration::ZERO must surface the zero-floor diagnostic, not the cap diagnostic"
4919 );
4920 }
4921
4922 #[test]
4923 fn wall_clock_canonical_takes_precedence_over_cap() {
4924 // The cross-arm ordering pin: a `Duration` that is *both*
4925 // sub-millisecond (non-canonical-form) and structurally above
4926 // the cap surfaces the canonical-form diagnostic first,
4927 // because the round-trip-shape break is the more fundamental
4928 // issue (the value can't even round-trip through the codec, so
4929 // the cap diagnostic naming `1ms..=1h` would be misleading —
4930 // there's no integer-ms form of the offending value). Pin the
4931 // order so a future refactor that reorders the arms surfaces
4932 // here as a test failure rather than a silent diagnostic
4933 // regression. Peer of
4934 // `policy_timeout_canonical_takes_precedence_over_cap`.
4935 let w = LIMITS_WALL_CLOCK_MAX + Duration::from_nanos(1);
4936 let l = LimitsSpec {
4937 wall_clock: Some(w),
4938 ..Default::default()
4939 };
4940 assert_eq!(
4941 l.validate().unwrap_err(),
4942 LimitsError::WallClockNotCanonical { wall_clock: w },
4943 "sub-ms above-cap value must surface the canonical-form diagnostic, not the cap diagnostic"
4944 );
4945 }
4946
4947 #[test]
4948 fn wall_clock_cap_diagnostic_carries_offending_value() {
4949 // The diagnostic-shape pin: the offending `Duration` is
4950 // carried verbatim into the
4951 // [`LimitsError::WallClockExceedsCap`] variant so the surfaced
4952 // error message names the value the author wrote, not just
4953 // the cap. Same self-locating diagnostic shape every other
4954 // typed-cap arm on this surface carries
4955 // (`MemoryExceedsWasm32Cap` carries the offending byte count
4956 // verbatim, `PolicyTimeoutExceedsCap` carries the offending
4957 // `Duration` verbatim).
4958 let w = Duration::from_secs(7200); // 2h
4959 let l = LimitsSpec {
4960 wall_clock: Some(w),
4961 ..Default::default()
4962 };
4963 let err = l.validate().unwrap_err();
4964 assert!(
4965 matches!(err, LimitsError::WallClockExceedsCap { wall_clock } if wall_clock == w),
4966 "got {err:?}"
4967 );
4968 let msg = err.to_string();
4969 assert!(
4970 msg.contains("7200"),
4971 ":limits :wall-clock cap diagnostic must carry the offending value verbatim (got: {msg})"
4972 );
4973 }
4974
4975 #[test]
4976 fn wall_clock_cap_pins_canonical_value() {
4977 // The [`LIMITS_WALL_CLOCK_MAX`] constant pins the value at
4978 // exactly 1 hour (3600s = 3_600_000ms) — the largest unit the
4979 // shared duration codec emits as a clean canonical string
4980 // (`"<n>h"`). Pinning the literal value here surfaces a future
4981 // drift (a relaxation to 24h, a tightening to 5m) as a
4982 // deliberate test edit, not a silent contract narrowing.
4983 //
4984 // The three typed-`Duration` caps on the validation surface
4985 // (`LIMITS_WALL_CLOCK_MAX` per-process, `POLICY_TIMEOUT_MAX`
4986 // per-edge, `POLICY_BREAKER_WINDOW_MAX` per-breaker) share a
4987 // single uniform top edge at the codec's largest emitted unit
4988 // — a structural-property invariant the equality assertions
4989 // here enshrine, so a future drift on any of the three
4990 // surfaces as a deliberate test edit. Same shape every other
4991 // typed-cap value pin uses
4992 // (`policy_timeout_cap_pins_canonical_value`,
4993 // `circuit_breaker_window_cap_pins_canonical_value`).
4994 assert_eq!(LIMITS_WALL_CLOCK_MAX, Duration::from_secs(3600));
4995 assert_eq!(LIMITS_WALL_CLOCK_MAX.as_millis(), 3_600_000);
4996 assert_eq!(LIMITS_WALL_CLOCK_MAX, crate::POLICY_TIMEOUT_MAX);
4997 assert_eq!(LIMITS_WALL_CLOCK_MAX, crate::POLICY_BREAKER_WINDOW_MAX);
4998 }
4999
5000 #[test]
5001 fn wall_clock_cap_value_round_trips_through_codec() {
5002 // The codec round-trip property the cap arm preserves: the
5003 // [`LIMITS_WALL_CLOCK_MAX`] constant itself round-trips through
5004 // the in-module duration codec — every value at the cap
5005 // renders to a clean canonical string (`"1h"`) and parses back
5006 // to the same `Duration`. Pin this so a future drift between
5007 // the cap constant and the codec's largest emitted unit
5008 // surfaces here. Same shape every other typed boundary pin on
5009 // this surface uses
5010 // (`wasm32_memory_cap_matches_parsed_4_gib`,
5011 // `policy_timeout_cap_value_round_trips_through_codec`).
5012 let l = LimitsSpec {
5013 wall_clock: Some(LIMITS_WALL_CLOCK_MAX),
5014 ..Default::default()
5015 };
5016 let json = serde_json::to_string(&l).unwrap();
5017 assert!(
5018 json.contains("\"1h\""),
5019 "the LIMITS_WALL_CLOCK_MAX value must render to the canonical \"1h\" form (got: {json})"
5020 );
5021 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
5022 assert_eq!(back.wall_clock, Some(LIMITS_WALL_CLOCK_MAX));
5023 l.validate()
5024 .expect("LIMITS_WALL_CLOCK_MAX itself must pass validate");
5025 }
5026
5027 // ── value-shape: :cpu upper bound — 128-core schedulability ceiling ─────
5028 //
5029 // The third `LimitsSpec` axis brought to a top-edge cap, peer to
5030 // the `:memory` wasm32 ceiling and the `:wall-clock` 1h ceiling.
5031 // Mirrors the test discipline those peers carry: the
5032 // fail-before-pass-after pin, the one-millicore-boundary pin, the
5033 // far-above-cap sweep, the inclusive-at-cap positive control, the
5034 // production-band positive-control sweep, the cross-arm zero-then-
5035 // cap ordering pin, the diagnostic-shape pin carrying the offending
5036 // value verbatim, and the cap-value literal-identity + codec
5037 // round-trip pins anchoring the constant.
5038
5039 #[test]
5040 fn validate_rejects_cpu_above_cap() {
5041 // The fail-before-pass-after pin: 128_001m = 128 cores + 1
5042 // millicore is structurally one canonical-tick past the
5043 // [`LIMITS_CPU_MILLICORES_MAX`] ceiling — a `u32` magnitude the
5044 // millicore codec round-trips losslessly as `"128001m"`, and
5045 // that silently passed validate on every pre-gate codebase
5046 // because the typed slot's only check was the zero-floor arm.
5047 // The Kubernetes scheduler consuming the value (via the
5048 // `pleme-computeunit` chart's `resources.requests.cpu`
5049 // projection) cannot bind the pod to any node, far from the
5050 // source caixa.lisp.
5051 let m = LIMITS_CPU_MILLICORES_MAX + 1;
5052 let l = LimitsSpec {
5053 cpu: Some(m),
5054 ..Default::default()
5055 };
5056 assert_eq!(
5057 l.validate().unwrap_err(),
5058 LimitsError::CpuExceedsCap { millicores: m }
5059 );
5060 }
5061
5062 #[test]
5063 fn validate_rejects_cpu_far_above_cap() {
5064 // The "obvious authoring footgun" case: a `(:cpu "1000000m")`
5065 // (1000 cores) or `(:cpu "4294967295m")` (≈ u32::MAX) — values
5066 // the millicore codec accepts cleanly, the codec round-trips
5067 // losslessly through serde, but the Kubernetes scheduler
5068 // cannot bind to any node. Until this gate landed validate
5069 // accepted them. Pin the common above-cap values (1000 cores,
5070 // 10_000 cores, u32::MAX) so a future relaxation that drops
5071 // the upper bound surfaces here. Peer of
5072 // `validate_rejects_memory_8_gib` /
5073 // `validate_rejects_wall_clock_far_above_cap`.
5074 for m in [1_000_000_u32, 10_000_000, u32::MAX] {
5075 let l = LimitsSpec {
5076 cpu: Some(m),
5077 ..Default::default()
5078 };
5079 assert_eq!(
5080 l.validate().unwrap_err(),
5081 LimitsError::CpuExceedsCap { millicores: m }
5082 );
5083 }
5084 }
5085
5086 #[test]
5087 fn validate_accepts_cpu_at_cap() {
5088 // The boundary value — exactly [`LIMITS_CPU_MILLICORES_MAX`]
5089 // (128 cores = 128_000m) — must validate. The cap is inclusive
5090 // on the top edge, matching the discipline on every sibling
5091 // capped axis ([`LIMITS_MEMORY_WASM32_MAX_BYTES`],
5092 // [`LIMITS_WALL_CLOCK_MAX`], [`crate::POLICY_TIMEOUT_MAX`],
5093 // [`crate::POLICY_BREAKER_WINDOW_MAX`],
5094 // [`crate::POLICY_RATE_LIMIT_MAX`]). Pin the boundary
5095 // explicitly so a future off-by-one tightening
5096 // (`>= LIMITS_CPU_MILLICORES_MAX` instead of `>`) surfaces here
5097 // as a test failure rather than a silent contract narrowing.
5098 let l = LimitsSpec {
5099 cpu: Some(LIMITS_CPU_MILLICORES_MAX),
5100 ..Default::default()
5101 };
5102 l.validate()
5103 .expect("cpu == LIMITS_CPU_MILLICORES_MAX must validate");
5104 }
5105
5106 #[test]
5107 fn validate_accepts_cpu_typical_values() {
5108 // The documented production-playbook band positive-control
5109 // sweep — every value the canonical caixa Servico runs in
5110 // (100m..=2000m) must pass, plus a sweep through the larger
5111 // burstable / multi-component-host band (4000m, 8000m, 16000m,
5112 // 32000m, 64000m, 128000m) the cap accepts. Mirrors
5113 // `accepts_wall_clock_typical_values` on the sibling
5114 // `:wall-clock` axis.
5115 for m in [
5116 1_u32, // smallest non-zero
5117 100, // typical small worker
5118 500, // canonical test default (peer to limits/flux/helm)
5119 1_000, // 1 core, single-threaded wasm32 saturation
5120 2_000, // 2 cores
5121 4_000, // typical burstable
5122 8_000, // upper realistic per-Servico band
5123 16_000, // documented heavy-Servico ceiling
5124 32_000, // wide-node multi-component-host
5125 64_000, // half the cap
5126 128_000, // exactly at cap
5127 ] {
5128 let l = LimitsSpec {
5129 cpu: Some(m),
5130 ..Default::default()
5131 };
5132 l.validate()
5133 .unwrap_or_else(|e| panic!("cpu={m}m must validate; got {e:?}"));
5134 }
5135 }
5136
5137 #[test]
5138 fn cpu_zero_takes_precedence_over_cap() {
5139 // The cross-arm ordering pin: `Some(0)` is structurally outside
5140 // both `>= 1` (zero-floor) and `<= LIMITS_CPU_MILLICORES_MAX`
5141 // (cap), but the zero-floor diagnostic is the more
5142 // self-locating one (it directly names the omit-axis
5143 // remediation), so the validate gate must fire on zero first.
5144 // Same shape every other zero-then-cap ordering on this surface
5145 // uses (`MemoryZero` then `MemoryExceedsWasm32Cap`,
5146 // `WallClockZero` then `WallClockExceedsCap`).
5147 let l = LimitsSpec {
5148 cpu: Some(0),
5149 ..Default::default()
5150 };
5151 assert_eq!(
5152 l.validate().unwrap_err(),
5153 LimitsError::CpuZero,
5154 "Some(0) must surface the zero-floor diagnostic, not the cap diagnostic"
5155 );
5156 }
5157
5158 #[test]
5159 fn validate_rejects_cpu_cap_after_earlier_axes() {
5160 // Cross-axis ordering: when both an above-cap `:cpu` and an
5161 // earlier-axis violation are present, the earlier axis must
5162 // fire first. The validate sequence is :memory → :fuel →
5163 // :wall-clock → :cpu, so a paired memory-zero + cpu-above-cap
5164 // input surfaces `MemoryZero`, never the cpu-cap diagnostic.
5165 // Pins the canonical axis order so a future refactor that
5166 // reorders the arms surfaces here as a test failure rather
5167 // than a silent diagnostic regression. Peer of
5168 // `validate_rejects_first_zero_axis_deterministically` and
5169 // `validate_rejects_memory_cap_before_other_axes`.
5170 let l = LimitsSpec {
5171 memory: Some(0),
5172 fuel: None,
5173 wall_clock: None,
5174 cpu: Some(LIMITS_CPU_MILLICORES_MAX + 1),
5175 };
5176 assert_eq!(
5177 l.validate().unwrap_err(),
5178 LimitsError::MemoryZero,
5179 "earlier-axis violation must take precedence over later-axis cap violation"
5180 );
5181 }
5182
5183 #[test]
5184 fn cpu_cap_diagnostic_carries_offending_value() {
5185 // The diagnostic-shape pin: the offending millicore count is
5186 // carried verbatim into the [`LimitsError::CpuExceedsCap`]
5187 // variant so the surfaced error message names the value the
5188 // author wrote, not just the cap. Same self-locating
5189 // diagnostic shape every other typed-cap arm on this surface
5190 // carries (`MemoryExceedsWasm32Cap` carries the offending byte
5191 // count verbatim, `WallClockExceedsCap` carries the offending
5192 // `Duration` verbatim).
5193 let m = 256_000_u32; // 256 cores — double the cap
5194 let l = LimitsSpec {
5195 cpu: Some(m),
5196 ..Default::default()
5197 };
5198 let err = l.validate().unwrap_err();
5199 assert!(
5200 matches!(err, LimitsError::CpuExceedsCap { millicores } if millicores == m),
5201 "got {err:?}"
5202 );
5203 let msg = err.to_string();
5204 assert!(
5205 msg.contains("256000"),
5206 ":limits :cpu cap diagnostic must carry the offending value verbatim (got: {msg})"
5207 );
5208 }
5209
5210 #[test]
5211 fn cpu_cap_pins_canonical_value() {
5212 // The [`LIMITS_CPU_MILLICORES_MAX`] constant pins the value at
5213 // exactly 128 cores (128_000 millicores) — the largest
5214 // commercially-common non-metal cloud Kubernetes node vCPU
5215 // count. Pinning the literal value here surfaces a future
5216 // drift (a relaxation to 256 cores, a tightening to 64 cores)
5217 // as a deliberate test edit, not a silent contract narrowing.
5218 // Same shape every other typed-cap value pin uses
5219 // (`wall_clock_cap_pins_canonical_value`,
5220 // `wasm32_memory_cap_matches_parsed_4_gib`).
5221 assert_eq!(LIMITS_CPU_MILLICORES_MAX, 128_000);
5222 assert_eq!(LIMITS_CPU_MILLICORES_MAX, 128 * 1000);
5223 }
5224
5225 #[test]
5226 fn cpu_cap_value_round_trips_through_codec() {
5227 // The codec round-trip property the cap arm preserves: the
5228 // [`LIMITS_CPU_MILLICORES_MAX`] constant itself round-trips
5229 // through the in-module millicore codec — the cap value
5230 // renders to a clean canonical string (`"128000m"`) and parses
5231 // back to the same `u32`. Pin this so a future drift between
5232 // the cap constant and the codec's accepted magnitude surfaces
5233 // here. Same shape every other typed boundary pin on this
5234 // surface uses (`wasm32_memory_cap_matches_parsed_4_gib`,
5235 // `wall_clock_cap_value_round_trips_through_codec`).
5236 let l = LimitsSpec {
5237 cpu: Some(LIMITS_CPU_MILLICORES_MAX),
5238 ..Default::default()
5239 };
5240 let json = serde_json::to_string(&l).unwrap();
5241 assert!(
5242 json.contains("\"128000m\""),
5243 "the LIMITS_CPU_MILLICORES_MAX value must render to the canonical \"128000m\" form (got: {json})"
5244 );
5245 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
5246 assert_eq!(back.cpu, Some(LIMITS_CPU_MILLICORES_MAX));
5247 l.validate()
5248 .expect("LIMITS_CPU_MILLICORES_MAX itself must pass validate");
5249 }
5250
5251 // ── value-shape: :fuel upper bound — 10^12 no-op-budget ceiling ────────
5252 //
5253 // The fourth and final `LimitsSpec` axis brought to a top-edge
5254 // cap, closing the open edge the 857dfcc CPU-cap commit body
5255 // explicitly named: "three of the four axes carry a top-and-bottom
5256 // edge gate; only `:fuel` remains with a zero-floor-only shape."
5257 // Mirrors the test discipline every sibling capped axis carries:
5258 // the fail-before-pass-after pin, the one-instruction-boundary
5259 // pin, the far-above-cap sweep, the inclusive-at-cap positive
5260 // control, the production-band positive-control sweep, the
5261 // cross-arm zero-then-cap ordering pin, the cross-axis
5262 // earlier-then-later precedence pin, the diagnostic-shape pin
5263 // carrying the offending value verbatim, and the cap-value
5264 // literal-identity + codec round-trip pins anchoring the
5265 // constant.
5266
5267 #[test]
5268 fn validate_rejects_fuel_above_cap() {
5269 // The fail-before-pass-after pin: `LIMITS_FUEL_MAX + 1` =
5270 // one wasm-instruction past the structural ceiling — a `u64`
5271 // magnitude the typed slot round-trips losslessly through
5272 // serde, and that silently passed validate on every pre-gate
5273 // codebase because the typed slot's only check was the
5274 // zero-floor arm. The wasm-engine consuming the value (via
5275 // `Store::set_fuel` projection in the M2.5 host runtime)
5276 // accepts the magnitude but the sibling `:wall-clock` 1h cap
5277 // fires before the fuel counter could ever drain — the typed
5278 // `:fuel` slot becomes a no-op budget far from the source
5279 // caixa.lisp.
5280 let f = LIMITS_FUEL_MAX + 1;
5281 let l = LimitsSpec {
5282 fuel: Some(f),
5283 ..Default::default()
5284 };
5285 assert_eq!(
5286 l.validate().unwrap_err(),
5287 LimitsError::FuelExceedsCap { fuel: f }
5288 );
5289 }
5290
5291 #[test]
5292 fn validate_rejects_fuel_far_above_cap() {
5293 // The "obvious authoring footgun" case: a `(:fuel
5294 // 1000000000000000)` (10^15 instructions), a paste-from-binary
5295 // `u64::MAX`, or a hex-literal-confused-for-decimal magnitude
5296 // — values the `u64` slot accepts cleanly, the codec
5297 // round-trips losslessly through serde, but the wasm-engine
5298 // can never honor as a meaningful counter. Until this gate
5299 // landed validate accepted them. Pin the common above-cap
5300 // values (10x cap, 1000x cap, `u64::MAX`) so a future
5301 // relaxation that drops the upper bound surfaces here. Peer
5302 // of `validate_rejects_cpu_far_above_cap` /
5303 // `validate_rejects_memory_8_gib` /
5304 // `validate_rejects_wall_clock_far_above_cap`.
5305 for f in [LIMITS_FUEL_MAX * 10, LIMITS_FUEL_MAX * 1_000, u64::MAX] {
5306 let l = LimitsSpec {
5307 fuel: Some(f),
5308 ..Default::default()
5309 };
5310 assert_eq!(
5311 l.validate().unwrap_err(),
5312 LimitsError::FuelExceedsCap { fuel: f }
5313 );
5314 }
5315 }
5316
5317 #[test]
5318 fn validate_accepts_fuel_at_cap() {
5319 // The boundary value — exactly [`LIMITS_FUEL_MAX`] (10^12
5320 // wasm instructions) — must validate. The cap is inclusive
5321 // on the top edge, matching the discipline on every sibling
5322 // capped axis ([`LIMITS_MEMORY_WASM32_MAX_BYTES`],
5323 // [`LIMITS_WALL_CLOCK_MAX`], [`LIMITS_CPU_MILLICORES_MAX`],
5324 // [`crate::POLICY_TIMEOUT_MAX`],
5325 // [`crate::POLICY_BREAKER_WINDOW_MAX`],
5326 // [`crate::POLICY_RATE_LIMIT_MAX`]). Pin the boundary
5327 // explicitly so a future off-by-one tightening
5328 // (`>= LIMITS_FUEL_MAX` instead of `>`) surfaces here as a
5329 // test failure rather than a silent contract narrowing.
5330 let l = LimitsSpec {
5331 fuel: Some(LIMITS_FUEL_MAX),
5332 ..Default::default()
5333 };
5334 l.validate().expect("fuel == LIMITS_FUEL_MAX must validate");
5335 }
5336
5337 #[test]
5338 fn validate_accepts_fuel_typical_values() {
5339 // The documented production-playbook band positive-control
5340 // sweep — every value the canonical caixa Servico runs in
5341 // (10^6..=10^9 fuel-units) must pass, plus a sweep through
5342 // the larger compute-bound-Servico band (10^10, 10^11) the
5343 // cap accepts. The canonical fixture is `1_000_000` =
5344 // wasmtime's documented `Store::set_fuel(1_000_000)` example.
5345 // Mirrors `validate_accepts_cpu_typical_values` on the
5346 // sibling `:cpu` axis.
5347 for f in [
5348 1_u64, // smallest non-zero
5349 1_000, // tiny per-call budget
5350 1_000_000, // canonical fixture (10^6) — wasmtime book example
5351 10_000_000, // typical small-Servico (10^7)
5352 100_000_000, // typical heavier-Servico (10^8)
5353 1_000_000_000, // 1 billion — upper realistic per-call (10^9)
5354 100_000_000_000, // 10^11 — heavy compute-bound (10x below cap)
5355 500_000_000_000, // half the cap
5356 1_000_000_000_000, // exactly at cap (10^12)
5357 ] {
5358 let l = LimitsSpec {
5359 fuel: Some(f),
5360 ..Default::default()
5361 };
5362 l.validate()
5363 .unwrap_or_else(|e| panic!("fuel={f} must validate; got {e:?}"));
5364 }
5365 }
5366
5367 #[test]
5368 fn fuel_zero_takes_precedence_over_cap() {
5369 // The cross-arm ordering pin: `Some(0)` is structurally
5370 // outside both `>= 1` (zero-floor) and `<= LIMITS_FUEL_MAX`
5371 // (cap), but the zero-floor diagnostic is the more
5372 // self-locating one (it directly names the omit-axis
5373 // remediation and the wasmtime-traps-at-zero semantics), so
5374 // the validate gate must fire on zero first. Same shape every
5375 // other zero-then-cap ordering on this surface uses
5376 // (`MemoryZero` then `MemoryExceedsWasm32Cap`,
5377 // `WallClockZero` then `WallClockExceedsCap`, `CpuZero` then
5378 // `CpuExceedsCap`).
5379 let l = LimitsSpec {
5380 fuel: Some(0),
5381 ..Default::default()
5382 };
5383 assert_eq!(
5384 l.validate().unwrap_err(),
5385 LimitsError::FuelZero,
5386 "Some(0) must surface the zero-floor diagnostic, not the cap diagnostic"
5387 );
5388 }
5389
5390 #[test]
5391 fn validate_rejects_fuel_cap_after_earlier_axes() {
5392 // Cross-axis ordering: when both an above-cap `:fuel` and an
5393 // earlier-axis violation are present, the earlier axis must
5394 // fire first. The validate sequence is :memory → :fuel →
5395 // :wall-clock → :cpu, so a paired memory-zero + fuel-above-
5396 // cap input surfaces `MemoryZero`, never the fuel-cap
5397 // diagnostic. Pins the canonical axis order so a future
5398 // refactor that reorders the arms surfaces here as a test
5399 // failure rather than a silent diagnostic regression. Peer
5400 // of `validate_rejects_cpu_cap_after_earlier_axes`.
5401 let l = LimitsSpec {
5402 memory: Some(0),
5403 fuel: Some(LIMITS_FUEL_MAX + 1),
5404 wall_clock: None,
5405 cpu: None,
5406 };
5407 assert_eq!(
5408 l.validate().unwrap_err(),
5409 LimitsError::MemoryZero,
5410 "earlier-axis violation must take precedence over later-axis cap violation"
5411 );
5412 }
5413
5414 #[test]
5415 fn validate_rejects_fuel_cap_before_later_axes() {
5416 // Cross-axis ordering on the other side: when both an
5417 // above-cap `:fuel` and a later-axis violation are present,
5418 // the `:fuel` cap must fire before the `:wall-clock` /
5419 // `:cpu` zero-floor diagnostics. The validate sequence is
5420 // :memory → :fuel → :wall-clock → :cpu, so a paired
5421 // fuel-above-cap + wall-clock-zero input surfaces
5422 // `FuelExceedsCap`, not `WallClockZero`. Pins the canonical
5423 // axis order on the new arm's downstream side, peer to the
5424 // upstream pin `validate_rejects_fuel_cap_after_earlier_axes`.
5425 let l = LimitsSpec {
5426 memory: None,
5427 fuel: Some(LIMITS_FUEL_MAX + 1),
5428 wall_clock: Some(Duration::ZERO),
5429 cpu: Some(0),
5430 };
5431 assert_eq!(
5432 l.validate().unwrap_err(),
5433 LimitsError::FuelExceedsCap {
5434 fuel: LIMITS_FUEL_MAX + 1
5435 },
5436 ":fuel cap diagnostic must take precedence over later-axis zero-floor diagnostics"
5437 );
5438 }
5439
5440 #[test]
5441 fn fuel_cap_diagnostic_carries_offending_value() {
5442 // The diagnostic-shape pin: the offending fuel count is
5443 // carried verbatim into the [`LimitsError::FuelExceedsCap`]
5444 // variant so the surfaced error message names the value the
5445 // author wrote, not just the cap. Same self-locating
5446 // diagnostic shape every other typed-cap arm on this surface
5447 // carries (`MemoryExceedsWasm32Cap` carries the offending
5448 // byte count verbatim, `WallClockExceedsCap` carries the
5449 // offending `Duration` verbatim, `CpuExceedsCap` carries the
5450 // offending millicore count verbatim).
5451 let f = 5_000_000_000_000_u64; // 5 trillion — 5x the cap
5452 let l = LimitsSpec {
5453 fuel: Some(f),
5454 ..Default::default()
5455 };
5456 let err = l.validate().unwrap_err();
5457 assert!(
5458 matches!(err, LimitsError::FuelExceedsCap { fuel } if fuel == f),
5459 "got {err:?}"
5460 );
5461 let msg = err.to_string();
5462 assert!(
5463 msg.contains("5000000000000"),
5464 ":limits :fuel cap diagnostic must carry the offending value verbatim (got: {msg})"
5465 );
5466 }
5467
5468 #[test]
5469 fn fuel_cap_pins_canonical_value() {
5470 // The [`LIMITS_FUEL_MAX`] constant pins the value at exactly
5471 // 10^12 (1 trillion wasm instructions) — the round-number
5472 // ceiling above the operational envelope the sibling
5473 // [`LIMITS_WALL_CLOCK_MAX`] (1h) × wasmtime's fuel-tracked
5474 // execution rate (~10^9 fuel/sec) yields. Pinning the
5475 // literal value here surfaces a future drift (a relaxation
5476 // to 10^15, a tightening to 10^9) as a deliberate test edit,
5477 // not a silent contract narrowing. Same shape every other
5478 // typed-cap value pin uses (`cpu_cap_pins_canonical_value`,
5479 // `wall_clock_cap_pins_canonical_value`,
5480 // `wasm32_memory_cap_matches_parsed_4_gib`).
5481 assert_eq!(LIMITS_FUEL_MAX, 1_000_000_000_000);
5482 assert_eq!(LIMITS_FUEL_MAX, 10_u64.pow(12));
5483 }
5484
5485 #[test]
5486 fn fuel_cap_value_round_trips_through_serde() {
5487 // The serde round-trip property the cap arm preserves: the
5488 // [`LIMITS_FUEL_MAX`] constant itself round-trips through
5489 // the in-module `u64` serde codec — the cap value renders as
5490 // the bare integer literal and parses back to the same
5491 // `u64`. Pin this so a future drift between the cap constant
5492 // and the codec's accepted magnitude (a future custom u64
5493 // serializer that introduces lossy formatting) surfaces
5494 // here. Same shape every other typed boundary pin on this
5495 // surface uses (`wasm32_memory_cap_matches_parsed_4_gib`,
5496 // `wall_clock_cap_value_round_trips_through_codec`,
5497 // `cpu_cap_value_round_trips_through_codec`).
5498 let l = LimitsSpec {
5499 fuel: Some(LIMITS_FUEL_MAX),
5500 ..Default::default()
5501 };
5502 let json = serde_json::to_string(&l).unwrap();
5503 assert!(
5504 json.contains("1000000000000"),
5505 "the LIMITS_FUEL_MAX value must render verbatim as the bare integer 10^12 \
5506 (got: {json})"
5507 );
5508 let back: LimitsSpec = serde_json::from_str(&json).unwrap();
5509 assert_eq!(back.fuel, Some(LIMITS_FUEL_MAX));
5510 l.validate()
5511 .expect("LIMITS_FUEL_MAX itself must pass validate");
5512 }
5513
5514 // ── per-`:limits :memory` accessor pins (LimitsSpec::memory) ─────────
5515
5516 #[test]
5517 fn limits_memory_returns_option_u64_byte_equal_across_permutations() {
5518 // The canonical per-`:limits` `:memory` Lunatic-per-process
5519 // wasm32-linear-memory byte-cap scalar pin: [`LimitsSpec::memory`]
5520 // must return the `:limits :memory` typed `u64` verbatim as an
5521 // `Option<u64>`, byte-equal to the raw field access across the
5522 // three canonical shape-arms — `None` (no cap declared —
5523 // engine-default applies), `Some(LIMITS_MEMORY_WASM32_PAGE_BYTES)`
5524 // (the structural minimum a validated `:limits :memory` may
5525 // carry, one wasm32 linear-memory page), `Some(64 * 1024 *
5526 // 1024)` (the canonical 64 MiB byte-cap the module-level
5527 // docstring names).
5528 //
5529 // Peer of the sibling per-`:politicas` [`crate::MeshPolicy::mtls_required`]
5530 // (c0110f1) / [`crate::MeshPolicy::retries`] (bdfb399) /
5531 // [`crate::MeshPolicy::timeout`] (7073d0f) accessor pin trio on
5532 // the sibling `Option<Copy-T>`-return axis, extended to the
5533 // peer per-`:limits` typed-`u64` optional-scalar shape —
5534 // first `Option<Copy-T>`-return accessor on the M2 slot family.
5535 // Pins against a future silent detour that re-derived the cap
5536 // from a peer axis (an accidental `.fuel`-collapse that
5537 // assumed the two `Option<u64>` axes carry the same value), a
5538 // `None` → `Some(0)` "zero means unbounded" collapse (the
5539 // canonical `Option<u64>` → `u64` collapse footgun the
5540 // [`LimitsError::MemoryZero`] validate arm guards on the peer
5541 // zero-floor axis), or a per-arm variant swap that landed on
5542 // one consumer without the other.
5543 for memory in [
5544 None,
5545 Some(LIMITS_MEMORY_WASM32_PAGE_BYTES),
5546 Some(64 * 1024 * 1024),
5547 ] {
5548 let l = LimitsSpec {
5549 memory,
5550 ..LimitsSpec::default()
5551 };
5552 assert_eq!(
5553 l.memory(),
5554 memory,
5555 "LimitsSpec::memory must return :limits :memory verbatim \
5556 (got {:?}, expected {memory:?})",
5557 l.memory(),
5558 );
5559 assert_eq!(
5560 l.memory(),
5561 l.memory,
5562 "LimitsSpec::memory must byte-equal the raw .memory \
5563 field access across every value in the accept-set",
5564 );
5565 }
5566 }
5567
5568 #[test]
5569 fn limits_is_empty_memory_arm_routes_through_accessor() {
5570 // Composition pin: [`LimitsSpec::is_empty`]'s `memory` arm
5571 // must key off [`LimitsSpec::memory`], not the raw `.memory`
5572 // field access. Structurally: setting ONLY the `memory` slot
5573 // on an otherwise-default LimitsSpec must flip `is_empty()`
5574 // from `true` (all-`None`) to `false` (one axis carries a
5575 // value); the flip must be observed across every value in the
5576 // accept-set since the emptiness semantic reads "any axis
5577 // carries a value" — not "any axis carries a value above a
5578 // threshold" — the same non-collapsing shape the sibling M3
5579 // [`crate::MeshPolicy::is_empty`] predicate carries on its
5580 // peer `Option<Copy-T>`-typed slot surfaces.
5581 //
5582 // Pins against a future silent detour that re-derived the
5583 // emptiness predicate off a peer axis (an accidental
5584 // `.fuel.is_none()`-only chain that dropped the `memory` arm
5585 // entirely), an accessor-side detour that no longer names the
5586 // substrate-primitive typed dispatch (an accidental
5587 // `self.memory.unwrap_or(0) == 0` fallback in the accessor
5588 // that would silently classify both `None` and `Some(0)` as
5589 // the same value), or a threshold collapse (a
5590 // `self.memory().is_some_and(|m| m > 0)` that would silently
5591 // classify `Some(0)` as unset).
5592 //
5593 // Peer of the sibling per-`:politicas`
5594 // [`crate::MeshPolicy::is_empty`] `mtls_required` arm
5595 // accessor-composition pin (c0110f1) on the sibling optional-
5596 // scalar axis — same "the emptiness / shape-gate predicate
5597 // must route through the substrate-primitive typed dispatch"
5598 // discipline extended onto the peer per-`:limits` emptiness
5599 // predicate.
5600 let empty = LimitsSpec::default();
5601 assert!(
5602 empty.is_empty(),
5603 "LimitsSpec::default() must be is_empty() — every axis \
5604 defaults to None",
5605 );
5606 for memory in [
5607 Some(LIMITS_MEMORY_WASM32_PAGE_BYTES),
5608 Some(64 * 1024 * 1024),
5609 Some(LIMITS_MEMORY_WASM32_MAX_BYTES),
5610 ] {
5611 let l = LimitsSpec {
5612 memory,
5613 ..LimitsSpec::default()
5614 };
5615 assert!(
5616 !l.is_empty(),
5617 "LimitsSpec::is_empty must return false when :memory \
5618 is {memory:?} — the emptiness predicate reads \"any \
5619 axis carries a value\", not \"any axis carries a \
5620 value above a threshold\"",
5621 );
5622 assert_eq!(
5623 l.memory().is_none(),
5624 l.is_empty(),
5625 "when :memory is the only set axis, is_empty() must \
5626 equal memory().is_none() — the accessor and the \
5627 emptiness predicate must route through the same \
5628 substrate-primitive typed dispatch on the :memory \
5629 arm",
5630 );
5631 }
5632 }
5633
5634 #[test]
5635 fn limits_memory_projects_option_u64_by_copy() {
5636 // The by-copy pin: [`LimitsSpec::memory`] returns `Option<u64>`
5637 // by copy — `Option<u64>` is `Copy` and the accessor must
5638 // return by value, not by reference. Peer of the sibling per-
5639 // `:politicas` [`crate::MeshPolicy::mtls_required`] (c0110f1)
5640 // borrow-invariant pin on the peer `Option<bool>` shape,
5641 // extended onto the peer `Option<u64>` copy-invariant shape —
5642 // the accessor's returned `Option<u64>` must outlive `&self`
5643 // (multiple calls must return equal values from a dropped-
5644 // `&self` copy, since the returned Option carries no borrow),
5645 // and calling the accessor twice on the same LimitsSpec must
5646 // yield the same `Option<u64>` verbatim (idempotent, no side
5647 // effects on `&self`).
5648 //
5649 // Pins against a future silent detour that returned
5650 // `Option<&u64>` (which would type-check but silently break
5651 // every downstream caller — the future `wasmtime::Store::limiter`
5652 // wire path consumes `Option<u64>` by value and `&u64` would
5653 // fold to a detached copy at the call site), an accidental
5654 // `Option::as_ref()` projection (`self.memory.as_ref()` would
5655 // also type-check but return `Option<&u64>`), or a one-arm-
5656 // only accessor that reads `Some(*m)` in the Some arm but
5657 // reads a fresh `Default::default()` in the None arm.
5658 for memory in [
5659 None,
5660 Some(LIMITS_MEMORY_WASM32_PAGE_BYTES),
5661 Some(64 * 1024 * 1024),
5662 Some(LIMITS_MEMORY_WASM32_MAX_BYTES),
5663 ] {
5664 let l = LimitsSpec {
5665 memory,
5666 ..LimitsSpec::default()
5667 };
5668 let first = l.memory();
5669 let second = l.memory();
5670 assert_eq!(
5671 first, second,
5672 "LimitsSpec::memory must be idempotent — two \
5673 successive calls on the same &self must return the \
5674 same Option<u64>",
5675 );
5676 assert_eq!(
5677 first, memory,
5678 "LimitsSpec::memory must return :limits :memory \
5679 verbatim by copy — got {first:?}, expected {memory:?}",
5680 );
5681 }
5682 }
5683
5684 #[test]
5685 #[allow(clippy::too_many_lines)]
5686 fn validate_memory_arms_route_through_lifted_memory_accessor() {
5687 // Composition pin: every value-shape gate in
5688 // [`LimitsSpec::validate`] on the `:memory` axis (the
5689 // zero-floor `MemoryZero` arm, the sub-page `MemoryBelowWasm32Page`
5690 // arm, the above-cap `MemoryExceedsWasm32Cap` arm, the
5691 // non-page-multiple `MemoryNotPageMultiple` arm) must key off
5692 // [`LimitsSpec::memory`], not the raw `self.memory` field
5693 // access. Peer of the sibling per-`:politicas`
5694 // [`crate::AplicacaoSpec::validate_politicas`] `:timeout` /
5695 // `:retries` arm converge pin (1017b9d) on the sibling M3
5696 // mesh-slot family, extended onto the M2 per-`:limits`
5697 // `:memory` axis; peer of the sibling per-`:limits` `:fuel` /
5698 // `:wall-clock` / `:cpu` arms in the same fan-out that
5699 // already route through `self.fuel()` / `self.wall_clock()`
5700 // / `self.cpu()` at :880 / :888 / :942.
5701 //
5702 // Assertion shape: for each memory value in the
5703 // accept-and-refuse set, `LimitsSpec::memory()` must byte-
5704 // equal the raw `.memory` field it borrows from, and the
5705 // validate call on a `LimitsSpec { memory: <v>, ..default() }`
5706 // fixture must surface the same variant/Ok discriminant the
5707 // accessor-composed spec surfaces. Together they catch any
5708 // future silent detour — an accessor drift that no longer
5709 // shipped the raw slot verbatim, a validate-branch rebrand to
5710 // a peer-axis field read, an accidental `Option`-collapse in
5711 // any of the four arms — at caixa-core build time rather than
5712 // at a downstream runtime declared-but-inert-limits divergence
5713 // at the wasmtime `Store::limiter` boundary.
5714 //
5715 // `#[allow(clippy::too_many_lines)]` per the same discipline
5716 // peer over-100-line composition pins in this module accept
5717 // (see e.g. `limits_is_empty_memory_arm_routes_through_accessor`,
5718 // `limits_memory_returns_option_u64_byte_equal_across_permutations`).
5719 for memory in [
5720 None,
5721 Some(0), // → MemoryZero
5722 Some(1), // → MemoryBelowWasm32Page (sub-page)
5723 Some(LIMITS_MEMORY_WASM32_PAGE_BYTES - 1), // → MemoryBelowWasm32Page (at-under-page)
5724 Some(LIMITS_MEMORY_WASM32_PAGE_BYTES), // → Ok (at-page-floor)
5725 Some(LIMITS_MEMORY_WASM32_PAGE_BYTES + 1), // → MemoryNotPageMultiple (one-past-page)
5726 Some(2 * LIMITS_MEMORY_WASM32_PAGE_BYTES), // → Ok (multi-page)
5727 Some(LIMITS_MEMORY_WASM32_MAX_BYTES), // → Ok (at-cap)
5728 Some(LIMITS_MEMORY_WASM32_MAX_BYTES + 1), // → MemoryExceedsWasm32Cap (one-past-cap)
5729 ] {
5730 let l = LimitsSpec {
5731 memory,
5732 ..LimitsSpec::default()
5733 };
5734 // (1) The accessor must byte-equal the raw field it wraps.
5735 assert_eq!(
5736 l.memory(),
5737 l.memory,
5738 "LimitsSpec::memory() must byte-equal the raw \
5739 .memory field for {memory:?} — an accessor detour \
5740 that dropped the raw slot's Option<u64> verbatim \
5741 would silently split validate's :memory arms from \
5742 every peer emit-site consumer that also routes \
5743 through the accessor (the future wasmtime \
5744 Store::limiter wire path, the caixa-helm \
5745 resources.limits.memory materializer)",
5746 );
5747 // (2) Two successive validate() calls must yield the same
5748 // variant/Ok discriminant — the accessor-projected reads
5749 // and the raw-projected reads must produce identical
5750 // validation outcomes.
5751 let first = l.validate();
5752 let second = l.validate();
5753 assert_eq!(
5754 first, second,
5755 "LimitsSpec::validate must be idempotent on :memory \
5756 {memory:?} — two successive calls must surface the \
5757 same variant/Ok discriminant, catching any accessor \
5758 detour that would introduce a value-dependent side \
5759 effect on the &self projection",
5760 );
5761 }
5762 // (3) The specific arm-order shape the four converged sites
5763 // encode: `MemoryZero` (raw-`Some(0)`) precedes the page-floor
5764 // arm, which precedes the cap arm, which precedes the page-
5765 // multiple arm. Each arm must fire off the accessor-projected
5766 // read on its specific fixture value.
5767 assert_eq!(
5768 LimitsSpec {
5769 memory: Some(0),
5770 ..LimitsSpec::default()
5771 }
5772 .validate(),
5773 Err(LimitsError::MemoryZero),
5774 "MemoryZero must fire on Some(0) via the accessor projection",
5775 );
5776 assert_eq!(
5777 LimitsSpec {
5778 memory: Some(1),
5779 ..LimitsSpec::default()
5780 }
5781 .validate(),
5782 Err(LimitsError::MemoryBelowWasm32Page { bytes: 1 }),
5783 "MemoryBelowWasm32Page must fire on Some(1) via the accessor projection",
5784 );
5785 assert_eq!(
5786 LimitsSpec {
5787 memory: Some(LIMITS_MEMORY_WASM32_MAX_BYTES + 1),
5788 ..LimitsSpec::default()
5789 }
5790 .validate(),
5791 Err(LimitsError::MemoryExceedsWasm32Cap {
5792 bytes: LIMITS_MEMORY_WASM32_MAX_BYTES + 1
5793 }),
5794 "MemoryExceedsWasm32Cap must fire on one-past-cap via the accessor projection",
5795 );
5796 assert_eq!(
5797 LimitsSpec {
5798 memory: Some(LIMITS_MEMORY_WASM32_PAGE_BYTES + 1),
5799 ..LimitsSpec::default()
5800 }
5801 .validate(),
5802 Err(LimitsError::MemoryNotPageMultiple {
5803 bytes: LIMITS_MEMORY_WASM32_PAGE_BYTES + 1
5804 }),
5805 "MemoryNotPageMultiple must fire on one-past-page-floor via the accessor projection",
5806 );
5807 assert_eq!(
5808 LimitsSpec {
5809 memory: Some(LIMITS_MEMORY_WASM32_PAGE_BYTES),
5810 ..LimitsSpec::default()
5811 }
5812 .validate(),
5813 Ok(()),
5814 "at-page-floor must pass validate via the accessor projection",
5815 );
5816 assert_eq!(
5817 LimitsSpec {
5818 memory: Some(LIMITS_MEMORY_WASM32_MAX_BYTES),
5819 ..LimitsSpec::default()
5820 }
5821 .validate(),
5822 Ok(()),
5823 "at-cap must pass validate via the accessor projection",
5824 );
5825 }
5826
5827 // ── per-`:limits :fuel` accessor pins (LimitsSpec::fuel) ─────────
5828
5829 #[test]
5830 fn limits_fuel_returns_option_u64_byte_equal_across_permutations() {
5831 // The canonical per-`:limits` `:fuel` wasmtime-per-call
5832 // wasm-instruction budget scalar pin: [`LimitsSpec::fuel`]
5833 // must return the `:limits :fuel` typed `u64` verbatim as an
5834 // `Option<u64>`, byte-equal to the raw field access across
5835 // the three canonical shape-arms — `None` (no fuel budget
5836 // declared — engine-default applies), `Some(1)` (the
5837 // structural minimum a validated `:limits :fuel` may carry,
5838 // one wasm instruction; wasmtime traps the first instruction
5839 // at `fuel=0`, so `Some(1)` is the smallest budget that
5840 // executes any code), `Some(1_000_000)` (the canonical 10⁶
5841 // fuel-unit budget the in-tree `Caixa::template` and the
5842 // wasmtime book's `Store::set_fuel(1_000_000)` example both
5843 // carry).
5844 //
5845 // Peer of the sibling per-`:limits` [`LimitsSpec::memory`]
5846 // (620c067) accessor byte-equality pin on the peer typed-`u64`
5847 // optional-scalar axis, extended to the wasm-instruction-budget
5848 // shape — second `Option<Copy-T>`-return accessor on the M2
5849 // slot family. Pins against a future silent detour that
5850 // re-derived the fuel budget from a peer axis (an accidental
5851 // `.memory`-collapse that assumed the two `Option<u64>` axes
5852 // carry the same value — the two axes share a shape but not
5853 // a semantic, `:memory` counts linear-memory bytes and `:fuel`
5854 // counts wasm instructions), a `None` → `Some(0)` "zero means
5855 // unbounded" collapse (the canonical `Option<u64>` → `u64`
5856 // collapse footgun the [`LimitsError::FuelZero`] validate arm
5857 // guards on the peer zero-floor axis; wasmtime interprets
5858 // `fuel=0` as "trap the first instruction" not "no bound"), or
5859 // a per-arm variant swap that landed on one consumer without
5860 // the other.
5861 for fuel in [None, Some(1_u64), Some(1_000_000_u64)] {
5862 let l = LimitsSpec {
5863 fuel,
5864 ..LimitsSpec::default()
5865 };
5866 assert_eq!(
5867 l.fuel(),
5868 fuel,
5869 "LimitsSpec::fuel must return :limits :fuel verbatim \
5870 (got {:?}, expected {fuel:?})",
5871 l.fuel(),
5872 );
5873 assert_eq!(
5874 l.fuel(),
5875 l.fuel,
5876 "LimitsSpec::fuel must byte-equal the raw .fuel \
5877 field access across every value in the accept-set",
5878 );
5879 }
5880 }
5881
5882 #[test]
5883 fn limits_is_empty_fuel_arm_routes_through_accessor() {
5884 // Composition pin: [`LimitsSpec::is_empty`]'s `fuel` arm
5885 // must key off [`LimitsSpec::fuel`], not the raw `.fuel`
5886 // field access. Structurally: setting ONLY the `fuel` slot
5887 // on an otherwise-default LimitsSpec must flip `is_empty()`
5888 // from `true` (all-`None`) to `false` (one axis carries a
5889 // value); the flip must be observed across every value in
5890 // the accept-set since the emptiness semantic reads "any
5891 // axis carries a value" — not "any axis carries a value
5892 // above a threshold" — the same non-collapsing shape the
5893 // sibling M3 [`crate::MeshPolicy::is_empty`] predicate
5894 // carries on its peer `Option<Copy-T>`-typed slot surfaces
5895 // and the sibling per-`:limits` [`LimitsSpec::memory`]
5896 // (620c067) `is_empty()` accessor-composition pin carries on
5897 // the peer `Option<u64>` axis.
5898 //
5899 // Pins against a future silent detour that re-derived the
5900 // emptiness predicate off a peer axis (an accidental
5901 // `.memory.is_none()`-only chain that dropped the `fuel` arm
5902 // entirely), an accessor-side detour that no longer names the
5903 // substrate-primitive typed dispatch (an accidental
5904 // `self.fuel.unwrap_or(0) == 0` fallback in the accessor
5905 // that would silently classify both `None` and `Some(0)` as
5906 // the same value — a footgun the [`LimitsError::FuelZero`]
5907 // validate arm explicitly closes since `fuel=0` traps rather
5908 // than expresses "unbounded"), or a threshold collapse (a
5909 // `self.fuel().is_some_and(|f| f > 0)` that would silently
5910 // classify `Some(0)` as unset).
5911 //
5912 // Peer of the sibling per-`:limits` [`LimitsSpec::memory`]
5913 // (620c067) `is_empty` composition pin on the peer
5914 // `Option<u64>` axis — same "the emptiness predicate must
5915 // route through the substrate-primitive typed dispatch"
5916 // discipline extended onto the peer per-`:limits` `:fuel`
5917 // arm.
5918 let empty = LimitsSpec::default();
5919 assert!(
5920 empty.is_empty(),
5921 "LimitsSpec::default() must be is_empty() — every axis \
5922 defaults to None",
5923 );
5924 for fuel in [Some(1_u64), Some(1_000_000_u64), Some(LIMITS_FUEL_MAX)] {
5925 let l = LimitsSpec {
5926 fuel,
5927 ..LimitsSpec::default()
5928 };
5929 assert!(
5930 !l.is_empty(),
5931 "LimitsSpec::is_empty must return false when :fuel \
5932 is {fuel:?} — the emptiness predicate reads \"any \
5933 axis carries a value\", not \"any axis carries a \
5934 value above a threshold\"",
5935 );
5936 assert_eq!(
5937 l.fuel().is_none(),
5938 l.is_empty(),
5939 "when :fuel is the only set axis, is_empty() must \
5940 equal fuel().is_none() — the accessor and the \
5941 emptiness predicate must route through the same \
5942 substrate-primitive typed dispatch on the :fuel \
5943 arm",
5944 );
5945 }
5946 }
5947
5948 #[test]
5949 fn limits_fuel_projects_option_u64_by_copy() {
5950 // The by-copy pin: [`LimitsSpec::fuel`] returns `Option<u64>`
5951 // by copy — `Option<u64>` is `Copy` and the accessor must
5952 // return by value, not by reference. Peer of the sibling per-
5953 // `:limits` [`LimitsSpec::memory`] (620c067) copy-invariant
5954 // pin on the peer `Option<u64>` shape — the accessor's
5955 // returned `Option<u64>` must outlive `&self` (multiple calls
5956 // must return equal values from a dropped-`&self` copy, since
5957 // the returned Option carries no borrow), and calling the
5958 // accessor twice on the same LimitsSpec must yield the same
5959 // `Option<u64>` verbatim (idempotent, no side effects on
5960 // `&self`).
5961 //
5962 // Pins against a future silent detour that returned
5963 // `Option<&u64>` (which would type-check but silently break
5964 // every downstream caller — the future `wasmtime::Store::set_fuel`
5965 // wire path consumes `u64` by value and `&u64` would fold to
5966 // a detached copy at the call site), an accidental
5967 // `Option::as_ref()` projection (`self.fuel.as_ref()` would
5968 // also type-check but return `Option<&u64>`), or a one-arm-
5969 // only accessor that reads `Some(*f)` in the Some arm but
5970 // reads a fresh `Default::default()` in the None arm.
5971 for fuel in [
5972 None,
5973 Some(1_u64),
5974 Some(1_000_000_u64),
5975 Some(LIMITS_FUEL_MAX),
5976 ] {
5977 let l = LimitsSpec {
5978 fuel,
5979 ..LimitsSpec::default()
5980 };
5981 let first = l.fuel();
5982 let second = l.fuel();
5983 assert_eq!(
5984 first, second,
5985 "LimitsSpec::fuel must be idempotent — two \
5986 successive calls on the same &self must return the \
5987 same Option<u64>",
5988 );
5989 assert_eq!(
5990 first, fuel,
5991 "LimitsSpec::fuel must return :limits :fuel \
5992 verbatim by copy — got {first:?}, expected {fuel:?}",
5993 );
5994 }
5995 }
5996
5997 // ── per-`:limits :wall-clock` accessor pins (LimitsSpec::wall_clock) ─
5998
5999 #[test]
6000 fn limits_wall_clock_returns_option_duration_byte_equal_across_permutations() {
6001 // The canonical per-`:limits` `:wall-clock` wasmtime-per-call
6002 // wall-clock deadline scalar pin: [`LimitsSpec::wall_clock`]
6003 // must return the `:limits :wall-clock` typed `Duration`
6004 // verbatim as an `Option<Duration>`, byte-equal to the raw
6005 // field access across the three canonical shape-arms — `None`
6006 // (no wall-clock deadline declared — engine-default applies),
6007 // `Some(Duration::from_millis(1))` (the structural minimum a
6008 // validated `:limits :wall-clock` may carry, the
6009 // integer-millisecond floor
6010 // [`LimitsError::WallClockNotCanonical`] rejects everything
6011 // sub-ms; `Duration::ZERO` is separately rejected by
6012 // [`LimitsError::WallClockZero`]), `Some(Duration::from_secs(30))`
6013 // (the canonical 30s deadline the module-level docstring
6014 // names).
6015 //
6016 // Peer of the sibling per-`:limits` [`LimitsSpec::memory`]
6017 // (620c067) / [`LimitsSpec::fuel`] (795dee7) accessor
6018 // byte-equality pins on the peer typed-`u64` optional-scalar
6019 // axes, extended to the wall-clock-deadline `Option<Duration>`
6020 // shape — third `Option<Copy-T>`-return accessor on the M2 slot
6021 // family. Sibling to [`crate::MeshPolicy::timeout`] (7073d0f) on
6022 // the M3 mesh-slot family's peer `Option<Duration>` accessor
6023 // axis — same typed-`Duration` shape extended from the M3
6024 // per-call-timeout axis to the M2 per-outermost-call-deadline
6025 // axis. Pins against a future silent detour that re-derived the
6026 // wall-clock deadline from a peer axis (an accidental
6027 // `.fuel`-collapse that assumed the wall-clock deadline and
6028 // the fuel budget carry the same value — the two axes serve
6029 // different sandboxing purposes, wall-clock tracks scheduler
6030 // real time and fuel tracks wasm instructions), a `None` →
6031 // `Some(Duration::ZERO)` "zero means unbounded" collapse (the
6032 // canonical `Option<Duration>` → `Duration` collapse footgun
6033 // the [`LimitsError::WallClockZero`] validate arm guards on the
6034 // peer zero-floor axis; a zero deadline traps the first
6035 // instruction), or a per-arm variant swap that landed on one
6036 // consumer without the other.
6037 for wall_clock in [
6038 None,
6039 Some(Duration::from_millis(1)),
6040 Some(Duration::from_secs(30)),
6041 ] {
6042 let l = LimitsSpec {
6043 wall_clock,
6044 ..LimitsSpec::default()
6045 };
6046 assert_eq!(
6047 l.wall_clock(),
6048 wall_clock,
6049 "LimitsSpec::wall_clock must return :limits :wall-clock verbatim \
6050 (got {:?}, expected {wall_clock:?})",
6051 l.wall_clock(),
6052 );
6053 assert_eq!(
6054 l.wall_clock(),
6055 l.wall_clock,
6056 "LimitsSpec::wall_clock must byte-equal the raw .wall_clock \
6057 field access across every value in the accept-set",
6058 );
6059 }
6060 }
6061
6062 #[test]
6063 fn limits_is_empty_wall_clock_arm_routes_through_accessor() {
6064 // Composition pin: [`LimitsSpec::is_empty`]'s `wall_clock` arm
6065 // must key off [`LimitsSpec::wall_clock`], not the raw
6066 // `.wall_clock` field access. Structurally: setting ONLY the
6067 // `wall_clock` slot on an otherwise-default LimitsSpec must
6068 // flip `is_empty()` from `true` (all-`None`) to `false` (one
6069 // axis carries a value); the flip must be observed across every
6070 // value in the accept-set since the emptiness semantic reads
6071 // "any axis carries a value" — not "any axis carries a value
6072 // above a threshold" — the same non-collapsing shape the
6073 // sibling M3 [`crate::MeshPolicy::is_empty`] predicate carries
6074 // on its peer `Option<Copy-T>`-typed slot surfaces and the
6075 // sibling per-`:limits` [`LimitsSpec::memory`] (620c067) /
6076 // [`LimitsSpec::fuel`] (795dee7) `is_empty()` accessor-
6077 // composition pins carry on the peer `Option<u64>` axes.
6078 //
6079 // Pins against a future silent detour that re-derived the
6080 // emptiness predicate off a peer axis (an accidental
6081 // `.memory.is_none()`-only chain that dropped the `wall_clock`
6082 // arm entirely), an accessor-side detour that no longer names
6083 // the substrate-primitive typed dispatch (an accidental
6084 // `self.wall_clock.unwrap_or(Duration::ZERO).is_zero()` fallback
6085 // in the accessor that would silently classify both `None` and
6086 // `Some(Duration::ZERO)` as the same value — a footgun the
6087 // [`LimitsError::WallClockZero`] validate arm explicitly closes
6088 // since a zero deadline traps rather than expresses
6089 // "unbounded"), or a threshold collapse (a
6090 // `self.wall_clock().is_some_and(|w| !w.is_zero())` that would
6091 // silently classify `Some(Duration::ZERO)` as unset).
6092 //
6093 // Peer of the sibling per-`:limits` [`LimitsSpec::memory`]
6094 // (620c067) / [`LimitsSpec::fuel`] (795dee7) `is_empty`
6095 // composition pins on the peer `Option<u64>` axes — same "the
6096 // emptiness predicate must route through the substrate-
6097 // primitive typed dispatch" discipline extended onto the peer
6098 // per-`:limits` `:wall-clock` arm.
6099 let empty = LimitsSpec::default();
6100 assert!(
6101 empty.is_empty(),
6102 "LimitsSpec::default() must be is_empty() — every axis \
6103 defaults to None",
6104 );
6105 for wall_clock in [
6106 Some(Duration::from_millis(1)),
6107 Some(Duration::from_secs(30)),
6108 Some(LIMITS_WALL_CLOCK_MAX),
6109 ] {
6110 let l = LimitsSpec {
6111 wall_clock,
6112 ..LimitsSpec::default()
6113 };
6114 assert!(
6115 !l.is_empty(),
6116 "LimitsSpec::is_empty must return false when :wall-clock \
6117 is {wall_clock:?} — the emptiness predicate reads \"any \
6118 axis carries a value\", not \"any axis carries a \
6119 value above a threshold\"",
6120 );
6121 assert_eq!(
6122 l.wall_clock().is_none(),
6123 l.is_empty(),
6124 "when :wall-clock is the only set axis, is_empty() must \
6125 equal wall_clock().is_none() — the accessor and the \
6126 emptiness predicate must route through the same \
6127 substrate-primitive typed dispatch on the :wall-clock \
6128 arm",
6129 );
6130 }
6131 }
6132
6133 #[test]
6134 fn limits_wall_clock_projects_option_duration_by_copy() {
6135 // The by-copy pin: [`LimitsSpec::wall_clock`] returns
6136 // `Option<Duration>` by copy — `Duration` is `Copy` (so
6137 // `Option<Duration>` is `Copy`) and the accessor must return by
6138 // value, not by reference. Peer of the sibling per-`:limits`
6139 // [`LimitsSpec::memory`] (620c067) / [`LimitsSpec::fuel`]
6140 // (795dee7) copy-invariant pins on the peer `Option<u64>`
6141 // shape, extended onto the peer `Option<Duration>` shape — the
6142 // accessor's returned `Option<Duration>` must outlive `&self`
6143 // (multiple calls must return equal values from a dropped-
6144 // `&self` copy, since the returned Option carries no borrow),
6145 // and calling the accessor twice on the same LimitsSpec must
6146 // yield the same `Option<Duration>` verbatim (idempotent, no
6147 // side effects on `&self`).
6148 //
6149 // Pins against a future silent detour that returned
6150 // `Option<&Duration>` (which would type-check but silently
6151 // break every downstream caller — the future
6152 // `wasmtime::Store::epoch_deadline_*` wire path consumes
6153 // `Duration` by value and `&Duration` would fold to a detached
6154 // copy at the call site), an accidental `Option::as_ref()`
6155 // projection (`self.wall_clock.as_ref()` would also type-check
6156 // but return `Option<&Duration>`), or a one-arm-only accessor
6157 // that reads `Some(*w)` in the Some arm but reads a fresh
6158 // `Default::default()` (which would collapse to
6159 // `Duration::ZERO`, not `None`) in the None arm.
6160 for wall_clock in [
6161 None,
6162 Some(Duration::from_millis(1)),
6163 Some(Duration::from_secs(30)),
6164 Some(LIMITS_WALL_CLOCK_MAX),
6165 ] {
6166 let l = LimitsSpec {
6167 wall_clock,
6168 ..LimitsSpec::default()
6169 };
6170 let first = l.wall_clock();
6171 let second = l.wall_clock();
6172 assert_eq!(
6173 first, second,
6174 "LimitsSpec::wall_clock must be idempotent — two \
6175 successive calls on the same &self must return the \
6176 same Option<Duration>",
6177 );
6178 assert_eq!(
6179 first, wall_clock,
6180 "LimitsSpec::wall_clock must return :limits :wall-clock \
6181 verbatim by copy — got {first:?}, expected {wall_clock:?}",
6182 );
6183 }
6184 }
6185
6186 // ── per-`:limits :cpu` accessor pins (LimitsSpec::cpu) ───────────
6187
6188 #[test]
6189 fn limits_cpu_returns_option_u32_byte_equal_across_permutations() {
6190 // The canonical per-`:limits` `:cpu` Kubernetes-millicore
6191 // soft cgroup-share scalar pin: [`LimitsSpec::cpu`] must return
6192 // the `:limits :cpu` typed `u32` verbatim as an `Option<u32>`,
6193 // byte-equal to the raw field access across the three canonical
6194 // shape-arms — `None` (no cgroup share declared —
6195 // scheduler-default applies), `Some(1)` (the structural minimum
6196 // a validated `:limits :cpu` may carry, one millicore; a zero
6197 // cgroup share is separately rejected by
6198 // [`LimitsError::CpuZero`]), `Some(500)` (the canonical 500m
6199 // half-a-core share the in-tree
6200 // `limits_slot_propagates_into_values_block` smoke test carries
6201 // as the load-bearing example, peer to the `caixa-flux`
6202 // projector's identical 500m default).
6203 //
6204 // Peer of the sibling per-`:limits` [`LimitsSpec::memory`]
6205 // (620c067) / [`LimitsSpec::fuel`] (795dee7) /
6206 // [`LimitsSpec::wall_clock`] (8cb717b) accessor byte-equality
6207 // pins on the peer typed-`u64` / `u64` / `Duration`
6208 // optional-scalar axes, extended to the cgroup-cpu-share
6209 // `Option<u32>` shape — fourth and final `Option<Copy-T>`-return
6210 // accessor on the M2 slot family, closing the M2 `:limits`
6211 // `Option<Copy-T>` accessor axis. Sibling to
6212 // [`crate::MeshPolicy::retries`] (bdfb399) on the M3 mesh-slot
6213 // family's peer `Option<u32>` accessor axis — same typed-`u32`
6214 // shape extended from the M3 per-edge-transient-failure-retry-
6215 // budget axis to the M2 per-process-cgroup-cpu-share axis.
6216 // Pins against a future silent detour that re-derived the cpu
6217 // share from a peer axis (an accidental `.retries`-collapse that
6218 // assumed the two `Option<u32>` axes carry the same value — the
6219 // two axes share a shape but not a semantic, M2 `:cpu` counts
6220 // millicores of soft cgroup share and M3 `:retries` counts
6221 // per-edge transient-failure retry budget), a `None` → `Some(0)`
6222 // "zero means unbounded" collapse (the canonical `Option<u32>` →
6223 // `u32` collapse footgun the [`LimitsError::CpuZero`] validate
6224 // arm guards on the peer zero-floor axis; a zero cgroup share
6225 // starves the process rather than expressing "unbounded"), or a
6226 // per-arm variant swap that landed on one consumer without the
6227 // other.
6228 for cpu in [None, Some(1_u32), Some(500_u32)] {
6229 let l = LimitsSpec {
6230 cpu,
6231 ..LimitsSpec::default()
6232 };
6233 assert_eq!(
6234 l.cpu(),
6235 cpu,
6236 "LimitsSpec::cpu must return :limits :cpu verbatim \
6237 (got {:?}, expected {cpu:?})",
6238 l.cpu(),
6239 );
6240 assert_eq!(
6241 l.cpu(),
6242 l.cpu,
6243 "LimitsSpec::cpu must byte-equal the raw .cpu \
6244 field access across every value in the accept-set",
6245 );
6246 }
6247 }
6248
6249 #[test]
6250 fn limits_is_empty_cpu_arm_routes_through_accessor() {
6251 // Composition pin: [`LimitsSpec::is_empty`]'s `cpu` arm must key
6252 // off [`LimitsSpec::cpu`], not the raw `.cpu` field access.
6253 // Structurally: setting ONLY the `cpu` slot on an
6254 // otherwise-default LimitsSpec must flip `is_empty()` from
6255 // `true` (all-`None`) to `false` (one axis carries a value);
6256 // the flip must be observed across every value in the
6257 // accept-set since the emptiness semantic reads "any axis
6258 // carries a value" — not "any axis carries a value above a
6259 // threshold" — the same non-collapsing shape the sibling M3
6260 // [`crate::MeshPolicy::is_empty`] predicate carries on its
6261 // peer `Option<Copy-T>`-typed slot surfaces and the sibling
6262 // per-`:limits` [`LimitsSpec::memory`] (620c067) /
6263 // [`LimitsSpec::fuel`] (795dee7) / [`LimitsSpec::wall_clock`]
6264 // (8cb717b) `is_empty()` accessor-composition pins carry on the
6265 // peer `Option<u64>` / `Option<u64>` / `Option<Duration>` axes.
6266 //
6267 // Pins against a future silent detour that re-derived the
6268 // emptiness predicate off a peer axis (an accidental
6269 // `.memory.is_none()`-only chain that dropped the `cpu` arm
6270 // entirely), an accessor-side detour that no longer names the
6271 // substrate-primitive typed dispatch (an accidental
6272 // `self.cpu.unwrap_or(0) == 0` fallback in the accessor that
6273 // would silently classify both `None` and `Some(0)` as the same
6274 // value — a footgun the [`LimitsError::CpuZero`] validate arm
6275 // explicitly closes since a zero cgroup share starves the
6276 // process rather than expressing "unbounded"), or a threshold
6277 // collapse (a `self.cpu().is_some_and(|m| m > 0)` that would
6278 // silently classify `Some(0)` as unset).
6279 //
6280 // Peer of the sibling per-`:limits` [`LimitsSpec::memory`]
6281 // (620c067) / [`LimitsSpec::fuel`] (795dee7) /
6282 // [`LimitsSpec::wall_clock`] (8cb717b) `is_empty` composition
6283 // pins on the peer `Option<u64>` / `Option<u64>` /
6284 // `Option<Duration>` axes — same "the emptiness predicate must
6285 // route through the substrate-primitive typed dispatch"
6286 // discipline extended onto the peer per-`:limits` `:cpu` arm.
6287 // Closes the M2 `:limits` `is_empty`-composition family — every
6288 // arm now routes through its typed accessor, no open-coded
6289 // field access remains.
6290 let empty = LimitsSpec::default();
6291 assert!(
6292 empty.is_empty(),
6293 "LimitsSpec::default() must be is_empty() — every axis \
6294 defaults to None",
6295 );
6296 for cpu in [Some(1_u32), Some(500_u32), Some(LIMITS_CPU_MILLICORES_MAX)] {
6297 let l = LimitsSpec {
6298 cpu,
6299 ..LimitsSpec::default()
6300 };
6301 assert!(
6302 !l.is_empty(),
6303 "LimitsSpec::is_empty must return false when :cpu \
6304 is {cpu:?} — the emptiness predicate reads \"any \
6305 axis carries a value\", not \"any axis carries a \
6306 value above a threshold\"",
6307 );
6308 assert_eq!(
6309 l.cpu().is_none(),
6310 l.is_empty(),
6311 "when :cpu is the only set axis, is_empty() must \
6312 equal cpu().is_none() — the accessor and the \
6313 emptiness predicate must route through the same \
6314 substrate-primitive typed dispatch on the :cpu \
6315 arm",
6316 );
6317 }
6318 }
6319
6320 #[test]
6321 fn limits_cpu_projects_option_u32_by_copy() {
6322 // The by-copy pin: [`LimitsSpec::cpu`] returns `Option<u32>` by
6323 // copy — `Option<u32>` is `Copy` and the accessor must return
6324 // by value, not by reference. Peer of the sibling per-`:limits`
6325 // [`LimitsSpec::memory`] (620c067) / [`LimitsSpec::fuel`]
6326 // (795dee7) / [`LimitsSpec::wall_clock`] (8cb717b)
6327 // copy-invariant pins on the peer `Option<u64>` / `Option<u64>`
6328 // / `Option<Duration>` shapes, extended onto the peer
6329 // `Option<u32>` copy-invariant shape — the accessor's returned
6330 // `Option<u32>` must outlive `&self` (multiple calls must
6331 // return equal values from a dropped-`&self` copy, since the
6332 // returned Option carries no borrow), and calling the accessor
6333 // twice on the same LimitsSpec must yield the same
6334 // `Option<u32>` verbatim (idempotent, no side effects on
6335 // `&self`).
6336 //
6337 // Pins against a future silent detour that returned
6338 // `Option<&u32>` (which would type-check but silently break
6339 // every downstream caller — the future K8s pod-spec
6340 // `resources.requests.cpu` wire path consumes `u32` by value
6341 // and `&u32` would fold to a detached copy at the call site),
6342 // an accidental `Option::as_ref()` projection
6343 // (`self.cpu.as_ref()` would also type-check but return
6344 // `Option<&u32>`), or a one-arm-only accessor that reads
6345 // `Some(*m)` in the Some arm but reads a fresh
6346 // `Default::default()` in the None arm.
6347 for cpu in [
6348 None,
6349 Some(1_u32),
6350 Some(500_u32),
6351 Some(LIMITS_CPU_MILLICORES_MAX),
6352 ] {
6353 let l = LimitsSpec {
6354 cpu,
6355 ..LimitsSpec::default()
6356 };
6357 let first = l.cpu();
6358 let second = l.cpu();
6359 assert_eq!(
6360 first, second,
6361 "LimitsSpec::cpu must be idempotent — two \
6362 successive calls on the same &self must return the \
6363 same Option<u32>",
6364 );
6365 assert_eq!(
6366 first, cpu,
6367 "LimitsSpec::cpu must return :limits :cpu \
6368 verbatim by copy — got {first:?}, expected {cpu:?}",
6369 );
6370 }
6371 }
6372}