slipstream/protocol.rs
1//! Protocol decision kernels — the load-bearing guards of the snapshot
2//! export/import protocol, extracted as pure functions so the PRODUCTION
3//! code and the exhaustive model checker (`tests/model.rs`) execute the
4//! **same logic**, not two hand-synchronized copies of it.
5//!
6//! Every function here is a guard whose correctness the machine-checked
7//! theorems depend on. The call sites:
8//!
9//! | kernel | production | model |
10//! |----------------------------|-------------------------------------|--------------------|
11//! | [`pointer_publish_allowed`]| `transport::swap_pointer` | `Act::Publish` |
12//! | [`payload_prunable`] | `transport::ObjectStoreTransport::prune` | `Act::Prune` |
13//! | [`resume_window_ok`] | `nats` resume paths (`check_resume_window`) | `Act::Resume` |
14//!
15//! Because the model transitions call these very functions, a change to any
16//! guard is re-verified against the full bounded state space on the next
17//! `cargo test --test model` — the guards cannot drift from the proof. The
18//! mutation tests in `tests/model.rs` additionally prove each guard is
19//! load-bearing: substituting a broken variant makes the checker produce a
20//! counterexample.
21//!
22//! Kernels operate on plain `u64` ranks (a [`WatchCursor`](crate::WatchCursor)'s
23//! revision, with revisionless cursors ranked 0 by the callers) so they stay
24//! free of I/O types and usable from the checker's `u8`-bounded state space.
25
26/// What the publisher observed at the pointer key before deciding.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PointerState {
29 /// No pointer object exists — the slot is open (create-only publish).
30 Absent,
31 /// A pointer object exists. `rank` is its cursor's rank, or `None` when
32 /// the object is unparseable — a corrupt pointer MUST be replaceable, or
33 /// one bad write wedges publishing forever (the same rule as the export
34 /// lease's corrupt-steal).
35 Present {
36 /// Rank of the existing pointer's cursor; `None` if unparseable.
37 rank: Option<u64>,
38 },
39}
40
41/// THE monotonic pointer guard: may `candidate_rank` be published over the
42/// observed `current` pointer?
43///
44/// `true` for an open slot, a corrupt pointer, or a candidate at or above
45/// the existing cursor; `false` exactly when the existing pointer is
46/// parseable and STRICTLY newer — the refusal that makes a slow exporter's
47/// stale publish a no-op instead of a regression.
48///
49/// Soundness of deciding on a read (before the conditional put): every
50/// writer uses this guard with a compare-and-swap, so the pointer's rank is
51/// monotone non-decreasing — once "strictly newer" is observed, it can never
52/// become false, so refusal needs no CAS. Machine-checked as `published
53/// cursor never regresses` in `tests/model.rs`.
54pub fn pointer_publish_allowed(current: &PointerState, candidate_rank: u64) -> bool {
55 match current {
56 PointerState::Absent => true,
57 PointerState::Present { rank: None } => true,
58 PointerState::Present {
59 rank: Some(existing),
60 } => candidate_rank >= *existing,
61 }
62}
63
64/// THE prune guard: may this payload object be deleted, given the current
65/// pointer's rank?
66///
67/// A payload is prunable only when ALL hold:
68/// - it is not the pointer's own target;
69/// - its rank is parseable AND **strictly below** the pointer's (an
70/// unparseable rank is never deleted — unknown objects are not ours);
71/// - its age has cleared the grace period (`aged_out`; the model passes
72/// `true`, checking the harshest zero-grace timing).
73///
74/// Strictly-below is what makes a dangling pointer impossible regardless of
75/// timing: [`pointer_publish_allowed`] refuses any candidate below the
76/// pointer, and the pointer is monotone — so anything this guard deletes
77/// (rank < pointer-at-prune ≤ pointer-at-any-later-swap) can never be
78/// successfully published afterward. The model checker FOUND the dangling
79/// counterexample under the earlier age-only rule (a same-cursor payload
80/// collected mid-publish, then published by the `>=` swap guard); this rule
81/// is the structural fix, machine-checked as `pointer target always
82/// fetchable` under zero-grace pruning.
83pub fn payload_prunable(
84 payload_rank: Option<u64>,
85 pointer_rank: u64,
86 is_pointer_target: bool,
87 aged_out: bool,
88) -> bool {
89 !is_pointer_target && aged_out && payload_rank.is_some_and(|rank| rank < pointer_rank)
90}
91
92/// THE cursor-expiry guard: is resuming from `revision` sound, given the
93/// stream's first retained sequence?
94///
95/// A resume reads `revision + 1` onward; it is sound iff nothing at or below
96/// `revision + 1`'s predecessor gap has been head-evicted — i.e.
97/// `first_sequence <= revision + 1`. Interior (per-subject) eviction inside
98/// the gap is safe for a last-write-wins fold (an overwrite-evicted revision
99/// implies a later revision of the same subject exists and will be
100/// delivered); lost DELETES come from head eviction, which is exactly what
101/// advances `first_sequence`.
102///
103/// This check must be performed by US: NATS does not error on a below-head
104/// start sequence — it silently clamps to the first retained message
105/// (pinned live by `tests/resync.rs::nats_silently_clamps_resume_below_first_seq`),
106/// which would skip the gap's evicted delete markers with no fallback and no
107/// resync. Machine-checked as `bootstrap never silently diverges` in
108/// `tests/model.rs` (where the model's retention floor is
109/// `first_sequence - 1`).
110pub fn resume_window_ok(revision: u64, first_sequence: u64) -> bool {
111 first_sequence <= revision.saturating_add(1)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn publish_guard_boundaries() {
120 let absent = PointerState::Absent;
121 let corrupt = PointerState::Present { rank: None };
122 let at = |r| PointerState::Present { rank: Some(r) };
123 assert!(pointer_publish_allowed(&absent, 0));
124 assert!(pointer_publish_allowed(&corrupt, 0));
125 assert!(pointer_publish_allowed(&at(5), 5), "equal republishes");
126 assert!(pointer_publish_allowed(&at(5), 6));
127 assert!(!pointer_publish_allowed(&at(5), 4), "stale is refused");
128 }
129
130 #[test]
131 fn prune_guard_boundaries() {
132 // Strictly below, aged, not the target: prunable.
133 assert!(payload_prunable(Some(4), 5, false, true));
134 // Equal rank is NOT prunable — it is still publishable (>= guard).
135 assert!(!payload_prunable(Some(5), 5, false, true));
136 assert!(!payload_prunable(Some(6), 5, false, true));
137 // The pointer's own target and unparseable ranks are never prunable.
138 assert!(!payload_prunable(Some(4), 5, true, true));
139 assert!(!payload_prunable(None, 5, false, true));
140 // Grace window holds everything.
141 assert!(!payload_prunable(Some(4), 5, false, false));
142 }
143
144 #[test]
145 fn resume_guard_boundaries() {
146 assert!(resume_window_ok(3, 4), "first retained == next read: sound");
147 assert!(resume_window_ok(3, 1), "history intact");
148 assert!(!resume_window_ok(3, 5), "gap head-evicted: expired");
149 assert!(resume_window_ok(u64::MAX, u64::MAX), "saturating boundary");
150 }
151}