Skip to main content

tollgate_core/
snapshot.rs

1//! Compiled account snapshots: the immutable, admission-ready form of an
2//! account's policy.
3//!
4//! A snapshot is what a control plane compiles *from* its source of truth
5//! (database rows, policy records, plan bindings) and pushes *to* service
6//! instances. By the time it reaches this type there is nothing left to
7//! resolve: bitsets, integers, and a compiled cost table. Strings, JSON, and
8//! joins belong to the control plane.
9
10use std::num::NonZeroU32;
11use std::sync::Arc;
12
13use jiff::Timestamp;
14
15use crate::budget::BudgetView;
16use crate::cost_table::CostTable;
17use crate::deny::DenyReason;
18use crate::ids::{AccountId, Generation, KeyId, PolicyRevision};
19use crate::units::CostUnits;
20
21/// Administrative state of the account at compile time.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum AccountStatus {
25    Active,
26    /// Temporarily disabled; denies but may return.
27    Suspended,
28    /// Terminally disabled. One-way: an account enters `Closed` from any
29    /// status and leaves it never (INVARIANTS.md GL-22).
30    Closed,
31}
32
33impl AccountStatus {
34    /// The one spelling of each status: serde's, the ledger column's, and the
35    /// operator-facing one.
36    ///
37    /// Three places compare these strings — the `tollgate_accounts.status`
38    /// `CHECK` constraint, the JSONB predicate that decides which snapshots a
39    /// status change rewrites, and the admin wire DTO. Spelling them
40    /// separately is how they drift, so they all read from here, and
41    /// `account_status_text_matches_its_serde_spelling` pins this against
42    /// serde. Exhaustive by construction: a new variant fails to compile
43    /// until it has a spelling.
44    #[must_use]
45    pub const fn as_str(self) -> &'static str {
46        match self {
47            AccountStatus::Active => "Active",
48            AccountStatus::Suspended => "Suspended",
49            AccountStatus::Closed => "Closed",
50        }
51    }
52}
53
54/// Which execution-capacity class an account's work belongs to (GL-99).
55///
56/// A separate axis from [`EnforcementMode`], and the two must not be
57/// conflated. Enforcement mode answers whether an account can *fund* a
58/// request; this answers whether an instance should *start* an already-valid
59/// request with the compute capacity it has right now. An assured account may
60/// be strict, and a best-effort account still spends quota and emits ordinary
61/// usage whenever it does execute.
62///
63/// The vocabulary is deliberately semantic rather than commercial. Tollgate
64/// has no `Paid`/`Free` and no open-ended priority integer: the product maps
65/// its plans onto these two, and which customers are assured is a decision
66/// that stays outside an enforcement substrate.
67///
68/// # Assured is the default, and that is a compatibility choice
69///
70/// An account that states no class gets the availability every account has
71/// today. The unsafe direction would be defaulting to `BestEffort`, which
72/// would silently make existing traffic sheddable the moment a capacity gate
73/// was enabled — the same reasoning that makes [`AccountStatus`] required at
74/// construction and [`EnforcementMode`] default to `Strict`.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub enum CapacityClass {
78    /// Work that may use the whole instance, including the reserve kept for
79    /// it. The default.
80    #[default]
81    Assured,
82    /// Work that may use only capacity not reserved for assured traffic, and
83    /// is shed first under load.
84    BestEffort,
85}
86
87impl CapacityClass {
88    /// The one spelling of each class: serde's, the ledger column's, and the
89    /// operator-facing one.
90    ///
91    /// The same three-consumer argument [`AccountStatus::as_str`] makes — the
92    /// `tollgate_accounts.capacity_class` `CHECK` constraint, the JSONB
93    /// predicate deciding which snapshots a class change rewrites, and the
94    /// admin wire DTO all compare these strings, so they all read from here.
95    /// It is also the metric label: capacity counters distinguish the classes
96    /// by these two tags and nothing else, which is what keeps their
97    /// cardinality bounded (GL-99).
98    #[must_use]
99    pub const fn as_str(self) -> &'static str {
100        match self {
101            CapacityClass::Assured => "Assured",
102            CapacityClass::BestEffort => "BestEffort",
103        }
104    }
105
106    /// Whether work of this class may draw on the assured reserve.
107    ///
108    /// Stated once, here, rather than re-derived as `== Assured` at each pool
109    /// decision: the reserve's whole purpose is that exactly one class reaches
110    /// it, and a second spelling of that rule is how the two drift.
111    #[must_use]
112    pub const fn may_use_assured_reserve(self) -> bool {
113        matches!(self, CapacityClass::Assured)
114    }
115}
116
117/// What an account does when its local lease cannot fund a quote.
118///
119/// This is the one axis of the fail-closed rule that is per-account rather than
120/// absolute. Unknown principals, stale snapshots, cost overflow and accounting
121/// backpressure deny under every mode; only *lease cannot fund this request*
122/// is negotiable, because only that condition says something about funding
123/// rather than about validity (INVARIANTS.md GL-1, GL-5).
124///
125/// **The cap is per service instance.** The counter it bounds is a local
126/// atomic, like every other local mechanism here, so a fleet of `N` instances
127/// can extend up to `N * overage_cap` units of credit for one account before
128/// any of them refuses. That multiplication is a property of the design, not
129/// an oversight: aggregating it would require the synchronous coordination the
130/// request path exists to avoid. Size the cap against the fleet, not against
131/// one process.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub enum EnforcementMode {
135    /// Deny with zero charge when the lease cannot fund the quote. The
136    /// behaviour every account had before elastic mode existed, and the
137    /// default a snapshot decodes to when it carries no mode at all.
138    #[default]
139    Strict,
140    /// Admit past the lease and record the spend as overage, up to
141    /// `overage_cap` units **per service instance**.
142    ///
143    /// Overage is unfunded spend: units consumed that no deposit paid for and
144    /// no lease debited. It is billed like any other usage — the resulting
145    /// event carries no lease capability, and the ledger records it as a
146    /// second funding term so per-account conservation still closes exactly.
147    Elastic { overage_cap: CostUnits },
148}
149
150impl EnforcementMode {
151    /// The one spelling of each mode's *tag*: serde's, the ledger column's,
152    /// and the operator-facing one.
153    ///
154    /// Deliberately the tag alone, never the cap. This string labels metrics
155    /// and log lines, and interpolating a per-account number into a label is
156    /// how a bounded label set becomes an unbounded one — the same rule
157    /// `payload_does_not_affect_the_slot` pins for [`DenyReason`]. Exhaustive
158    /// by construction: a new variant fails to compile until it has a
159    /// spelling, and `enforcement_mode_text_matches_its_serde_tag` pins these
160    /// against serde.
161    #[must_use]
162    pub const fn as_str(self) -> &'static str {
163        match self {
164            EnforcementMode::Strict => "Strict",
165            EnforcementMode::Elastic { .. } => "Elastic",
166        }
167    }
168
169    /// The overage allowance, or `None` under [`EnforcementMode::Strict`].
170    #[must_use]
171    pub const fn overage_cap(self) -> Option<CostUnits> {
172        match self {
173            EnforcementMode::Strict => None,
174            EnforcementMode::Elastic { overage_cap } => Some(overage_cap),
175        }
176    }
177}
178
179/// Up to 64 permission slots, compiled from whatever entitlement vocabulary
180/// the consumer uses. Admission is a superset test — one AND and one compare.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183#[cfg_attr(feature = "serde", serde(transparent))]
184pub struct PermissionBits(pub u64);
185
186impl PermissionBits {
187    pub const NONE: PermissionBits = PermissionBits(0);
188    pub const ALL: PermissionBits = PermissionBits(u64::MAX);
189
190    /// The single permission at `bit` (0..64).
191    #[inline]
192    #[must_use]
193    pub const fn bit(bit: u32) -> PermissionBits {
194        PermissionBits(1u64 << bit)
195    }
196
197    #[inline]
198    #[must_use]
199    pub const fn union(self, other: PermissionBits) -> PermissionBits {
200        PermissionBits(self.0 | other.0)
201    }
202
203    /// True when every bit in `required` is granted here.
204    #[inline]
205    #[must_use]
206    pub const fn contains_all(self, required: PermissionBits) -> bool {
207        self.0 & required.0 == required.0
208    }
209}
210
211/// The cost-weighted token bucket carried by a snapshot.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct WeightedRateLimit {
214    units_per_second: u64,
215    burst_units: u64,
216}
217
218impl WeightedRateLimit {
219    #[must_use]
220    pub const fn units_per_second(self) -> u64 {
221        self.units_per_second
222    }
223
224    #[must_use]
225    pub const fn burst_units(self) -> u64 {
226        self.burst_units
227    }
228}
229
230/// The request-count token bucket carried by a snapshot.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub struct RequestRateLimit {
233    requests_per_second: NonZeroU32,
234    burst_requests: NonZeroU32,
235}
236
237impl RequestRateLimit {
238    #[must_use]
239    pub const fn requests_per_second(self) -> NonZeroU32 {
240        self.requests_per_second
241    }
242
243    #[must_use]
244    pub const fn burst_requests(self) -> NonZeroU32 {
245        self.burst_requests
246    }
247}
248
249/// The account-wide rate dimensions carried inside a principal snapshot.
250///
251/// Admission compiles these values into shared mutable governor buckets. A
252/// separate value type lets that compiler bind the exact policy represented
253/// by a bucket back into every principal state that pins it, while leaving
254/// principal-local shaping and concurrency dimensions untouched.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub struct AccountRatePolicy {
257    weighted_rate: Option<WeightedRateLimit>,
258    legacy_weighted_rate: WeightedRateLimit,
259    request_rate: Option<RequestRateLimit>,
260}
261
262impl AccountRatePolicy {
263    #[must_use]
264    pub const fn weighted_rate(self) -> Option<WeightedRateLimit> {
265        self.weighted_rate
266    }
267
268    #[must_use]
269    pub const fn legacy_weighted_rate(self) -> WeightedRateLimit {
270        self.legacy_weighted_rate
271    }
272
273    #[must_use]
274    pub const fn request_rate(self) -> Option<RequestRateLimit> {
275        self.request_rate
276    }
277}
278
279/// Why resolved limits could not be constructed from a wire representation.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum ResolvedLimitsError {
282    RequestRatePairIncomplete,
283    PrincipalConcurrencyWithoutAccount,
284    PrincipalConcurrencyExceedsAccount {
285        principal: NonZeroU32,
286        account: NonZeroU32,
287    },
288}
289
290impl std::fmt::Display for ResolvedLimitsError {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self {
293            ResolvedLimitsError::RequestRatePairIncomplete => {
294                f.write_str("request_rate_per_second and request_burst must be supplied together")
295            }
296            ResolvedLimitsError::PrincipalConcurrencyWithoutAccount => {
297                f.write_str("principal_max_concurrent_requests requires max_concurrent_requests")
298            }
299            ResolvedLimitsError::PrincipalConcurrencyExceedsAccount { principal, account } => {
300                write!(
301                    f,
302                    "principal concurrency ceiling {principal} exceeds account ceiling {account}"
303                )
304            }
305        }
306    }
307}
308
309impl std::error::Error for ResolvedLimitsError {}
310
311/// Resolved integer limits. These parameterize the admission layer's local
312/// rate limiting and request shaping; the snapshot only carries them.
313///
314/// Fields are private and construction is incremental so adding another
315/// optional policy dimension does not break every consumer's struct literal.
316/// The two concurrency limits can only be installed together in a valid
317/// narrowing relationship.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
320#[cfg_attr(
321    feature = "serde",
322    serde(try_from = "WireResolvedLimits", into = "WireResolvedLimits")
323)]
324#[non_exhaustive]
325pub struct ResolvedLimits {
326    max_items_per_request: u64,
327    weighted_rate: Option<WeightedRateLimit>,
328    legacy_weighted_rate: WeightedRateLimit,
329    request_rate: Option<RequestRateLimit>,
330    max_concurrent_requests: Option<NonZeroU32>,
331    principal_max_concurrent_requests: Option<NonZeroU32>,
332}
333
334impl ResolvedLimits {
335    const COMPATIBILITY_FALLBACK: WeightedRateLimit = WeightedRateLimit {
336        units_per_second: u32::MAX as u64,
337        burst_units: u32::MAX as u64,
338    };
339
340    /// Start with only request shaping enabled. The carried legacy weighted
341    /// pair is deliberately finite and valid for governor so an older reader
342    /// remains safe during a reader-first rollout.
343    #[must_use]
344    pub const fn new(max_items_per_request: u64) -> Self {
345        Self {
346            max_items_per_request,
347            weighted_rate: None,
348            legacy_weighted_rate: Self::COMPATIBILITY_FALLBACK,
349            request_rate: None,
350            max_concurrent_requests: None,
351            principal_max_concurrent_requests: None,
352        }
353    }
354
355    #[must_use]
356    pub const fn with_weighted_rate(mut self, units_per_second: u64, burst_units: u64) -> Self {
357        let rate = WeightedRateLimit {
358            units_per_second,
359            burst_units,
360        };
361        self.weighted_rate = Some(rate);
362        self.legacy_weighted_rate = rate;
363        self
364    }
365
366    /// Select the values an older reader will enforce while the weighted
367    /// bucket is disabled for readers that understand the new wire flag.
368    #[must_use]
369    pub const fn with_weighted_rate_compatibility_fallback(
370        mut self,
371        units_per_second: u64,
372        burst_units: u64,
373    ) -> Self {
374        self.weighted_rate = None;
375        self.legacy_weighted_rate = WeightedRateLimit {
376            units_per_second,
377            burst_units,
378        };
379        self
380    }
381
382    #[must_use]
383    pub const fn with_request_rate(
384        mut self,
385        requests_per_second: NonZeroU32,
386        burst_requests: NonZeroU32,
387    ) -> Self {
388        self.request_rate = Some(RequestRateLimit {
389            requests_per_second,
390            burst_requests,
391        });
392        self
393    }
394
395    pub fn with_concurrency(
396        mut self,
397        max_concurrent_requests: NonZeroU32,
398        principal_max_concurrent_requests: Option<NonZeroU32>,
399    ) -> Result<Self, ResolvedLimitsError> {
400        if let Some(principal) = principal_max_concurrent_requests
401            && principal > max_concurrent_requests
402        {
403            return Err(ResolvedLimitsError::PrincipalConcurrencyExceedsAccount {
404                principal,
405                account: max_concurrent_requests,
406            });
407        }
408        self.max_concurrent_requests = Some(max_concurrent_requests);
409        self.principal_max_concurrent_requests = principal_max_concurrent_requests;
410        Ok(self)
411    }
412
413    #[must_use]
414    pub const fn max_items_per_request(self) -> u64 {
415        self.max_items_per_request
416    }
417
418    #[must_use]
419    pub const fn weighted_rate(self) -> Option<WeightedRateLimit> {
420        self.weighted_rate
421    }
422
423    /// The pair understood by pre-#91 readers. During the reader-first phase
424    /// they continue enforcing it even when the new flag disables the bucket.
425    #[must_use]
426    pub const fn legacy_weighted_rate(self) -> WeightedRateLimit {
427        self.legacy_weighted_rate
428    }
429
430    #[must_use]
431    pub const fn request_rate(self) -> Option<RequestRateLimit> {
432        self.request_rate
433    }
434
435    /// Extract the account-wide rate policy independently of request shaping
436    /// and concurrency limits.
437    #[must_use]
438    pub const fn account_rate_policy(self) -> AccountRatePolicy {
439        AccountRatePolicy {
440            weighted_rate: self.weighted_rate,
441            legacy_weighted_rate: self.legacy_weighted_rate,
442            request_rate: self.request_rate,
443        }
444    }
445
446    #[must_use]
447    pub const fn max_concurrent_requests(self) -> Option<NonZeroU32> {
448        self.max_concurrent_requests
449    }
450
451    #[must_use]
452    pub const fn principal_max_concurrent_requests(self) -> Option<NonZeroU32> {
453        self.principal_max_concurrent_requests
454    }
455}
456
457#[cfg(feature = "serde")]
458#[derive(serde::Serialize, serde::Deserialize)]
459struct WireResolvedLimits {
460    max_items_per_request: u64,
461    rate_units_per_second: u64,
462    rate_burst_units: u64,
463    #[serde(default = "default_true", skip_serializing_if = "is_true")]
464    weighted_rate_enabled: bool,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    request_rate_per_second: Option<NonZeroU32>,
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    request_burst: Option<NonZeroU32>,
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    max_concurrent_requests: Option<NonZeroU32>,
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    principal_max_concurrent_requests: Option<NonZeroU32>,
473}
474
475#[cfg(feature = "serde")]
476const fn default_true() -> bool {
477    true
478}
479
480#[cfg(feature = "serde")]
481const fn is_true(value: &bool) -> bool {
482    *value
483}
484
485#[cfg(feature = "serde")]
486impl TryFrom<WireResolvedLimits> for ResolvedLimits {
487    type Error = ResolvedLimitsError;
488
489    fn try_from(wire: WireResolvedLimits) -> Result<Self, Self::Error> {
490        let legacy_weighted_rate = WeightedRateLimit {
491            units_per_second: wire.rate_units_per_second,
492            burst_units: wire.rate_burst_units,
493        };
494        let mut limits = Self {
495            max_items_per_request: wire.max_items_per_request,
496            weighted_rate: wire.weighted_rate_enabled.then_some(legacy_weighted_rate),
497            legacy_weighted_rate,
498            request_rate: None,
499            max_concurrent_requests: None,
500            principal_max_concurrent_requests: None,
501        };
502        limits = match (wire.request_rate_per_second, wire.request_burst) {
503            (None, None) => limits,
504            (Some(requests_per_second), Some(burst_requests)) => {
505                limits.with_request_rate(requests_per_second, burst_requests)
506            }
507            _ => return Err(ResolvedLimitsError::RequestRatePairIncomplete),
508        };
509        match wire.max_concurrent_requests {
510            Some(account) => {
511                limits.with_concurrency(account, wire.principal_max_concurrent_requests)
512            }
513            None if wire.principal_max_concurrent_requests.is_some() => {
514                Err(ResolvedLimitsError::PrincipalConcurrencyWithoutAccount)
515            }
516            None => Ok(limits),
517        }
518    }
519}
520
521#[cfg(feature = "serde")]
522impl From<ResolvedLimits> for WireResolvedLimits {
523    fn from(limits: ResolvedLimits) -> Self {
524        Self {
525            max_items_per_request: limits.max_items_per_request,
526            rate_units_per_second: limits.legacy_weighted_rate.units_per_second,
527            rate_burst_units: limits.legacy_weighted_rate.burst_units,
528            weighted_rate_enabled: limits.weighted_rate.is_some(),
529            request_rate_per_second: limits.request_rate.map(|rate| rate.requests_per_second),
530            request_burst: limits.request_rate.map(|rate| rate.burst_requests),
531            max_concurrent_requests: limits.max_concurrent_requests,
532            principal_max_concurrent_requests: limits.principal_max_concurrent_requests,
533        }
534    }
535}
536
537/// One account credential's compiled, immutable admission state.
538///
539/// Shared as `Arc<AccountSnapshot>`; replaced whole (never mutated) when the
540/// control plane publishes a newer [`Generation`].
541/// # Layout
542///
543/// `#[repr(C, align(128))]`, and both halves are load-bearing. The alignment
544/// keeps one snapshot off its neighbours' cache lines. `repr(C)` is what makes
545/// the declaration order below a *contract* rather than whatever this build
546/// chose: without it the compiler reorders freely, and an offset assertion
547/// would pin an accident. The fields the request path reads to admit — status,
548/// permissions, validity, enforcement mode — are declared first so they share
549/// the first cache line, and the cold ones follow. Pinned by
550/// `stage_one_fields_share_the_first_cache_line`.
551#[derive(Debug, Clone)]
552#[repr(C, align(128))]
553#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
554#[non_exhaustive]
555pub struct AccountSnapshot {
556    pub status: AccountStatus,
557    /// Which execution-capacity class this account's work belongs to (GL-99).
558    ///
559    /// Declared beside `status` because the capacity gate reads it on the same
560    /// cache line admission already touches, and because it costs nothing to
561    /// do so: `AccountStatus` is a one-byte enum and `permissions` is
562    /// `u64`-aligned, so a second one-byte enum lands in padding that already
563    /// existed.
564    ///
565    /// Defaults on the wire as well as in storage, the precedent
566    /// `enforcement_mode` set. An absent class decodes to
567    /// [`CapacityClass::Assured`] — the availability every account has today,
568    /// and the only safe direction: defaulting to `BestEffort` would make
569    /// existing traffic sheddable the moment a gate was enabled.
570    #[cfg_attr(feature = "serde", serde(default))]
571    pub capacity_class: CapacityClass,
572    pub permissions: PermissionBits,
573    /// Hard staleness bound: past this instant the snapshot denies
574    /// (INVARIANTS.md GL-5) until the control plane delivers a successor.
575    pub valid_until: Timestamp,
576    /// What this account does when its lease cannot fund a quote. Declared
577    /// among the stage-one fields because funding reads it on the same cache
578    /// line the request path already touches for `status`.
579    ///
580    /// Defaults on the wire as well as in storage, so a control plane that
581    /// predates elastic mode keeps publishing successfully instead of getting
582    /// a 422 for a field it has never heard of. The default is
583    /// [`EnforcementMode::Strict`]: an unstated mode enforces, it does not
584    /// extend credit.
585    #[cfg_attr(feature = "serde", serde(default))]
586    pub enforcement_mode: EnforcementMode,
587    /// Monotonic snapshot version; see [`Generation`].
588    pub generation: Generation,
589    pub cost_table: Arc<CostTable>,
590    pub account_id: AccountId,
591    /// The credential this snapshot was compiled for, when key-scoped.
592    pub key_id: Option<KeyId>,
593    pub limits: ResolvedLimits,
594    /// What the control plane last said about the account's budget (GL-97), or
595    /// `None` when it said nothing — an older control plane, or a publication
596    /// that did not go through a store.
597    ///
598    /// `None` is deliberately not "zero remaining": an instance that reported
599    /// a fabricated zero because its publisher predated the field would deny
600    /// nothing but would tell every caller they were out of quota. Readers
601    /// return `None` rather than a number they cannot stand behind.
602    ///
603    /// Defaults on the wire as well as in storage, the precedent
604    /// `enforcement_mode` set, so a control plane that predates this keeps
605    /// publishing successfully instead of getting a 422 for a field it has
606    /// never heard of.
607    ///
608    /// There is no builder setter: the store stamps this at publication and is
609    /// its only writer. See [`PublishableSnapshot::with_budget`].
610    ///
611    /// Free, spatially: it lands in the tail padding `#[repr(align(128))]`
612    /// already reserved, so `AccountSnapshot` remains 256 bytes and the
613    /// request path touches no line it did not already touch.
614    #[cfg_attr(feature = "serde", serde(default))]
615    pub budget: Option<BudgetView>,
616    /// The consuming application's identity for the product policy compiled
617    /// into this snapshot (GL-94). See [`PolicyRevision`].
618    ///
619    /// Tollgate carries it and never reads it, so it is declared last, well
620    /// clear of the cache line admission touches. Distinct from `generation`:
621    /// that orders publications, this names the inputs one was compiled from.
622    ///
623    /// Defaults on the wire as well as in storage, the precedent
624    /// `enforcement_mode` and `budget` set. An absent revision decodes to
625    /// [`PolicyRevision::UNSTATED`] rather than failing, which is safe here in
626    /// a way it would not be for an enforcement field: nothing in Tollgate
627    /// reads this, so an unstated revision cannot change an outcome.
628    #[cfg_attr(feature = "serde", serde(default))]
629    pub policy_revision: PolicyRevision,
630}
631
632/// Builder for the optional parts of an immutable account snapshot.
633#[derive(Debug)]
634pub struct AccountSnapshotBuilder {
635    account_id: AccountId,
636    key_id: Option<KeyId>,
637    generation: Generation,
638    status: AccountStatus,
639    capacity_class: CapacityClass,
640    enforcement_mode: EnforcementMode,
641    valid_until: Timestamp,
642    permissions: PermissionBits,
643    limits: ResolvedLimits,
644    cost_table: Arc<CostTable>,
645    policy_revision: PolicyRevision,
646}
647
648impl AccountSnapshotBuilder {
649    #[must_use]
650    pub const fn key_id(mut self, key_id: KeyId) -> Self {
651        self.key_id = Some(key_id);
652        self
653    }
654
655    #[must_use]
656    pub const fn enforcement_mode(mut self, enforcement_mode: EnforcementMode) -> Self {
657        self.enforcement_mode = enforcement_mode;
658        self
659    }
660
661    /// Set the account's execution-capacity class (GL-99).
662    ///
663    /// Optional, and omitting it leaves [`CapacityClass::Assured`]. The class
664    /// is an account-owned fact: the control plane derives it from the ledger
665    /// rather than each publisher choosing one, and a snapshot whose class
666    /// contradicts its account is refused at publication.
667    #[must_use]
668    pub const fn capacity_class(mut self, capacity_class: CapacityClass) -> Self {
669        self.capacity_class = capacity_class;
670        self
671    }
672
673    /// Name the product policy this snapshot was compiled from (GL-94).
674    ///
675    /// Optional, and omitting it leaves [`PolicyRevision::UNSTATED`] — a
676    /// publisher that has no revision to state says nothing rather than
677    /// inventing one. Unlike `budget`, this has a setter: the control plane
678    /// compiling the snapshot is the value's author, not the store.
679    #[must_use]
680    pub const fn policy_revision(mut self, policy_revision: PolicyRevision) -> Self {
681        self.policy_revision = policy_revision;
682        self
683    }
684
685    #[must_use]
686    pub fn build(self) -> AccountSnapshot {
687        AccountSnapshot {
688            account_id: self.account_id,
689            key_id: self.key_id,
690            generation: self.generation,
691            status: self.status,
692            capacity_class: self.capacity_class,
693            enforcement_mode: self.enforcement_mode,
694            valid_until: self.valid_until,
695            permissions: self.permissions,
696            limits: self.limits,
697            cost_table: self.cost_table,
698            // Absent by construction. A publisher describes policy; the
699            // account's balance has one authority and it is the store.
700            budget: None,
701            policy_revision: self.policy_revision,
702        }
703    }
704}
705
706/// Why a compiled snapshot cannot be published.
707///
708/// The validation domain is the full `u64` configuration space. Arithmetic
709/// is checked in the same `CostTable` implementation the request path uses;
710/// overflow is a refusal, never a wrapped low quote (INVARIANTS GL-11/#16).
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum SnapshotValidationError {
713    /// The carried compatibility pair cannot be represented by governor.
714    /// It is validated even when disabled because an older reader will still
715    /// enforce these values during a mixed-version rollout.
716    WeightedRateOutsideGovernorDomain {
717        units_per_second: u64,
718        burst_units: u64,
719    },
720    /// The most expensive registered operation overflows at the batch cap.
721    QuoteOverflow {
722        operation_index: usize,
723        max_items: u64,
724    },
725    /// A request admitted by the batch cap can never fit in the whole burst.
726    QuoteExceedsBurst {
727        operation_index: usize,
728        max_quote: CostUnits,
729        burst_units: CostUnits,
730    },
731    /// An elastic account's overage cap cannot fund even one worst-case
732    /// request, so the mode would admit nothing it was configured to admit.
733    ///
734    /// Refused rather than silently tolerated, for the same reason
735    /// [`QuoteExceedsBurst`](Self::QuoteExceedsBurst) is: a configuration
736    /// value is an operator contract, and a cap that reads as "extend credit"
737    /// while behaving as `Strict` is the silent-semantics failure the
738    /// repository guidelines forbid.
739    OverageCapBelowMaxQuote {
740        operation_index: usize,
741        max_quote: CostUnits,
742        overage_cap: CostUnits,
743    },
744}
745
746impl std::fmt::Display for SnapshotValidationError {
747    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748        match self {
749            SnapshotValidationError::WeightedRateOutsideGovernorDomain {
750                units_per_second,
751                burst_units,
752            } => write!(
753                f,
754                "weighted rate ({units_per_second}/s, burst {burst_units}) must fit governor's non-zero u32 domain"
755            ),
756            SnapshotValidationError::QuoteOverflow {
757                operation_index,
758                max_items,
759            } => write!(
760                f,
761                "operation {operation_index} cost overflows at the batch cap of {max_items} items"
762            ),
763            SnapshotValidationError::QuoteExceedsBurst {
764                operation_index,
765                max_quote,
766                burst_units,
767            } => write!(
768                f,
769                "operation {operation_index} can quote {max_quote} units, exceeding the burst of {burst_units} units"
770            ),
771            SnapshotValidationError::OverageCapBelowMaxQuote {
772                operation_index,
773                max_quote,
774                overage_cap,
775            } => write!(
776                f,
777                "operation {operation_index} can quote {max_quote} units, exceeding the overage cap of {overage_cap} units"
778            ),
779        }
780    }
781}
782
783impl std::error::Error for SnapshotValidationError {}
784
785/// Evidence that an account snapshot satisfies publication-time invariants.
786///
787/// The raw [`AccountSnapshot`] intentionally remains constructible: the
788/// request path keeps `UnpriceableUnderLimits` as a defensive runtime
789/// backstop. Store publication and source boundaries exchange this proof so
790/// an invalid snapshot cannot reach them by caller convention alone.
791#[derive(Debug, Clone)]
792pub struct PublishableSnapshot {
793    snapshot: Arc<AccountSnapshot>,
794    maximum_quote: Option<CostUnits>,
795}
796
797impl PublishableSnapshot {
798    pub fn try_new(snapshot: Arc<AccountSnapshot>) -> Result<Self, SnapshotValidationError> {
799        let legacy_rate = snapshot.limits.legacy_weighted_rate();
800        if legacy_rate.units_per_second() == 0
801            || legacy_rate.units_per_second() > u64::from(u32::MAX)
802            || legacy_rate.burst_units() == 0
803            || legacy_rate.burst_units() > u64::from(u32::MAX)
804        {
805            return Err(SnapshotValidationError::WeightedRateOutsideGovernorDomain {
806                units_per_second: legacy_rate.units_per_second(),
807                burst_units: legacy_rate.burst_units(),
808            });
809        }
810        let Some((operation_index, maximum_weight)) = snapshot.cost_table.maximum_weight() else {
811            // With no registered operation the table cannot produce a quote,
812            // so no request can witness a quote/burst inconsistency.
813            return Ok(PublishableSnapshot {
814                snapshot,
815                maximum_quote: None,
816            });
817        };
818        let max_quote = snapshot
819            .cost_table
820            .quote_weight(maximum_weight, snapshot.limits.max_items_per_request())
821            .map_err(|_| SnapshotValidationError::QuoteOverflow {
822                operation_index,
823                max_items: snapshot.limits.max_items_per_request(),
824            })?
825            .total;
826        let burst_units = CostUnits(legacy_rate.burst_units());
827        if max_quote > burst_units {
828            return Err(SnapshotValidationError::QuoteExceedsBurst {
829                operation_index,
830                max_quote,
831                burst_units,
832            });
833        }
834        if let Some(overage_cap) = snapshot.enforcement_mode.overage_cap()
835            && max_quote > overage_cap
836        {
837            return Err(SnapshotValidationError::OverageCapBelowMaxQuote {
838                operation_index,
839                max_quote,
840                overage_cap,
841            });
842        }
843        Ok(PublishableSnapshot {
844            snapshot,
845            maximum_quote: Some(max_quote),
846        })
847    }
848
849    #[must_use]
850    pub fn as_snapshot(&self) -> &AccountSnapshot {
851        &self.snapshot
852    }
853
854    /// The largest quote publication proved the configured request shape can
855    /// produce. Control-plane consumers use this evidence directly instead
856    /// of repeating the cost-table scan at the next trust boundary.
857    #[must_use]
858    pub fn maximum_quote(&self) -> Option<CostUnits> {
859        self.maximum_quote
860    }
861
862    #[must_use]
863    pub fn into_inner(self) -> Arc<AccountSnapshot> {
864        self.snapshot
865    }
866
867    /// Re-stamp status and generation, carrying the publication proof over.
868    ///
869    /// Sound without revalidating, and the reason is worth keeping next to the
870    /// code rather than at the call site: [`try_new`](Self::try_new) checks
871    /// [`try_new`](Self::try_new) checks the carried weighted-rate pair's
872    /// domain, then checks `cost_table`'s worst quote at the batch cap against
873    /// the carried compatibility burst and an elastic account's
874    /// `overage_cap`. Neither `status` nor `generation` participates in those
875    /// checks, so a snapshot that was publishable stays publishable under any
876    /// value of either.
877    ///
878    /// Note what this method therefore must not grow: re-stamping the
879    /// enforcement mode would change a value validation *does* depend on, and
880    /// would need a fallible signature. An account-wide mode change goes
881    /// through publication, not through here.
882    ///
883    /// This is what an account-wide status change needs (GL-22): the ledger and
884    /// every live snapshot move together, and re-deriving a proof that cannot
885    /// have changed would only invite an `expect` at each call site.
886    /// Attach the ledger's budget view, carrying the publication proof over.
887    ///
888    /// Sound without revalidating for the reason [`restamped`](Self::restamped)
889    /// gives: [`try_new`](Self::try_new) checks the weighted-rate pair's
890    /// domain and the cost table's worst quote against the burst and any
891    /// overage cap. The budget view participates in none of them, so a
892    /// snapshot that was publishable stays publishable under any value of it.
893    ///
894    /// This exists so that the store, and only the store, can write the field:
895    /// [`AccountSnapshot`]'s builder has no setter for it, so no caller
896    /// outside this crate can put a balance into a snapshot except by
897    /// deserializing one — which is exactly why the argument is an `Option`
898    /// and a publish calls this *unconditionally*. An account the ledger does
899    /// not hold clears the field rather than leaving it, so a submitted value
900    /// can never survive publication. One fact, one writer — the rule GL-51
901    /// established for status, applied to a number that moves far faster.
902    #[must_use]
903    pub fn with_budget(&self, budget: Option<BudgetView>) -> Self {
904        let mut snapshot = AccountSnapshot::clone(&self.snapshot);
905        snapshot.budget = budget;
906        PublishableSnapshot {
907            snapshot: Arc::new(snapshot),
908            maximum_quote: self.maximum_quote,
909        }
910    }
911
912    #[must_use]
913    pub fn restamped(&self, status: AccountStatus, generation: Generation) -> Self {
914        let mut snapshot = AccountSnapshot::clone(&self.snapshot);
915        snapshot.status = status;
916        snapshot.generation = generation;
917        PublishableSnapshot {
918            snapshot: Arc::new(snapshot),
919            maximum_quote: self.maximum_quote,
920        }
921    }
922
923    /// The same publication proof at a new execution-capacity class and
924    /// generation (GL-99).
925    ///
926    /// Infallible for the reason [`restamped`](Self::restamped) is, and the
927    /// reason is worth stating rather than inferring from the signature:
928    /// [`try_new`](Self::try_new) validates the weighted-rate domain and the
929    /// cost table's worst quote against burst and any overage cap. A capacity
930    /// class participates in none of that — it decides whether an instance
931    /// *starts* already-funded work, not what that work costs — so the proof
932    /// carries over unchanged. Re-stamping the enforcement mode would not,
933    /// which is why there is no method for it.
934    #[must_use]
935    pub fn reclassified(&self, class: CapacityClass, generation: Generation) -> Self {
936        let mut snapshot = AccountSnapshot::clone(&self.snapshot);
937        snapshot.capacity_class = class;
938        snapshot.generation = generation;
939        PublishableSnapshot {
940            snapshot: Arc::new(snapshot),
941            maximum_quote: self.maximum_quote,
942        }
943    }
944}
945
946impl std::ops::Deref for PublishableSnapshot {
947    type Target = AccountSnapshot;
948
949    fn deref(&self) -> &Self::Target {
950        self.as_snapshot()
951    }
952}
953
954impl AsRef<AccountSnapshot> for PublishableSnapshot {
955    fn as_ref(&self) -> &AccountSnapshot {
956        self.as_snapshot()
957    }
958}
959
960impl TryFrom<Arc<AccountSnapshot>> for PublishableSnapshot {
961    type Error = SnapshotValidationError;
962
963    fn try_from(snapshot: Arc<AccountSnapshot>) -> Result<Self, Self::Error> {
964        PublishableSnapshot::try_new(snapshot)
965    }
966}
967
968impl AccountSnapshot {
969    /// Begin construction with the fields that have no meaningful default.
970    ///
971    /// `status` is deliberately required: inferring `Active` for an omitted
972    /// administrative state would turn incomplete control-plane data into an
973    /// authorization grant instead of failing closed (INVARIANTS.md GL-5).
974    #[must_use]
975    pub fn builder(
976        account_id: AccountId,
977        generation: Generation,
978        status: AccountStatus,
979        valid_until: Timestamp,
980        permissions: PermissionBits,
981        limits: ResolvedLimits,
982        cost_table: Arc<CostTable>,
983    ) -> AccountSnapshotBuilder {
984        AccountSnapshotBuilder {
985            account_id,
986            key_id: None,
987            generation,
988            status,
989            capacity_class: CapacityClass::Assured,
990            enforcement_mode: EnforcementMode::Strict,
991            valid_until,
992            permissions,
993            limits,
994            cost_table,
995            policy_revision: PolicyRevision::UNSTATED,
996        }
997    }
998
999    /// The principal-level admission test: status, staleness, permissions.
1000    /// Pure and O(1); rate and quota checks come after, in the admission
1001    /// pipeline, because they consume state.
1002    #[inline]
1003    pub fn admit(&self, now: Timestamp, required: PermissionBits) -> Result<(), DenyReason> {
1004        match self.status {
1005            AccountStatus::Active => {}
1006            AccountStatus::Suspended => return Err(DenyReason::AccountSuspended),
1007            AccountStatus::Closed => return Err(DenyReason::AccountClosed),
1008        }
1009        if now >= self.valid_until {
1010            return Err(DenyReason::SnapshotExpired);
1011        }
1012        if !self.permissions.contains_all(required) {
1013            return Err(DenyReason::MissingPermission);
1014        }
1015        Ok(())
1016    }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use crate::units::CostUnits;
1023
1024    /// The ledger column, the JSONB predicate and the wire DTO all compare
1025    /// these strings, so `as_str` and serde must agree exactly. If they ever
1026    /// diverge, a status change silently rewrites the wrong set of snapshots
1027    /// — the failure this whole mechanism exists to prevent (GL-51).
1028    #[test]
1029    #[cfg(feature = "serde")]
1030    fn account_status_text_matches_its_serde_spelling() {
1031        for status in [
1032            AccountStatus::Active,
1033            AccountStatus::Suspended,
1034            AccountStatus::Closed,
1035        ] {
1036            assert_eq!(
1037                serde_json::to_value(status).expect("a unit variant serializes"),
1038                serde_json::Value::String(status.as_str().to_owned()),
1039                "{status:?} disagrees with its serde spelling"
1040            );
1041        }
1042    }
1043
1044    /// The class's spellings must agree with serde's for the reason the
1045    /// status's must: the SQL `CHECK`, the JSONB republish predicate, the
1046    /// admin wire DTO, and the metric label all compare these exact strings.
1047    #[cfg(feature = "serde")]
1048    #[test]
1049    fn capacity_class_text_matches_its_serde_spelling() {
1050        for class in [CapacityClass::Assured, CapacityClass::BestEffort] {
1051            assert_eq!(
1052                serde_json::to_value(class).expect("a unit variant serializes"),
1053                serde_json::Value::String(class.as_str().to_owned()),
1054                "{class:?} disagrees with its serde spelling"
1055            );
1056        }
1057    }
1058
1059    /// An unstated class is `Assured`, and that direction is the whole
1060    /// compatibility argument: it preserves the availability every account
1061    /// has today. `BestEffort` would make existing traffic sheddable the
1062    /// moment a gate was enabled.
1063    #[test]
1064    fn the_default_class_is_assured() {
1065        assert_eq!(CapacityClass::default(), CapacityClass::Assured);
1066        assert!(CapacityClass::Assured.may_use_assured_reserve());
1067        assert!(!CapacityClass::BestEffort.may_use_assured_reserve());
1068    }
1069
1070    /// A snapshot from a control plane that predates GL-99 carries no class, and
1071    /// must decode as `Assured` rather than failing — the reader-first half of
1072    /// the rollout.
1073    #[cfg(feature = "serde")]
1074    #[test]
1075    fn a_snapshot_without_a_capacity_class_key_decodes_as_assured() {
1076        let snapshot = AccountSnapshot::builder(
1077            AccountId(1),
1078            Generation(1),
1079            AccountStatus::Active,
1080            Timestamp::from_second(10_000).unwrap(),
1081            PermissionBits::ALL,
1082            ResolvedLimits::new(64).with_weighted_rate(1_000, 1_000),
1083            Arc::new(CostTable::builder(CostUnits(1), CostUnits(1)).build()),
1084        )
1085        .capacity_class(CapacityClass::BestEffort)
1086        .build();
1087
1088        let mut value = serde_json::to_value(&snapshot).expect("a snapshot serializes");
1089        assert_eq!(
1090            value["capacity_class"],
1091            serde_json::Value::String("BestEffort".to_owned()),
1092            "a stated class is on the wire in its canonical spelling"
1093        );
1094        assert!(
1095            value
1096                .as_object_mut()
1097                .expect("a snapshot is a JSON object")
1098                .remove("capacity_class")
1099                .is_some()
1100        );
1101
1102        let decoded: AccountSnapshot =
1103            serde_json::from_value(value).expect("an older payload still decodes");
1104        assert_eq!(decoded.capacity_class, CapacityClass::Assured);
1105    }
1106
1107    /// A builder that states no class states `Assured` rather than inventing
1108    /// one, and the class survives a re-stamp of the other account-owned fact.
1109    #[test]
1110    fn a_class_survives_a_status_restamp() {
1111        let snapshot = AccountSnapshot::builder(
1112            AccountId(1),
1113            Generation(1),
1114            AccountStatus::Active,
1115            Timestamp::from_second(10_000).unwrap(),
1116            PermissionBits::ALL,
1117            ResolvedLimits::new(64).with_weighted_rate(1_000, 1_000),
1118            Arc::new(CostTable::builder(CostUnits(1), CostUnits(1)).build()),
1119        );
1120        assert_eq!(
1121            snapshot.build().capacity_class,
1122            CapacityClass::Assured,
1123            "an unstated class is assured"
1124        );
1125
1126        let publishable = PublishableSnapshot::try_new(Arc::new(
1127            AccountSnapshot::builder(
1128                AccountId(1),
1129                Generation(1),
1130                AccountStatus::Active,
1131                Timestamp::from_second(10_000).unwrap(),
1132                PermissionBits::ALL,
1133                ResolvedLimits::new(64).with_weighted_rate(1_000, 1_000),
1134                Arc::new(CostTable::builder(CostUnits(1), CostUnits(1)).build()),
1135            )
1136            .capacity_class(CapacityClass::BestEffort)
1137            .build(),
1138        ))
1139        .expect("the fixture publishes");
1140
1141        let restamped = publishable.restamped(AccountStatus::Suspended, Generation(2));
1142        assert_eq!(
1143            restamped.as_snapshot().capacity_class,
1144            CapacityClass::BestEffort,
1145            "a status change must not silently reclassify the account"
1146        );
1147
1148        // And the mirror: a class change leaves the status alone.
1149        let reclassified = publishable.reclassified(CapacityClass::Assured, Generation(3));
1150        assert_eq!(
1151            reclassified.as_snapshot().capacity_class,
1152            CapacityClass::Assured
1153        );
1154        assert_eq!(reclassified.as_snapshot().status, AccountStatus::Active);
1155        assert_eq!(reclassified.as_snapshot().generation, Generation(3));
1156        assert!(
1157            PublishableSnapshot::try_new(Arc::new(AccountSnapshot::clone(
1158                reclassified.as_snapshot()
1159            )))
1160            .is_ok(),
1161            "reclassifying preserves the publication proof"
1162        );
1163    }
1164
1165    /// A payload from a control plane that predates periodic budgets. It must
1166    /// decode, and it must decode to "nothing was said" — a required field
1167    /// would deny every principal until the whole catalogue was republished,
1168    /// and a defaulted zero would tell every caller they were out of quota.
1169    #[test]
1170    #[cfg(feature = "serde")]
1171    fn a_snapshot_without_a_budget_key_decodes_as_no_budget() {
1172        let snapshot = AccountSnapshot::builder(
1173            AccountId(1),
1174            Generation(1),
1175            AccountStatus::Active,
1176            Timestamp::from_second(10_000).unwrap(),
1177            PermissionBits::ALL,
1178            ResolvedLimits::new(64).with_weighted_rate(1_000, 1_000),
1179            Arc::new(CostTable::builder(CostUnits(1), CostUnits(1)).build()),
1180        )
1181        .build();
1182
1183        let mut value = serde_json::to_value(&snapshot).expect("a snapshot serializes");
1184        let removed = value
1185            .as_object_mut()
1186            .expect("a snapshot is a JSON object")
1187            .remove("budget");
1188        assert!(removed.is_some(), "the field is on the wire when present");
1189
1190        let decoded: AccountSnapshot =
1191            serde_json::from_value(value).expect("an older payload still decodes");
1192        assert_eq!(decoded.budget, None);
1193    }
1194
1195    /// A control plane that predates GL-94 publishes no revision, and its
1196    /// snapshots must keep working. The absent key decodes to "unstated"
1197    /// rather than to a decode error, which is the whole reader-first half of
1198    /// the rollout.
1199    #[test]
1200    #[cfg(feature = "serde")]
1201    fn a_snapshot_without_a_revision_key_decodes_as_unstated() {
1202        let snapshot = AccountSnapshot::builder(
1203            AccountId(1),
1204            Generation(1),
1205            AccountStatus::Active,
1206            Timestamp::from_second(10_000).unwrap(),
1207            PermissionBits::ALL,
1208            ResolvedLimits::new(64).with_weighted_rate(1_000, 1_000),
1209            Arc::new(CostTable::builder(CostUnits(1), CostUnits(1)).build()),
1210        )
1211        .policy_revision(PolicyRevision([0x5a; 32]))
1212        .build();
1213
1214        let mut value = serde_json::to_value(&snapshot).expect("a snapshot serializes");
1215        assert_eq!(
1216            value["policy_revision"],
1217            serde_json::Value::String("5a".repeat(32)),
1218            "a stated revision is on the wire in canonical form"
1219        );
1220        let removed = value
1221            .as_object_mut()
1222            .expect("a snapshot is a JSON object")
1223            .remove("policy_revision");
1224        assert!(removed.is_some(), "the field is on the wire when present");
1225
1226        let decoded: AccountSnapshot =
1227            serde_json::from_value(value).expect("an older payload still decodes");
1228        assert_eq!(decoded.policy_revision, PolicyRevision::UNSTATED);
1229        assert!(decoded.policy_revision.is_unstated());
1230    }
1231
1232    /// Omitting the setter states nothing rather than inventing a value.
1233    #[test]
1234    fn a_builder_without_a_revision_states_none() {
1235        let snapshot = AccountSnapshot::builder(
1236            AccountId(1),
1237            Generation(1),
1238            AccountStatus::Active,
1239            Timestamp::from_second(10_000).unwrap(),
1240            PermissionBits::ALL,
1241            ResolvedLimits::new(64),
1242            Arc::new(CostTable::builder(CostUnits(1), CostUnits(1)).build()),
1243        )
1244        .build();
1245        assert_eq!(snapshot.policy_revision, PolicyRevision::UNSTATED);
1246    }
1247
1248    /// The store overwrites this field on every publish, so the clearing arm
1249    /// has to work as well as the setting one — that is what stops a value
1250    /// arriving over the wire from surviving publication.
1251    #[test]
1252    fn with_budget_replaces_and_can_clear() {
1253        let publishable = PublishableSnapshot::try_new(Arc::new(
1254            AccountSnapshot::builder(
1255                AccountId(1),
1256                Generation(1),
1257                AccountStatus::Active,
1258                Timestamp::from_second(10_000).unwrap(),
1259                PermissionBits::ALL,
1260                ResolvedLimits::new(64).with_weighted_rate(1_000, 1_000),
1261                Arc::new(CostTable::builder(CostUnits(1), CostUnits(1)).build()),
1262            )
1263            .build(),
1264        ))
1265        .expect("the test snapshot is publishable");
1266        assert_eq!(publishable.budget, None, "a builder cannot set one");
1267
1268        let view = BudgetView {
1269            balance_at_publish: CostUnits(500),
1270            period_end: None,
1271        };
1272        let stamped = publishable.with_budget(Some(view));
1273        assert_eq!(stamped.budget, Some(view));
1274        assert_eq!(
1275            stamped.maximum_quote(),
1276            publishable.maximum_quote(),
1277            "the publication proof carries over: validation does not read the budget"
1278        );
1279        assert_eq!(stamped.with_budget(None).budget, None);
1280    }
1281
1282    /// The same rule `AccountStatus` follows, for the same reason: the mode's
1283    /// tag is compared as text by the ledger column, the JSONB predicate that
1284    /// selects which snapshots a mode change rewrites, and the admin wire DTO.
1285    #[test]
1286    #[cfg(feature = "serde")]
1287    fn enforcement_mode_text_matches_its_serde_tag() {
1288        for mode in [
1289            EnforcementMode::Strict,
1290            EnforcementMode::Elastic {
1291                overage_cap: CostUnits(1_000),
1292            },
1293        ] {
1294            let value = serde_json::to_value(mode).expect("a mode serializes");
1295            let tag = match &value {
1296                serde_json::Value::String(tag) => tag.clone(),
1297                serde_json::Value::Object(map) => {
1298                    map.keys().next().expect("one variant key").clone()
1299                }
1300                other => panic!("unexpected encoding {other}"),
1301            };
1302            assert_eq!(tag, mode.as_str(), "{mode:?} disagrees with its serde tag");
1303        }
1304    }
1305
1306    /// The cap must never reach a label. A per-account number in a metric
1307    /// label turns a fixed enum into unbounded cardinality — the rule
1308    /// `DenyReason`'s `payload_does_not_affect_the_slot` pins one layer down.
1309    #[test]
1310    fn the_cap_does_not_affect_the_mode_label() {
1311        assert_eq!(
1312            EnforcementMode::Elastic {
1313                overage_cap: CostUnits(1)
1314            }
1315            .as_str(),
1316            EnforcementMode::Elastic {
1317                overage_cap: CostUnits(u64::MAX)
1318            }
1319            .as_str()
1320        );
1321    }
1322
1323    /// An unstated mode enforces. This is the value every pre-existing stored
1324    /// row and every older control plane decodes to, so it is the one that
1325    /// must not extend credit.
1326    #[test]
1327    fn the_default_mode_is_strict() {
1328        assert_eq!(EnforcementMode::default(), EnforcementMode::Strict);
1329        assert_eq!(EnforcementMode::Strict.overage_cap(), None);
1330    }
1331
1332    #[test]
1333    fn concurrency_construction_rejects_a_principal_ceiling_above_the_account() {
1334        let account = NonZeroU32::new(4).unwrap();
1335        assert!(
1336            ResolvedLimits::new(64)
1337                .with_concurrency(account, Some(account))
1338                .is_ok(),
1339            "a principal ceiling equal to the account ceiling is a valid narrowing"
1340        );
1341        let principal = NonZeroU32::new(5).unwrap();
1342        assert_eq!(
1343            ResolvedLimits::new(64)
1344                .with_concurrency(account, Some(principal))
1345                .unwrap_err(),
1346            ResolvedLimitsError::PrincipalConcurrencyExceedsAccount { principal, account }
1347        );
1348    }
1349
1350    #[test]
1351    fn configured_dimensions_are_visible_through_the_public_accessors() {
1352        let requests_per_second = NonZeroU32::new(10).unwrap();
1353        let burst_requests = NonZeroU32::new(20).unwrap();
1354        let account = NonZeroU32::new(4).unwrap();
1355        let principal = NonZeroU32::new(2).unwrap();
1356        let limits = ResolvedLimits::new(64)
1357            .with_request_rate(requests_per_second, burst_requests)
1358            .with_concurrency(account, Some(principal))
1359            .unwrap();
1360
1361        let request_rate = limits.request_rate().expect("request rate is configured");
1362        assert_eq!(request_rate.requests_per_second(), requests_per_second);
1363        assert_eq!(request_rate.burst_requests(), burst_requests);
1364        assert_eq!(limits.max_concurrent_requests(), Some(account));
1365        assert_eq!(limits.principal_max_concurrent_requests(), Some(principal));
1366    }
1367
1368    #[test]
1369    fn account_rate_policy_extracts_exactly_the_rate_dimensions() {
1370        let account = ResolvedLimits::new(999)
1371            .with_weighted_rate(700, 800)
1372            .with_request_rate(NonZeroU32::new(9).unwrap(), NonZeroU32::new(10).unwrap());
1373        let principal = ResolvedLimits::new(64)
1374            .with_weighted_rate_compatibility_fallback(11, 12)
1375            .with_concurrency(
1376                NonZeroU32::new(4).unwrap(),
1377                Some(NonZeroU32::new(2).unwrap()),
1378            )
1379            .unwrap();
1380
1381        let policy = account.account_rate_policy();
1382        assert_eq!(policy.weighted_rate(), account.weighted_rate());
1383        assert_eq!(
1384            policy.legacy_weighted_rate(),
1385            account.legacy_weighted_rate()
1386        );
1387        assert_eq!(policy.request_rate(), account.request_rate());
1388
1389        assert_eq!(principal.max_items_per_request(), 64);
1390        assert_eq!(principal.weighted_rate(), None);
1391        assert_eq!(principal.legacy_weighted_rate().units_per_second(), 11);
1392        assert_eq!(principal.legacy_weighted_rate().burst_units(), 12);
1393        assert_eq!(principal.request_rate(), None);
1394        assert_eq!(principal.max_concurrent_requests(), NonZeroU32::new(4));
1395        assert_eq!(
1396            principal.principal_max_concurrent_requests(),
1397            NonZeroU32::new(2)
1398        );
1399    }
1400
1401    #[test]
1402    #[cfg(feature = "serde")]
1403    fn pre_staged_limits_decode_as_weighted_and_round_trip_canonically() {
1404        let old = serde_json::json!({
1405            "max_items_per_request": 64,
1406            "rate_units_per_second": 1_000,
1407            "rate_burst_units": 2_000
1408        });
1409        let limits: ResolvedLimits = serde_json::from_value(old.clone()).unwrap();
1410        assert_eq!(
1411            limits.weighted_rate(),
1412            Some(WeightedRateLimit {
1413                units_per_second: 1_000,
1414                burst_units: 2_000,
1415            })
1416        );
1417        assert_eq!(serde_json::to_value(limits).unwrap(), old);
1418    }
1419
1420    #[test]
1421    #[cfg(feature = "serde")]
1422    fn disabled_weighted_rate_preserves_the_legacy_fallback() {
1423        let wire = serde_json::json!({
1424            "max_items_per_request": 64,
1425            "rate_units_per_second": 800,
1426            "rate_burst_units": 1_600,
1427            "weighted_rate_enabled": false
1428        });
1429        let limits: ResolvedLimits = serde_json::from_value(wire.clone()).unwrap();
1430        assert_eq!(limits.weighted_rate(), None);
1431        assert_eq!(limits.legacy_weighted_rate().units_per_second(), 800);
1432        assert_eq!(limits.legacy_weighted_rate().burst_units(), 1_600);
1433        assert_eq!(serde_json::to_value(limits).unwrap(), wire);
1434    }
1435
1436    #[test]
1437    fn disabled_weighted_rate_still_validates_the_rollback_burst() {
1438        let mut snapshot = (*priced_snapshot(50, 50, &[(0, 1)], 64, 114)).clone();
1439        snapshot.limits =
1440            ResolvedLimits::new(64).with_weighted_rate_compatibility_fallback(1_000, 113);
1441        assert_eq!(
1442            PublishableSnapshot::try_new(Arc::new(snapshot)).unwrap_err(),
1443            SnapshotValidationError::QuoteExceedsBurst {
1444                operation_index: 0,
1445                max_quote: CostUnits(114),
1446                burst_units: CostUnits(113),
1447            }
1448        );
1449    }
1450
1451    #[test]
1452    #[cfg(feature = "serde")]
1453    fn wire_rejects_partial_rate_pairs_and_widening_principal_limits() {
1454        let partial = serde_json::json!({
1455            "max_items_per_request": 64,
1456            "rate_units_per_second": 1_000,
1457            "rate_burst_units": 2_000,
1458            "request_rate_per_second": 10
1459        });
1460        assert!(serde_json::from_value::<ResolvedLimits>(partial).is_err());
1461
1462        let widening = serde_json::json!({
1463            "max_items_per_request": 64,
1464            "rate_units_per_second": 1_000,
1465            "rate_burst_units": 2_000,
1466            "max_concurrent_requests": 4,
1467            "principal_max_concurrent_requests": 5
1468        });
1469        assert!(serde_json::from_value::<ResolvedLimits>(widening).is_err());
1470
1471        let orphaned_principal = serde_json::json!({
1472            "max_items_per_request": 64,
1473            "rate_units_per_second": 1_000,
1474            "rate_burst_units": 2_000,
1475            "principal_max_concurrent_requests": 4
1476        });
1477        assert!(serde_json::from_value::<ResolvedLimits>(orphaned_principal).is_err());
1478
1479        let equal = serde_json::json!({
1480            "max_items_per_request": 64,
1481            "rate_units_per_second": 1_000,
1482            "rate_burst_units": 2_000,
1483            "max_concurrent_requests": 4,
1484            "principal_max_concurrent_requests": 4
1485        });
1486        assert!(serde_json::from_value::<ResolvedLimits>(equal).is_ok());
1487    }
1488
1489    #[test]
1490    fn publication_rejects_weighted_values_outside_governors_domain() {
1491        for limits in [
1492            ResolvedLimits::new(64).with_weighted_rate(0, 1),
1493            ResolvedLimits::new(64).with_weighted_rate(1, 0),
1494            ResolvedLimits::new(64).with_weighted_rate(u64::from(u32::MAX) + 1, 1),
1495            ResolvedLimits::new(64).with_weighted_rate(1, u64::from(u32::MAX) + 1),
1496        ] {
1497            let mut snapshot = snapshot(AccountStatus::Active, t(1_000));
1498            snapshot.limits = limits;
1499            assert!(matches!(
1500                PublishableSnapshot::try_new(Arc::new(snapshot)),
1501                Err(SnapshotValidationError::WeightedRateOutsideGovernorDomain { .. })
1502            ));
1503        }
1504    }
1505
1506    /// A cap that cannot fund one worst-case request would read as "extend
1507    /// credit" and behave as `Strict`. Refused at publication, where the
1508    /// burst check already refuses its analogue.
1509    #[test]
1510    fn a_cap_below_the_worst_quote_is_unpublishable() {
1511        // Worst quote is 10 fixed + 7 x 10 items = 80, equal to the burst, so
1512        // only the overage cap can be what refuses these.
1513        let elastic = |overage_cap: u64| {
1514            let mut snapshot = AccountSnapshot::clone(&priced_snapshot(10, 1, &[(0, 7)], 10, 80));
1515            snapshot.enforcement_mode = EnforcementMode::Elastic {
1516                overage_cap: CostUnits(overage_cap),
1517            };
1518            PublishableSnapshot::try_new(Arc::new(snapshot))
1519        };
1520
1521        assert!(
1522            elastic(80).is_ok(),
1523            "a cap that funds exactly one worst case is publishable"
1524        );
1525        assert_eq!(
1526            elastic(79).unwrap_err(),
1527            SnapshotValidationError::OverageCapBelowMaxQuote {
1528                operation_index: 0,
1529                max_quote: CostUnits(80),
1530                overage_cap: CostUnits(79),
1531            }
1532        );
1533    }
1534
1535    /// The mode participates in validation, unlike status and generation, so
1536    /// `restamped` must not be able to change it — otherwise it would carry a
1537    /// proof forward over a value that proof depended on.
1538    #[test]
1539    fn restamping_cannot_change_the_enforcement_mode() {
1540        let mut snapshot = snapshot(AccountStatus::Active, t(1_000));
1541        snapshot.enforcement_mode = EnforcementMode::Elastic {
1542            overage_cap: CostUnits(5_000),
1543        };
1544        let publishable = PublishableSnapshot::try_new(Arc::new(snapshot)).unwrap();
1545        let restamped = publishable.restamped(AccountStatus::Suspended, Generation(9));
1546        assert_eq!(
1547            restamped.as_snapshot().enforcement_mode,
1548            EnforcementMode::Elastic {
1549                overage_cap: CostUnits(5_000)
1550            }
1551        );
1552    }
1553
1554    /// Re-stamping carries the publication proof because neither field it
1555    /// touches participates in validation. Pinned against a snapshot whose
1556    /// margin is exact: if `restamped` ever rebuilt the proof from scratch
1557    /// this would still pass, but if it ever *altered* limits or cost table
1558    /// it would not.
1559    #[test]
1560    fn restamping_preserves_everything_validation_depends_on() {
1561        let original = PublishableSnapshot::try_new(Arc::new(snapshot(
1562            AccountStatus::Active,
1563            Timestamp::from_second(1_000).unwrap(),
1564        )))
1565        .expect("the fixture is publishable");
1566
1567        let restamped = original.restamped(AccountStatus::Suspended, Generation(9));
1568
1569        assert_eq!(restamped.status, AccountStatus::Suspended);
1570        assert_eq!(restamped.generation, Generation(9));
1571        assert_eq!(restamped.account_id, original.account_id);
1572        assert_eq!(restamped.key_id, original.key_id);
1573        assert_eq!(restamped.valid_until, original.valid_until);
1574        assert_eq!(restamped.permissions, original.permissions);
1575        assert_eq!(
1576            restamped.limits.max_items_per_request(),
1577            original.limits.max_items_per_request()
1578        );
1579        assert_eq!(
1580            restamped.limits.legacy_weighted_rate().burst_units(),
1581            original.limits.legacy_weighted_rate().burst_units()
1582        );
1583        assert!(
1584            Arc::ptr_eq(&restamped.cost_table, &original.cost_table),
1585            "the cost table is shared, not rebuilt"
1586        );
1587        // The proof still holds when re-derived, which is the claim the doc
1588        // comment makes.
1589        PublishableSnapshot::try_new(restamped.into_inner())
1590            .expect("status and generation do not affect publishability");
1591    }
1592
1593    fn snapshot(status: AccountStatus, valid_until: Timestamp) -> AccountSnapshot {
1594        AccountSnapshot::builder(
1595            AccountId(1),
1596            Generation(1),
1597            status,
1598            valid_until,
1599            PermissionBits::bit(0).union(PermissionBits::bit(3)),
1600            ResolvedLimits::new(1024).with_weighted_rate(10_000, 50_000),
1601            Arc::new(CostTable::builder(CostUnits(50), CostUnits(50)).build()),
1602        )
1603        .key_id(KeyId(2))
1604        .build()
1605    }
1606
1607    fn t(secs: i64) -> Timestamp {
1608        Timestamp::from_second(secs).unwrap()
1609    }
1610
1611    /// The administrative state is part of the constructor's type, not a
1612    /// setter callers can forget. This signature witness fails to compile if
1613    /// the builder ever regains a fail-open status default.
1614    #[test]
1615    fn builder_requires_account_status_at_construction() {
1616        type Builder = fn(
1617            AccountId,
1618            Generation,
1619            AccountStatus,
1620            Timestamp,
1621            PermissionBits,
1622            ResolvedLimits,
1623            Arc<CostTable>,
1624        ) -> AccountSnapshotBuilder;
1625
1626        let _: Builder = AccountSnapshot::builder;
1627    }
1628
1629    #[test]
1630    fn active_valid_and_permitted_admits() {
1631        let s = snapshot(AccountStatus::Active, t(1_000));
1632        assert_eq!(s.admit(t(999), PermissionBits::bit(0)), Ok(()));
1633    }
1634
1635    #[test]
1636    fn suspended_and_closed_deny() {
1637        let s = snapshot(AccountStatus::Suspended, t(1_000));
1638        assert_eq!(
1639            s.admit(t(0), PermissionBits::NONE),
1640            Err(DenyReason::AccountSuspended)
1641        );
1642        let s = snapshot(AccountStatus::Closed, t(1_000));
1643        assert_eq!(
1644            s.admit(t(0), PermissionBits::NONE),
1645            Err(DenyReason::AccountClosed)
1646        );
1647    }
1648
1649    #[test]
1650    fn expiry_boundary_is_exclusive_of_valid_until() {
1651        let s = snapshot(AccountStatus::Active, t(1_000));
1652        assert_eq!(
1653            s.admit(t(1_000), PermissionBits::NONE),
1654            Err(DenyReason::SnapshotExpired)
1655        );
1656        assert_eq!(
1657            s.admit(t(1_001), PermissionBits::NONE),
1658            Err(DenyReason::SnapshotExpired)
1659        );
1660    }
1661
1662    #[test]
1663    fn missing_permission_denies() {
1664        let s = snapshot(AccountStatus::Active, t(1_000));
1665        assert_eq!(
1666            s.admit(t(0), PermissionBits::bit(1)),
1667            Err(DenyReason::MissingPermission)
1668        );
1669        // Superset of granted bits is required, not intersection.
1670        assert_eq!(
1671            s.admit(t(0), PermissionBits::bit(0).union(PermissionBits::bit(1))),
1672            Err(DenyReason::MissingPermission)
1673        );
1674    }
1675
1676    fn priced_snapshot(
1677        fixed: u64,
1678        minimum: u64,
1679        weights: &[(usize, u64)],
1680        max_items: u64,
1681        burst: u64,
1682    ) -> Arc<AccountSnapshot> {
1683        struct Op(usize);
1684        impl crate::cost_table::OpIndex for Op {
1685            fn index(&self) -> usize {
1686                self.0
1687            }
1688        }
1689
1690        let mut builder = CostTable::builder(CostUnits(fixed), CostUnits(minimum));
1691        for (index, weight) in weights {
1692            builder = builder.weight(&Op(*index), CostUnits(*weight));
1693        }
1694        Arc::new(
1695            AccountSnapshot::builder(
1696                AccountId(1),
1697                Generation(1),
1698                AccountStatus::Active,
1699                t(1_000),
1700                PermissionBits::bit(0).union(PermissionBits::bit(3)),
1701                ResolvedLimits::new(max_items).with_weighted_rate(1_000, burst),
1702                Arc::new(builder.build()),
1703            )
1704            .key_id(KeyId(2))
1705            .build(),
1706        )
1707    }
1708
1709    #[test]
1710    fn publication_uses_the_largest_registered_weight() {
1711        let snapshot = priced_snapshot(10, 1, &[(0, 2), (3, 7), (5, 4)], 10, 79);
1712        assert_eq!(
1713            PublishableSnapshot::try_new(snapshot).unwrap_err(),
1714            SnapshotValidationError::QuoteExceedsBurst {
1715                operation_index: 3,
1716                max_quote: CostUnits(80),
1717                burst_units: CostUnits(79),
1718            }
1719        );
1720    }
1721
1722    #[test]
1723    fn publication_reports_first_operation_when_maximum_weights_tie() {
1724        assert_eq!(
1725            PublishableSnapshot::try_new(priced_snapshot(0, 0, &[(2, 7), (5, 7)], 10, 69))
1726                .unwrap_err(),
1727            SnapshotValidationError::QuoteExceedsBurst {
1728                operation_index: 2,
1729                max_quote: CostUnits(70),
1730                burst_units: CostUnits(69),
1731            }
1732        );
1733    }
1734
1735    #[test]
1736    fn publication_accepts_a_worst_case_quote_equal_to_the_burst() {
1737        let publishable =
1738            PublishableSnapshot::try_new(priced_snapshot(10, 1, &[(0, 7)], 10, 80)).unwrap();
1739        assert_eq!(
1740            publishable.maximum_quote(),
1741            Some(CostUnits(80)),
1742            "the proof passed to the next trust boundary is the quote validated here"
1743        );
1744    }
1745
1746    #[test]
1747    fn publication_rejects_a_minimum_above_the_burst() {
1748        assert!(matches!(
1749            PublishableSnapshot::try_new(priced_snapshot(0, 50, &[(0, 0)], 64, 49)),
1750            Err(SnapshotValidationError::QuoteExceedsBurst {
1751                max_quote: CostUnits(50),
1752                ..
1753            })
1754        ));
1755    }
1756
1757    #[test]
1758    fn publication_rejects_worst_case_quote_overflow() {
1759        assert_eq!(
1760            PublishableSnapshot::try_new(priced_snapshot(
1761                1,
1762                0,
1763                &[(0, u64::MAX)],
1764                2,
1765                u64::from(u32::MAX),
1766            ))
1767            .unwrap_err(),
1768            SnapshotValidationError::QuoteOverflow {
1769                operation_index: 0,
1770                max_items: 2,
1771            }
1772        );
1773    }
1774
1775    #[test]
1776    fn publication_allows_a_table_with_no_registered_operations() {
1777        PublishableSnapshot::try_new(priced_snapshot(100, 100, &[], 64, 1)).unwrap();
1778    }
1779}
1780
1781#[cfg(test)]
1782mod layout {
1783    use super::*;
1784    use std::mem::{align_of, offset_of, size_of};
1785
1786    /// One cache line, and the admission path reads it once.
1787    ///
1788    /// Before `#[repr(C)]` these four fields were wherever the compiler put
1789    /// them — measured at offsets 160, 192, 200 and 32, so admitting a request
1790    /// touched two lines to read four values. Declaration order is now a
1791    /// contract, and this is what makes it one: a later field inserted among
1792    /// them, or a reorder that looks harmless, fails here rather than showing
1793    /// up as an unexplained regression in `snapshot/admit`.
1794    #[test]
1795    fn stage_one_fields_share_the_first_cache_line() {
1796        const LINE: usize = 64;
1797        for (name, offset) in [
1798            ("status", offset_of!(AccountSnapshot, status)),
1799            ("permissions", offset_of!(AccountSnapshot, permissions)),
1800            ("valid_until", offset_of!(AccountSnapshot, valid_until)),
1801            (
1802                "enforcement_mode",
1803                offset_of!(AccountSnapshot, enforcement_mode),
1804            ),
1805        ] {
1806            assert!(
1807                offset < LINE,
1808                "{name} sits at offset {offset}, past the first {LINE}-byte line \
1809                 the request path reads"
1810            );
1811        }
1812    }
1813
1814    /// The revision is carried, never read, so it must not displace anything
1815    /// admission touches. Asserting it is *past* the first line is the half
1816    /// that would catch a well-meaning reorder putting it up front.
1817    #[test]
1818    fn the_policy_revision_is_cold() {
1819        assert!(
1820            offset_of!(AccountSnapshot, policy_revision) >= 64,
1821            "the policy revision belongs outside the stage-one cache line"
1822        );
1823    }
1824
1825    /// The alignment keeps one account's snapshot off another's cache lines,
1826    /// and the size claim in `budget`'s documentation is now checked rather
1827    /// than asserted in prose: `policy_revision` lands in padding the
1828    /// `align(128)` had already reserved, so the type did not grow.
1829    #[test]
1830    fn the_snapshot_stays_one_two_line_object() {
1831        assert_eq!(align_of::<AccountSnapshot>(), 128);
1832        assert_eq!(
1833            size_of::<AccountSnapshot>(),
1834            256,
1835            "a snapshot that outgrew its two lines costs every account an \
1836             extra line; justify the growth or move the new field"
1837        );
1838    }
1839}