Skip to main content

cloud_sdk/operation/
permit.rs

1//! Plan-confirm execution authority for state-changing operations.
2
3mod clock;
4mod direct;
5mod execution_error;
6mod fingerprint;
7mod shared;
8mod state;
9
10pub use clock::PermitClock;
11pub use direct::{CostPermit, DestructivePermit, MutationPermit};
12pub use execution_error::PermitExecutionError;
13pub use fingerprint::{
14    CanonicalPlanFingerprint, PlanConfirmation, PlanFingerprintBuildError, PlanFingerprintDigest,
15    PlanFingerprintRef, PlanSubject, build_canonical_plan, build_plan_digest,
16};
17pub use shared::{
18    SharedCostPermit, SharedDestructivePermit, SharedMutationPermit, SharedPermitState,
19};
20pub use state::PermitAttempt;
21
22use core::fmt;
23
24use subtle::{Choice, ConstantTimeEq};
25
26/// Maximum bytes admitted for each account, tenant, or permit-scope value.
27pub const MAX_PLAN_SCOPE_BYTES: usize = 1024;
28/// Minimum caller-provided idempotency identity length.
29pub const MIN_PERMIT_IDEMPOTENCY_BYTES: usize = 16;
30/// Maximum caller-provided idempotency identity length.
31pub const MAX_PERMIT_IDEMPOTENCY_BYTES: usize = 64;
32
33/// Runtime execution authority required by an operation.
34#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub enum PermitScope {
36    /// Non-destructive state mutation without a known direct charge.
37    Mutation,
38    /// Destructive or disabling state mutation.
39    Destructive,
40    /// Mutation that may directly incur provider charges.
41    Cost,
42}
43
44/// Caller assessment of whether the planned request changes effective state.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum PlanChange {
47    /// The request would make no effective change and must not be authorized.
48    NoOp,
49    /// The caller confirmed an effective state change.
50    ChangesState,
51}
52
53/// Repetition policy selected during plan confirmation.
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum ReplayPolicy {
56    /// Exactly one attempt is authorized.
57    SingleAttempt,
58    /// A proven `NotSent` attempt may be recovered and repeated.
59    RecoverNotSent,
60    /// Uncertain delivery may repeat only after operation-specific reconciliation.
61    ReconcileThenRetry,
62}
63
64/// Invalid attempt budget.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum AttemptBudgetError {
67    /// At least one attempt is required.
68    Zero,
69}
70
71impl_static_error!(AttemptBudgetError, Self::Zero => "permit attempt budget must be nonzero");
72
73/// Nonzero maximum number of attempts sharing one authority.
74#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
75pub struct AttemptBudget(u16);
76
77impl AttemptBudget {
78    /// Creates a nonzero attempt budget.
79    pub const fn new(value: u16) -> Result<Self, AttemptBudgetError> {
80        if value == 0 {
81            return Err(AttemptBudgetError::Zero);
82        }
83        Ok(Self(value))
84    }
85
86    /// Returns the total attempt bound.
87    #[must_use]
88    pub const fn get(self) -> u16 {
89        self.0
90    }
91}
92
93/// Caller-observed wall-clock timestamp in Unix seconds.
94#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
95pub struct PermitTimestamp(u64);
96
97impl PermitTimestamp {
98    /// Wraps caller-provided Unix seconds without acquiring a clock.
99    #[must_use]
100    pub const fn from_seconds(seconds: u64) -> Self {
101        Self(seconds)
102    }
103
104    /// Returns Unix seconds.
105    #[must_use]
106    pub const fn as_seconds(self) -> u64 {
107        self.0
108    }
109}
110
111/// Invalid bounded permit-validity interval.
112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
113pub enum PermitValidityError {
114    /// Expiry must be later than issuance.
115    Empty,
116    /// The interval exceeds the shared-state representation.
117    TooLong,
118}
119
120impl_static_error!(PermitValidityError,
121    Self::Empty => "permit expiry must follow issuance",
122    Self::TooLong => "permit validity interval is too long",
123);
124
125/// Caller-owned issuance and expiry observations.
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub struct PermitValidity {
128    issued_at: PermitTimestamp,
129    expires_at: PermitTimestamp,
130    duration: u32,
131}
132
133impl PermitValidity {
134    /// Creates a bounded interval suitable for direct and atomic shared state.
135    pub fn new(
136        issued_at: PermitTimestamp,
137        expires_at: PermitTimestamp,
138    ) -> Result<Self, PermitValidityError> {
139        let duration = expires_at
140            .0
141            .checked_sub(issued_at.0)
142            .ok_or(PermitValidityError::Empty)?;
143        if duration == 0 {
144            return Err(PermitValidityError::Empty);
145        }
146        let duration = u32::try_from(duration).map_err(|_| PermitValidityError::TooLong)?;
147        Ok(Self {
148            issued_at,
149            expires_at,
150            duration,
151        })
152    }
153
154    /// Returns the issuance timestamp.
155    #[must_use]
156    pub const fn issued_at(self) -> PermitTimestamp {
157        self.issued_at
158    }
159
160    /// Returns the expiry timestamp.
161    #[must_use]
162    pub const fn expires_at(self) -> PermitTimestamp {
163        self.expires_at
164    }
165
166    pub(crate) fn offset(self, now: PermitTimestamp) -> Result<u32, ExecutionPermitError> {
167        let elapsed = now
168            .0
169            .checked_sub(self.issued_at.0)
170            .ok_or(ExecutionPermitError::NotYetValid)?;
171        if elapsed >= u64::from(self.duration) {
172            return Err(ExecutionPermitError::Expired);
173        }
174        u32::try_from(elapsed).map_err(|_| ExecutionPermitError::Expired)
175    }
176}
177
178/// Invalid three-letter currency code.
179#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum CurrencyCodeError {
181    /// Currency codes must be exactly three ASCII letters.
182    Invalid,
183}
184
185impl_static_error!(CurrencyCodeError, Self::Invalid => "currency code must be three uppercase ASCII letters");
186
187/// Exact ISO-style uppercase currency code.
188#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
189pub struct CurrencyCode([u8; 3]);
190
191impl CurrencyCode {
192    /// Validates an exact uppercase three-letter code.
193    pub fn new(value: &str) -> Result<Self, CurrencyCodeError> {
194        let bytes: [u8; 3] = value
195            .as_bytes()
196            .try_into()
197            .map_err(|_| CurrencyCodeError::Invalid)?;
198        if !bytes.iter().all(u8::is_ascii_uppercase) {
199            return Err(CurrencyCodeError::Invalid);
200        }
201        Ok(Self(bytes))
202    }
203
204    /// Returns exact code bytes.
205    #[must_use]
206    pub const fn as_bytes(self) -> [u8; 3] {
207        self.0
208    }
209}
210
211/// Invalid cost confirmation.
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub enum PlanCostError {
214    /// An observed price must be nonzero for cost authority.
215    ZeroObservedPrice,
216    /// The observed price exceeds the caller's spending ceiling.
217    SpendingCeilingExceeded,
218}
219
220impl_static_error!(PlanCostError,
221    Self::ZeroObservedPrice => "observed price must be nonzero",
222    Self::SpendingCeilingExceeded => "observed price exceeds spending ceiling",
223);
224
225/// Exact caller-observed price and spending ceiling in common scaled units.
226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub struct PlanCost {
228    currency: CurrencyCode,
229    scale: u8,
230    observed_units: u128,
231    ceiling_units: u128,
232}
233
234impl PlanCost {
235    /// Creates one exact bounded cost confirmation.
236    pub fn new(
237        currency: CurrencyCode,
238        scale: u8,
239        observed_units: u128,
240        ceiling_units: u128,
241    ) -> Result<Self, PlanCostError> {
242        if observed_units == 0 {
243            return Err(PlanCostError::ZeroObservedPrice);
244        }
245        if observed_units > ceiling_units {
246            return Err(PlanCostError::SpendingCeilingExceeded);
247        }
248        Ok(Self {
249            currency,
250            scale,
251            observed_units,
252            ceiling_units,
253        })
254    }
255
256    pub(crate) const fn fields(self) -> (CurrencyCode, u8, u128, u128) {
257        (
258            self.currency,
259            self.scale,
260            self.observed_units,
261            self.ceiling_units,
262        )
263    }
264}
265
266/// Invalid account, tenant, or permit-context value.
267#[derive(Clone, Copy, Debug, Eq, PartialEq)]
268pub enum PermitContextError {
269    /// Permit context must not be empty.
270    Empty,
271    /// Permit context exceeds its fixed policy bound.
272    TooLong,
273}
274
275impl_static_error!(PermitContextError,
276    Self::Empty => "permit context is empty",
277    Self::TooLong => "permit context exceeds the length limit",
278);
279
280/// Exact bounded context binding a permit to caller policy.
281#[derive(Clone, Copy)]
282pub struct PermitContext<'a>(&'a [u8]);
283
284impl<'a> PermitContext<'a> {
285    /// Validates a nonempty bounded context.
286    pub fn new(value: &'a [u8]) -> Result<Self, PermitContextError> {
287        if value.is_empty() {
288            return Err(PermitContextError::Empty);
289        }
290        if value.len() > MAX_PLAN_SCOPE_BYTES {
291            return Err(PermitContextError::TooLong);
292        }
293        Ok(Self(value))
294    }
295
296    pub(crate) const fn bytes(self) -> &'a [u8] {
297        self.0
298    }
299}
300
301impl fmt::Debug for PermitContext<'_> {
302    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303        formatter.write_str("PermitContext([redacted])")
304    }
305}
306
307/// Optional exact account or tenant identity.
308#[derive(Clone, Copy, Debug)]
309pub enum PlanFingerprintScope<'a> {
310    /// No identity applies in this dimension.
311    Absent,
312    /// Exact bounded identity bytes.
313    Value(&'a [u8]),
314}
315
316impl<'a> PlanFingerprintScope<'a> {
317    pub(crate) fn bytes(self) -> Result<Option<&'a [u8]>, PermitContextError> {
318        match self {
319            Self::Absent => Ok(None),
320            Self::Value([]) => Err(PermitContextError::Empty),
321            Self::Value(value) if value.len() > MAX_PLAN_SCOPE_BYTES => {
322                Err(PermitContextError::TooLong)
323            }
324            Self::Value(value) => Ok(Some(value)),
325        }
326    }
327}
328
329/// Invalid caller-provided repetition identity.
330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
331pub enum PermitIdempotencyKeyError {
332    /// Identity has too few entropy-bearing bytes.
333    TooShort,
334    /// Identity exceeds the fixed policy bound.
335    TooLong,
336    /// An all-zero identity is rejected.
337    AllZero,
338}
339
340impl_static_error!(PermitIdempotencyKeyError,
341    Self::TooShort => "permit idempotency identity is too short",
342    Self::TooLong => "permit idempotency identity is too long",
343    Self::AllZero => "permit idempotency identity cannot be all zero",
344);
345
346/// Borrowed caller-owned identity used only for exact reconciliation matching.
347#[derive(Clone, Copy)]
348pub struct PermitIdempotencyKey<'a>(&'a [u8]);
349
350impl<'a> PermitIdempotencyKey<'a> {
351    /// Validates identity shape. Entropy quality remains a caller duty.
352    pub fn new(value: &'a [u8]) -> Result<Self, PermitIdempotencyKeyError> {
353        if value.len() < MIN_PERMIT_IDEMPOTENCY_BYTES {
354            return Err(PermitIdempotencyKeyError::TooShort);
355        }
356        if value.len() > MAX_PERMIT_IDEMPOTENCY_BYTES {
357            return Err(PermitIdempotencyKeyError::TooLong);
358        }
359        let mut nonzero = Choice::from(0);
360        for byte in value {
361            nonzero |= !byte.ct_eq(&0);
362        }
363        if !bool::from(nonzero) {
364            return Err(PermitIdempotencyKeyError::AllZero);
365        }
366        Ok(Self(value))
367    }
368
369    pub(crate) const fn bytes(self) -> &'a [u8] {
370        self.0
371    }
372
373    pub(crate) fn matches(self, other: Self) -> bool {
374        self.0.len() == other.0.len() && bool::from(self.0.ct_eq(other.0))
375    }
376}
377
378impl fmt::Debug for PermitIdempotencyKey<'_> {
379    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
380        formatter.write_str("PermitIdempotencyKey([redacted])")
381    }
382}
383
384/// Observable permit lifecycle state.
385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
386pub enum PermitState {
387    /// Ready to authorize one attempt.
388    Ready,
389    /// One caller currently owns the attempt.
390    InFlight,
391    /// A proven-not-sent attempt awaits generation-bound recovery.
392    Recoverable,
393    /// Delivery may have happened and operation-specific reconciliation is required.
394    PendingReconciliation,
395    /// Authority is permanently spent.
396    Spent,
397}
398
399/// Generation-bound recovery authority returned only for `NotSent`.
400#[derive(Clone, Copy, Debug, Eq, PartialEq)]
401pub struct RecoveryToken(pub(crate) u16);
402
403/// Generation-bound reconciliation authority returned after uncertain delivery.
404#[derive(Clone, Copy, Debug, Eq, PartialEq)]
405pub struct ReconciliationToken(pub(crate) u16);
406
407/// State transition produced by one completed or failed attempt.
408#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub enum PermitDisposition {
410    /// Authority is permanently spent.
411    Spent,
412    /// A proven-not-sent attempt may be explicitly recovered.
413    Recoverable(RecoveryToken),
414    /// Operation-specific reconciliation is required.
415    PendingReconciliation(ReconciliationToken),
416}
417
418/// Permit authorization or lifecycle failure.
419#[derive(Clone, Copy, Debug, Eq, PartialEq)]
420pub enum ExecutionPermitError {
421    /// The plan scope does not match the permit type.
422    ScopeMismatch,
423    /// The permit is not valid yet.
424    NotYetValid,
425    /// The permit has expired.
426    Expired,
427    /// Caller time moved backward.
428    ClockRollback,
429    /// Another caller owns the only in-flight attempt.
430    AttemptInFlight,
431    /// The permit needs explicit not-sent recovery.
432    RecoveryRequired,
433    /// The permit needs operation-specific reconciliation.
434    ReconciliationRequired,
435    /// The authority or attempt budget is spent.
436    Spent,
437    /// Recovery or reconciliation belongs to another generation.
438    StaleGeneration,
439    /// The supplied request fingerprint differs from the confirmed plan.
440    FingerprintMismatch,
441    /// Reconciliation used a different idempotency identity.
442    IdempotencyMismatch,
443    /// The selected replay policy forbids this transition.
444    ReplayForbidden,
445    /// Atomic generation capacity is exhausted.
446    GenerationExhausted,
447}
448
449impl_static_error!(ExecutionPermitError,
450    Self::ScopeMismatch => "execution permit scope does not match the plan",
451    Self::NotYetValid => "execution permit is not valid yet",
452    Self::Expired => "execution permit has expired",
453    Self::ClockRollback => "execution permit clock moved backward",
454    Self::AttemptInFlight => "execution permit already has an in-flight attempt",
455    Self::RecoveryRequired => "execution permit requires not-sent recovery",
456    Self::ReconciliationRequired => "execution permit requires reconciliation",
457    Self::Spent => "execution permit is spent",
458    Self::StaleGeneration => "execution permit generation is stale",
459    Self::FingerprintMismatch => "execution permit fingerprint does not match",
460    Self::IdempotencyMismatch => "execution permit idempotency identity does not match",
461    Self::ReplayForbidden => "execution permit replay policy forbids repetition",
462    Self::GenerationExhausted => "execution permit generation is exhausted",
463);
464
465#[cfg(test)]
466mod tests;