Skip to main content

riffdb_types/
freshness.rs

1//! Opaque commit tokens, projection frontiers, and freshness policies (ADR-0086 §3).
2//!
3//! Wire carriage of these types is a separate protocol concern. This module owns
4//! only semantic identities. V2 binds the database as well as its incarnation;
5//! V1 remains readable for primary-only legacy compatibility.
6
7use std::cmp::Ordering;
8use std::error::Error;
9use std::fmt;
10use std::hash::{Hash, Hasher};
11use std::time::Duration;
12
13use crate::{CommitSequence, DatabaseId, FrontierPosition};
14
15/// Opaque encoding version for [`CommitToken`] and [`ProjectionFrontier`] bytes.
16const FRESHNESS_BYTES_V1: u8 = 0x01;
17/// Database-bound successor; V1 payloads remain byte-exact.
18const FRESHNESS_BYTES_V2: u8 = 0x02;
19
20/// Position tag: no commit has been applied.
21const POSITION_BEFORE_FIRST: u8 = 0x00;
22/// Position tag: applied through an exact nonzero commit sequence.
23const POSITION_APPLIED_THROUGH: u8 = 0x01;
24
25/// An opaque, scoped commit identity used as a causal freshness fence.
26///
27/// V2 binds `(database_id, history_incarnation, commit_sequence)`. Public diagnostics redact
28/// the payload; callers that need wire bytes use [`CommitToken::as_bytes`].
29#[derive(Clone)]
30pub struct CommitToken {
31    bytes: Vec<u8>,
32    database_id: Option<DatabaseId>,
33    history_incarnation: u64,
34    commit_sequence: CommitSequence,
35}
36
37impl CommitToken {
38    /// Builds a legacy V1 token with no database scope. Followers refuse V1.
39    #[must_use]
40    pub fn new(history_incarnation: u64, commit_sequence: CommitSequence) -> Self {
41        let mut bytes = Vec::with_capacity(1 + 8 + 1 + 8);
42        bytes.push(FRESHNESS_BYTES_V1);
43        bytes.extend_from_slice(&history_incarnation.to_be_bytes());
44        bytes.push(POSITION_APPLIED_THROUGH);
45        bytes.extend_from_slice(&commit_sequence.to_be_bytes());
46        Self {
47            bytes,
48            database_id: None,
49            history_incarnation,
50            commit_sequence,
51        }
52    }
53
54    /// Builds a V2 token bound to one database and exact history position.
55    #[must_use]
56    pub fn new_scoped(
57        database_id: DatabaseId,
58        history_incarnation: u64,
59        commit_sequence: CommitSequence,
60    ) -> Self {
61        let mut token = Self::new(history_incarnation, commit_sequence);
62        token.bytes = scoped_bytes(database_id, &token.bytes);
63        token.database_id = Some(database_id);
64        token
65    }
66
67    /// Parses opaque token bytes produced by [`Self::as_bytes`].
68    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FreshnessTokenError> {
69        let (database_id, history_incarnation, position) = decode_frontier_payload(&bytes)?;
70        let FrontierPosition::AppliedThrough(commit_sequence) = position else {
71            return Err(FreshnessTokenError::InvalidShape);
72        };
73        Ok(Self {
74            bytes,
75            database_id,
76            history_incarnation,
77            commit_sequence,
78        })
79    }
80
81    /// Database scope; absent only on legacy V1 tokens.
82    #[must_use]
83    pub const fn database_id(&self) -> Option<DatabaseId> {
84        self.database_id
85    }
86
87    /// The exact required frontier, preserving the complete token scope.
88    #[must_use]
89    pub fn frontier(&self) -> ProjectionFrontier {
90        ProjectionFrontier {
91            bytes: self.bytes.clone(),
92            database_id: self.database_id,
93            history_incarnation: self.history_incarnation,
94            position: FrontierPosition::AppliedThrough(self.commit_sequence),
95        }
96    }
97
98    /// History incarnation bound into the token.
99    #[must_use]
100    pub const fn history_incarnation(&self) -> u64 {
101        self.history_incarnation
102    }
103
104    /// Commit sequence bound into the token.
105    #[must_use]
106    pub const fn commit_sequence(&self) -> CommitSequence {
107        self.commit_sequence
108    }
109
110    /// Opaque bytes for wire carriage (CP2b protocol) and storage.
111    #[must_use]
112    pub fn as_bytes(&self) -> &[u8] {
113        &self.bytes
114    }
115
116    /// Consumes the token and returns its opaque bytes.
117    #[must_use]
118    pub fn into_bytes(self) -> Vec<u8> {
119        self.bytes
120    }
121}
122
123/// An opaque, scoped projection visibility frontier.
124///
125/// V2 binds `(database_id, history_incarnation, FrontierPosition)`. Comparison and
126/// [`ProjectionFrontier::satisfies`] require equal scopes; a restore that
127/// bumps the incarnation can never satisfy a pre-restore causal token.
128#[derive(Clone)]
129pub struct ProjectionFrontier {
130    bytes: Vec<u8>,
131    database_id: Option<DatabaseId>,
132    history_incarnation: u64,
133    position: FrontierPosition,
134}
135
136impl ProjectionFrontier {
137    /// Builds an engine-local or legacy V1 frontier with no database scope.
138    #[must_use]
139    pub fn new(history_incarnation: u64, position: FrontierPosition) -> Self {
140        let mut bytes = Vec::with_capacity(1 + 8 + 1 + 8);
141        bytes.push(FRESHNESS_BYTES_V1);
142        bytes.extend_from_slice(&history_incarnation.to_be_bytes());
143        match position {
144            FrontierPosition::BeforeFirst => {
145                bytes.push(POSITION_BEFORE_FIRST);
146            }
147            FrontierPosition::AppliedThrough(sequence) => {
148                bytes.push(POSITION_APPLIED_THROUGH);
149                bytes.extend_from_slice(&sequence.to_be_bytes());
150            }
151        }
152        Self {
153            bytes,
154            database_id: None,
155            history_incarnation,
156            position,
157        }
158    }
159
160    /// Builds a V2 frontier bound to one database and history incarnation.
161    #[must_use]
162    pub fn new_scoped(
163        database_id: DatabaseId,
164        history_incarnation: u64,
165        position: FrontierPosition,
166    ) -> Self {
167        let mut frontier = Self::new(history_incarnation, position);
168        frontier.bytes = scoped_bytes(database_id, &frontier.bytes);
169        frontier.database_id = Some(database_id);
170        frontier
171    }
172
173    /// Parses opaque frontier bytes produced by [`Self::as_bytes`].
174    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FreshnessTokenError> {
175        let (database_id, history_incarnation, position) = decode_frontier_payload(&bytes)?;
176        Ok(Self {
177            bytes,
178            database_id,
179            history_incarnation,
180            position,
181        })
182    }
183
184    /// Database scope; absent only on legacy V1 frontiers.
185    #[must_use]
186    pub const fn database_id(&self) -> Option<DatabaseId> {
187        self.database_id
188    }
189
190    /// Returns a token for an applied position without losing its scope.
191    #[must_use]
192    pub fn commit_token(&self) -> Option<CommitToken> {
193        let FrontierPosition::AppliedThrough(commit_sequence) = self.position else {
194            return None;
195        };
196        Some(CommitToken {
197            bytes: self.bytes.clone(),
198            database_id: self.database_id,
199            history_incarnation: self.history_incarnation,
200            commit_sequence,
201        })
202    }
203
204    /// History incarnation bound into the frontier.
205    #[must_use]
206    pub const fn history_incarnation(&self) -> u64 {
207        self.history_incarnation
208    }
209
210    /// Sequence position of the frontier (may be [`FrontierPosition::BeforeFirst`]).
211    #[must_use]
212    pub const fn position(&self) -> FrontierPosition {
213        self.position
214    }
215
216    /// Opaque bytes for wire carriage (CP2b protocol) and storage.
217    #[must_use]
218    pub fn as_bytes(&self) -> &[u8] {
219        &self.bytes
220    }
221
222    /// Consumes the frontier and returns its opaque bytes.
223    #[must_use]
224    pub fn into_bytes(self) -> Vec<u8> {
225        self.bytes
226    }
227
228    /// Whether this frontier covers `required` under exactly the same scope.
229    ///
230    /// Database, version-scope, or incarnation mismatch always fails closed.
231    #[must_use]
232    pub fn satisfies(&self, required: &CommitToken) -> bool {
233        if self.database_id != required.database_id
234            || self.history_incarnation != required.history_incarnation
235        {
236            return false;
237        }
238        match self.position {
239            FrontierPosition::BeforeFirst => false,
240            FrontierPosition::AppliedThrough(current) => {
241                current.get() >= required.commit_sequence.get()
242            }
243        }
244    }
245
246    /// Sequence distance from this frontier to `head` when both share an
247    /// database scope and incarnation and are sequenced.
248    #[must_use]
249    pub fn lag_sequences(&self, head: &Self) -> Option<u64> {
250        if self.database_id != head.database_id
251            || self.history_incarnation != head.history_incarnation
252        {
253            return None;
254        }
255        match (self.position, head.position) {
256            (FrontierPosition::BeforeFirst, FrontierPosition::AppliedThrough(head_seq)) => {
257                Some(head_seq.get())
258            }
259            (
260                FrontierPosition::AppliedThrough(current_seq),
261                FrontierPosition::AppliedThrough(head_seq),
262            ) => head_seq.get().checked_sub(current_seq.get()),
263            _ => None,
264        }
265    }
266}
267
268/// Projected-query freshness policy (ADR-0086 §3).
269///
270/// Wait/retry loops and wall clocks live outside the deterministic engine; the
271/// policy is a pure data shape the service layer interprets.
272#[derive(Clone, Debug, Eq, PartialEq)]
273pub enum FreshnessPolicy {
274    /// Serve only once the projection frontier satisfies `token`.
275    Causal {
276        /// Required causal fence.
277        token: CommitToken,
278        /// Maximum wait budget interpreted by the service wait loop.
279        max_wait: Duration,
280    },
281    /// Serve only if head-to-frontier lag is within `max_lag_sequences`.
282    Bounded {
283        /// Maximum allowed sequence distance (head − frontier).
284        max_lag_sequences: u64,
285    },
286    /// Serve the current published snapshot, reporting its frontier.
287    Available,
288}
289
290/// Safe failure to construct or decode a freshness token or frontier.
291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
292pub enum FreshnessTokenError {
293    /// Bytes are truncated or carry a trailing suffix.
294    TruncatedOrTrailing,
295    /// Version, position tag, or sequence shape is invalid.
296    InvalidShape,
297    /// Commit sequence was the zero sentinel.
298    ZeroCommitSequence,
299}
300
301impl fmt::Display for FreshnessTokenError {
302    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303        formatter.write_str(match self {
304            Self::TruncatedOrTrailing => "freshness token bytes are truncated or trailing",
305            Self::InvalidShape => "freshness token bytes have an invalid shape",
306            Self::ZeroCommitSequence => "commit sequence must be nonzero",
307        })
308    }
309}
310
311impl Error for FreshnessTokenError {}
312
313fn scoped_bytes(database_id: DatabaseId, legacy: &[u8]) -> Vec<u8> {
314    let mut bytes = Vec::with_capacity(legacy.len() + 16);
315    bytes.push(FRESHNESS_BYTES_V2);
316    bytes.extend_from_slice(database_id.as_bytes());
317    bytes.extend_from_slice(&legacy[1..]);
318    bytes
319}
320
321fn decode_frontier_payload(
322    bytes: &[u8],
323) -> Result<(Option<DatabaseId>, u64, FrontierPosition), FreshnessTokenError> {
324    let (database_id, payload) = match bytes.first() {
325        Some(&FRESHNESS_BYTES_V1) => (None, &bytes[1..]),
326        Some(&FRESHNESS_BYTES_V2) => {
327            if bytes.len() < 17 {
328                return Err(FreshnessTokenError::TruncatedOrTrailing);
329            }
330            let database = DatabaseId::from_bytes(bytes[1..17].try_into().expect("length checked"))
331                .map_err(|_| FreshnessTokenError::InvalidShape)?;
332            (Some(database), &bytes[17..])
333        }
334        None => return Err(FreshnessTokenError::TruncatedOrTrailing),
335        _ => return Err(FreshnessTokenError::InvalidShape),
336    };
337    if payload.len() < 8 + 1 {
338        return Err(FreshnessTokenError::TruncatedOrTrailing);
339    }
340    let history_incarnation = u64::from_be_bytes(payload[..8].try_into().expect("length checked"));
341    match payload[8] {
342        POSITION_BEFORE_FIRST => {
343            if payload.len() != 9 {
344                return Err(FreshnessTokenError::TruncatedOrTrailing);
345            }
346            Ok((
347                database_id,
348                history_incarnation,
349                FrontierPosition::BeforeFirst,
350            ))
351        }
352        POSITION_APPLIED_THROUGH => {
353            if payload.len() != 17 {
354                return Err(FreshnessTokenError::TruncatedOrTrailing);
355            }
356            let sequence = u64::from_be_bytes(payload[9..17].try_into().expect("length checked"));
357            let commit_sequence =
358                CommitSequence::new(sequence).ok_or(FreshnessTokenError::ZeroCommitSequence)?;
359            Ok((
360                database_id,
361                history_incarnation,
362                FrontierPosition::AppliedThrough(commit_sequence),
363            ))
364        }
365        _ => Err(FreshnessTokenError::InvalidShape),
366    }
367}
368
369macro_rules! freshness_byte_traits {
370    ($type:ty) => {
371        impl PartialEq for $type {
372            fn eq(&self, other: &Self) -> bool {
373                self.bytes == other.bytes
374            }
375        }
376
377        impl Eq for $type {}
378
379        impl PartialOrd for $type {
380            fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
381                Some(self.cmp(other))
382            }
383        }
384
385        impl Ord for $type {
386            fn cmp(&self, other: &Self) -> Ordering {
387                self.bytes.cmp(&other.bytes)
388            }
389        }
390
391        impl Hash for $type {
392            fn hash<H: Hasher>(&self, state: &mut H) {
393                self.bytes.hash(state);
394            }
395        }
396    };
397}
398
399freshness_byte_traits!(CommitToken);
400freshness_byte_traits!(ProjectionFrontier);
401
402impl fmt::Debug for CommitToken {
403    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
404        formatter
405            .debug_struct("CommitToken")
406            .field("bytes", &"[REDACTED]")
407            .field("length", &self.bytes.len())
408            .finish()
409    }
410}
411
412impl fmt::Debug for ProjectionFrontier {
413    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
414        formatter
415            .debug_struct("ProjectionFrontier")
416            .field("bytes", &"[REDACTED]")
417            .field("length", &self.bytes.len())
418            .finish()
419    }
420}
421
422impl TryFrom<Vec<u8>> for CommitToken {
423    type Error = FreshnessTokenError;
424
425    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
426        Self::from_bytes(value)
427    }
428}
429
430impl TryFrom<Vec<u8>> for ProjectionFrontier {
431    type Error = FreshnessTokenError;
432
433    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
434        Self::from_bytes(value)
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn seq(value: u64) -> CommitSequence {
443        CommitSequence::new(value).expect("nonzero")
444    }
445
446    fn database(seed: u8) -> DatabaseId {
447        DatabaseId::from_unix_milliseconds_and_random(42, [seed; 10]).unwrap()
448    }
449
450    // req: REP-004
451    #[test]
452    fn scoped_freshness_v2_preserves_golden_v1_and_v2_bytes() {
453        let legacy = CommitToken::new(3, seq(42));
454        let token = CommitToken::new_scoped(database(1), 3, seq(42));
455        let before = ProjectionFrontier::new_scoped(database(1), 3, FrontierPosition::BeforeFirst);
456        let fixtures = include_str!("../../../fixtures/freshness/opaque-v1-v2.txt")
457            .lines()
458            .collect::<Vec<_>>();
459        let hex = |bytes: &[u8]| {
460            bytes
461                .iter()
462                .map(|byte| format!("{byte:02x}"))
463                .collect::<String>()
464        };
465        assert_eq!(hex(legacy.as_bytes()), fixtures[0]);
466        assert_eq!(hex(token.as_bytes()), fixtures[1]);
467        assert_eq!(hex(before.as_bytes()), fixtures[2]);
468        for expected in [legacy, token] {
469            let decoded = CommitToken::from_bytes(expected.as_bytes().to_vec()).unwrap();
470            assert_eq!(decoded, expected);
471            let frontier = decoded.frontier();
472            assert_eq!(frontier.commit_token().unwrap(), decoded);
473            assert_eq!(
474                ProjectionFrontier::from_bytes(frontier.as_bytes().to_vec()).unwrap(),
475                frontier
476            );
477            assert!(frontier.satisfies(&decoded));
478        }
479        assert_eq!(
480            ProjectionFrontier::from_bytes(before.as_bytes().to_vec()).unwrap(),
481            before
482        );
483        assert!(before.commit_token().is_none());
484        assert!(CommitToken::from_bytes(before.into_bytes()).is_err());
485    }
486
487    // req: REP-004
488    #[test]
489    fn scoped_freshness_refuses_foreign_database_incarnation_and_unbound_v1() {
490        let token = CommitToken::new_scoped(database(1), 3, seq(42));
491        let frontier = token.frontier();
492        for other in [
493            CommitToken::new_scoped(database(2), 3, seq(42)),
494            CommitToken::new_scoped(database(1), 4, seq(42)),
495            CommitToken::new(3, seq(42)),
496        ] {
497            assert_ne!(token, other);
498            assert!(!frontier.satisfies(&other));
499            assert!(!other.frontier().satisfies(&token));
500            assert_eq!(frontier.lag_sequences(&other.frontier()), None);
501        }
502        assert!(frontier.satisfies(&CommitToken::new_scoped(database(1), 3, seq(41))));
503        assert!(!frontier.satisfies(&CommitToken::new_scoped(database(1), 3, seq(43))));
504        assert_eq!(
505            format!("{token:?}"),
506            "CommitToken { bytes: \"[REDACTED]\", length: 34 }"
507        );
508    }
509
510    // req: REP-004
511    #[test]
512    fn scoped_freshness_decoder_refuses_every_truncation_and_noncanonical_shape() {
513        let bytes = CommitToken::new_scoped(database(1), 3, seq(42)).into_bytes();
514        for length in 0..bytes.len() {
515            assert!(CommitToken::from_bytes(bytes[..length].to_vec()).is_err());
516            assert!(ProjectionFrontier::from_bytes(bytes[..length].to_vec()).is_err());
517        }
518        let mut trailing = bytes.clone();
519        trailing.push(0);
520        assert!(CommitToken::from_bytes(trailing.clone()).is_err());
521        assert!(ProjectionFrontier::from_bytes(trailing).is_err());
522        for (offset, value) in [(0, 3), (7, 0), (9, 0), (25, 2), (33, 0)] {
523            let mut corrupt = bytes.clone();
524            corrupt[offset] = value;
525            assert!(CommitToken::from_bytes(corrupt.clone()).is_err());
526            assert!(ProjectionFrontier::from_bytes(corrupt).is_err());
527        }
528    }
529
530    #[test]
531    fn commit_token_round_trips_opaque_bytes() {
532        let token = CommitToken::new(3, seq(42));
533        let decoded = CommitToken::from_bytes(token.as_bytes().to_vec()).expect("decode");
534        assert_eq!(decoded.history_incarnation(), 3);
535        assert_eq!(decoded.commit_sequence(), seq(42));
536        assert_eq!(decoded, token);
537        let debug = format!("{token:?}");
538        assert!(debug.contains("REDACTED"));
539        assert!(!debug.contains("42"));
540    }
541
542    #[test]
543    fn projection_frontier_round_trips_before_first_and_applied() {
544        let before = ProjectionFrontier::new(1, FrontierPosition::BeforeFirst);
545        let decoded = ProjectionFrontier::from_bytes(before.as_bytes().to_vec()).expect("decode");
546        assert_eq!(decoded.position(), FrontierPosition::BeforeFirst);
547        assert_eq!(decoded.history_incarnation(), 1);
548
549        let applied = ProjectionFrontier::new(2, FrontierPosition::AppliedThrough(seq(9)));
550        let decoded = ProjectionFrontier::from_bytes(applied.as_bytes().to_vec()).expect("decode");
551        assert_eq!(decoded.position(), FrontierPosition::AppliedThrough(seq(9)));
552        assert_eq!(decoded, applied);
553    }
554
555    #[test]
556    fn frontier_satisfies_requires_matching_incarnation() {
557        let frontier = ProjectionFrontier::new(1, FrontierPosition::AppliedThrough(seq(10)));
558        let same = CommitToken::new(1, seq(10));
559        let earlier = CommitToken::new(1, seq(7));
560        let later = CommitToken::new(1, seq(11));
561        let restored = CommitToken::new(2, seq(5));
562
563        assert!(frontier.satisfies(&same));
564        assert!(frontier.satisfies(&earlier));
565        assert!(!frontier.satisfies(&later));
566        // Falsifiable: dropping incarnation from the comparison would make this pass.
567        assert!(
568            !frontier.satisfies(&restored),
569            "stale pre-restore token must not satisfy a new incarnation frontier"
570        );
571    }
572
573    #[test]
574    fn lag_sequences_is_none_across_incarnations() {
575        let current = ProjectionFrontier::new(1, FrontierPosition::AppliedThrough(seq(3)));
576        let head = ProjectionFrontier::new(1, FrontierPosition::AppliedThrough(seq(10)));
577        assert_eq!(current.lag_sequences(&head), Some(7));
578        let other = ProjectionFrontier::new(2, FrontierPosition::AppliedThrough(seq(10)));
579        assert_eq!(current.lag_sequences(&other), None);
580    }
581}