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