Skip to main content

dk_protocol/
stale_overlay.rs

1//! STALE_OVERLAY pre-write policy.
2//!
3//! Locks now release at `dk_submit` (default on; opt out with
4//! `DKOD_RELEASE_ON_SUBMIT=0`), so a waiting session can acquire a
5//! contested symbol seconds after the holder submits — far sooner than the
6//! old "locks release at merge" window. The recovery contract tells agents
7//! to re-read the file before writing, but if they skip that step their
8//! overlay is still pinned to `base_commit` and they will silently clobber
9//! the submitted (but not-yet-merged) overlay from the other session.
10//!
11//! This module is the engine-side backstop. It is deliberately pure so it
12//! can be unit-tested without Postgres; the live handler is a thin wrapper
13//! around a `ChangesetStore` query and a call into [`is_stale`].
14
15use chrono::{DateTime, Utc};
16use uuid::Uuid;
17
18/// Minimal view of a submitted-but-not-merged changeset that touched the
19/// same file path as the write we're about to perform.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CompetingChangeset {
22    pub changeset_id: Uuid,
23    pub session_id: Option<Uuid>,
24    pub state: String,
25    pub updated_at: DateTime<Utc>,
26}
27
28/// States treated as "live" — a changeset in any of these states may still
29/// affect an overlay this write is about to clobber.
30///
31/// `draft` is excluded because drafts are session-local and cannot have been
32/// observed by another session. `merged` is excluded because once a change
33/// is on main, the AST merger at `dk_merge` is the correct layer to
34/// reconcile it — STALE firing there would be a false positive. `rejected`
35/// and `closed` are inert.
36pub const LIVE_STATES: &[&str] = &["submitted", "verifying", "approved"];
37
38/// Return the first competitor that makes the session's local view of the
39/// file stale, or `None` if the session is safe to write.
40///
41/// A competitor counts as stale when **all** of the following hold:
42/// - its `session_id` differs from `session_id` (self-amend is never stale
43///   against itself — note that in PR1 one session owns at most one
44///   submitted changeset, so this degenerates to "same session");
45/// - its `state` is in [`LIVE_STATES`];
46/// - either the session has never read the path (`last_read.is_none()`), or
47///   the competitor's `updated_at` is strictly after the session's last
48///   read.
49///
50/// Callers should surface the returned competitor's `changeset_id` in the
51/// STALE_OVERLAY response so the agent has a concrete referent for its
52/// retry.
53pub fn is_stale(
54    session_id: Uuid,
55    last_read: Option<DateTime<Utc>>,
56    competitors: &[CompetingChangeset],
57) -> Option<&CompetingChangeset> {
58    competitors.iter().find(|cs| {
59        if cs.session_id == Some(session_id) {
60            return false;
61        }
62        if !LIVE_STATES.contains(&cs.state.as_str()) {
63            return false;
64        }
65        match last_read {
66            Some(lr) => cs.updated_at > lr,
67            None => true,
68        }
69    })
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    fn cs(session: Option<Uuid>, state: &str, at: DateTime<Utc>) -> CompetingChangeset {
77        CompetingChangeset {
78            changeset_id: Uuid::new_v4(),
79            session_id: session,
80            state: state.to_string(),
81            updated_at: at,
82        }
83    }
84
85    #[test]
86    fn no_competitors_is_safe() {
87        let sid = Uuid::new_v4();
88        assert!(is_stale(sid, None, &[]).is_none());
89        assert!(is_stale(sid, Some(Utc::now()), &[]).is_none());
90    }
91
92    #[test]
93    fn self_session_never_stale() {
94        let sid = Uuid::new_v4();
95        let competitors = vec![cs(Some(sid), "submitted", Utc::now())];
96        assert!(is_stale(sid, None, &competitors).is_none());
97    }
98
99    #[test]
100    fn draft_never_stale() {
101        let sid = Uuid::new_v4();
102        let other = Uuid::new_v4();
103        let competitors = vec![cs(Some(other), "draft", Utc::now())];
104        assert!(is_stale(sid, None, &competitors).is_none());
105    }
106
107    #[test]
108    fn merged_never_stale() {
109        // Once merged, the AST merger at dk_merge is the right layer.
110        let sid = Uuid::new_v4();
111        let other = Uuid::new_v4();
112        let competitors = vec![cs(Some(other), "merged", Utc::now())];
113        assert!(is_stale(sid, None, &competitors).is_none());
114    }
115
116    #[test]
117    fn rejected_and_closed_are_inert() {
118        let sid = Uuid::new_v4();
119        let other = Uuid::new_v4();
120        assert!(is_stale(sid, None, &[cs(Some(other), "rejected", Utc::now())]).is_none());
121        assert!(is_stale(sid, None, &[cs(Some(other), "closed", Utc::now())]).is_none());
122    }
123
124    #[test]
125    fn competing_submitted_with_no_read_is_stale() {
126        let sid = Uuid::new_v4();
127        let other = Uuid::new_v4();
128        let competitors = vec![cs(Some(other), "submitted", Utc::now())];
129        assert!(is_stale(sid, None, &competitors).is_some());
130    }
131
132    #[test]
133    fn competing_verifying_counts_as_live() {
134        let sid = Uuid::new_v4();
135        let other = Uuid::new_v4();
136        let competitors = vec![cs(Some(other), "verifying", Utc::now())];
137        assert!(is_stale(sid, None, &competitors).is_some());
138    }
139
140    #[test]
141    fn competing_approved_counts_as_live() {
142        let sid = Uuid::new_v4();
143        let other = Uuid::new_v4();
144        let competitors = vec![cs(Some(other), "approved", Utc::now())];
145        assert!(is_stale(sid, None, &competitors).is_some());
146    }
147
148    #[test]
149    fn read_after_competing_submit_is_safe() {
150        let sid = Uuid::new_v4();
151        let other = Uuid::new_v4();
152        let submit_time = Utc::now() - chrono::Duration::seconds(10);
153        let read_time = Utc::now();
154        let competitors = vec![cs(Some(other), "submitted", submit_time)];
155        assert!(is_stale(sid, Some(read_time), &competitors).is_none());
156    }
157
158    #[test]
159    fn read_before_competing_submit_is_stale() {
160        let sid = Uuid::new_v4();
161        let other = Uuid::new_v4();
162        let read_time = Utc::now() - chrono::Duration::seconds(10);
163        let submit_time = Utc::now();
164        let competitors = vec![cs(Some(other), "submitted", submit_time)];
165        assert!(is_stale(sid, Some(read_time), &competitors).is_some());
166    }
167
168    #[test]
169    fn read_equal_to_competing_submit_is_safe() {
170        // Strictly after: equal timestamps are treated as "we have already seen it".
171        let sid = Uuid::new_v4();
172        let other = Uuid::new_v4();
173        let t = Utc::now();
174        let competitors = vec![cs(Some(other), "submitted", t)];
175        assert!(is_stale(sid, Some(t), &competitors).is_none());
176    }
177
178    #[test]
179    fn first_live_competitor_wins() {
180        let sid = Uuid::new_v4();
181        let other_a = Uuid::new_v4();
182        let other_b = Uuid::new_v4();
183        let t = Utc::now();
184        // draft then submitted — the draft is skipped, the submitted returns.
185        let draft = cs(Some(other_a), "draft", t);
186        let live = cs(Some(other_b), "submitted", t);
187        let competitors = vec![draft, live.clone()];
188        let got = is_stale(sid, None, &competitors).expect("expected stale");
189        assert_eq!(got.session_id, live.session_id);
190        assert_eq!(got.state, "submitted");
191    }
192
193    #[test]
194    fn no_session_id_still_counts() {
195        // Platform-level changesets may have null session_id — treat them as
196        // foreign competitors.
197        let sid = Uuid::new_v4();
198        let competitors = vec![cs(None, "submitted", Utc::now())];
199        assert!(is_stale(sid, None, &competitors).is_some());
200    }
201}