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}