Skip to main content

newt_interaction/
resolution.rs

1//! **Exactly-once resolution: the contract, not the storage** (A3, #1837).
2//!
3//! The abstract shape lives here because it is a set of function
4//! signatures over types this crate already owns, which costs nothing
5//! against the dependency guard. The only implementation that needs a
6//! database lives in `newt-core`, which is the layer that has one.
7//!
8//! **This contract does not mandate a SQL shape.** The store in
9//! `newt-core` resolves through a rowcount CAS inside an `Immediate`
10//! transaction, but that is a property of *that* implementation — the
11//! serialization it needs comes from SQLite's own locking across
12//! independent connections, not from anything stated here. An in-memory
13//! implementation may legitimately collapse the whole thing to one
14//! compare-and-swap. What every implementation must preserve is the
15//! observable contract below.
16//!
17//! **Idempotency keys are a promise, and a violated promise is an
18//! error.** A key says "this is the same submission I already sent". If
19//! two DIFFERENT responses arrive under one key, honouring the first and
20//! silently discarding the second would let a retry substitute a
21//! different answer — an authorization-relevant substitution disguised as
22//! a network retry. So that case is
23//! [`ResolutionError::IdempotencyConflict`], not a quiet first-wins. A
24//! genuine retry — the same response bytes, hence the same
25//! [`ResponseId`] — collapses to [`Resolution::Replayed`].
26
27use thiserror::Error;
28
29use crate::ids::{IdempotencyKey, InstanceId, ResponseId};
30
31/// What a caller asks a store to persist: this response, resolving this
32/// offer, under this key.
33///
34/// Ids only. The store is a resolver, not a second copy of the record.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ResolutionRecord {
37    /// The offer being resolved.
38    pub instance: InstanceId,
39    /// The response resolving it — already validated by
40    /// [`validate_response`](crate::binding::validate_response).
41    pub response: ResponseId,
42    /// The caller's replay key for this submission.
43    pub idempotency_key: IdempotencyKey,
44}
45
46/// The outcome of an attempt to resolve an offer.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Resolution {
49    /// This response resolved the offer. Exactly one attempt per offer
50    /// ever sees this.
51    Won,
52    /// The offer was already resolved by a different response. The loser
53    /// is told WHO won, so every racer observes the same terminal fact
54    /// rather than a bare failure.
55    Lost {
56        /// The response that resolved the offer.
57        winner: ResponseId,
58    },
59    /// The same submission arrived again. Not a second resolution.
60    Replayed {
61        /// The response that resolved the offer — the same one.
62        winner: ResponseId,
63    },
64}
65
66/// The facts of an idempotency-key collision.
67///
68/// Boxed into [`ResolutionError`] rather than inlined: two content ids
69/// and a key would make every `Result` in this module carry the conflict
70/// case's width on the success path too.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct IdempotencyConflict {
73    /// The reused key.
74    pub key: String,
75    /// The response that used it first.
76    pub existing: ResponseId,
77    /// The different response presented under it.
78    pub presented: ResponseId,
79}
80
81/// Why a resolution attempt could not be carried out.
82///
83/// Generic over the implementation's own error so the contract does not
84/// drag a storage error type into this crate.
85#[derive(Debug, Error)]
86pub enum ResolutionError<E> {
87    /// Two different submissions were presented under one idempotency
88    /// key. Refused rather than resolved: see the module docs.
89    #[error(
90        "idempotency key `{}` was already used by response `{}`, \
91         but `{}` is a different submission",
92        .0.key, .0.existing, .0.presented
93    )]
94    IdempotencyConflict(Box<IdempotencyConflict>),
95    /// The implementation failed.
96    #[error("resolution store failed: {0}")]
97    Store(E),
98}
99
100/// Persisting the exactly-once decision.
101///
102/// One offer resolves at most once. Every implementation must satisfy:
103///
104/// - the FIRST valid response for an offer returns [`Resolution::Won`];
105/// - any later, different response returns [`Resolution::Lost`] naming
106///   the winner — never a second `Won`, and never a silent success;
107/// - the same response under the same key returns
108///   [`Resolution::Replayed`], leaving the winner unchanged;
109/// - a different response under an already-used key is
110///   [`ResolutionError::IdempotencyConflict`];
111/// - concurrent attempts from independent connections settle on ONE
112///   winner, and every loser observes that same winner.
113pub trait ResolutionStore {
114    /// The implementation's own failure type.
115    type Error;
116
117    /// Attempt to resolve an offer, exactly once.
118    ///
119    /// # Errors
120    ///
121    /// [`ResolutionError::IdempotencyConflict`] when a key is reused for
122    /// a different submission, or [`ResolutionError::Store`] when the
123    /// implementation itself fails.
124    fn resolve(
125        &self,
126        record: &ResolutionRecord,
127    ) -> Result<Resolution, ResolutionError<Self::Error>>;
128
129    /// Who resolved this offer, if anyone has.
130    ///
131    /// # Errors
132    ///
133    /// The implementation's own failure.
134    fn winner(&self, instance: &InstanceId) -> Result<Option<ResponseId>, Self::Error>;
135}