Skip to main content

lash_core/runtime/effect/
promise_semantics.rs

1//! Store-agnostic state transitions for AwaitEvent keyed promises.
2//!
3//! Backends own persistence, authentication, and compare-and-swap mechanics.
4//! This module owns the semantic decisions that must remain identical across
5//! the inline, SQLite, Postgres, and engine-backed implementations.
6
7use super::{AwaitEventWaitIdentity, ExecutionScope, Resolution, ResolveOutcome};
8use crate::RuntimeError;
9
10/// Derive the stable promise identity shared by every substrate.
11pub fn derive_key_id(
12    scope: &ExecutionScope,
13    wait: &AwaitEventWaitIdentity,
14) -> Result<String, RuntimeError> {
15    scope.validate()?;
16    wait.validate()?;
17    crate::stable_hash::stable_json_sha256_hex(&(scope, wait)).map_err(|err| {
18        RuntimeError::new(
19            "await_event_key_hash",
20            format!("failed to hash await-event identity: {err}"),
21        )
22    })
23}
24
25/// Canonical bytes authenticated by HMAC-backed durable AwaitEvent adapters.
26///
27/// The key id remains in the signed material even though it is derived from
28/// `scope` and `wait`: authenticating all three serialized key fields prevents
29/// backends from accidentally accepting a key whose visible identity and
30/// routing identity disagree.
31pub fn sign_material(
32    scope: &ExecutionScope,
33    wait: &AwaitEventWaitIdentity,
34    key_id: &str,
35) -> Vec<u8> {
36    serde_json::to_vec(&(scope, wait, key_id))
37        .expect("await-event signing material contains only infallible JSON values")
38}
39
40/// State observed by a backend while holding its promise transition fence.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum PromiseState {
43    /// No waiter or earlier resolution has materialized a row yet.
44    Missing,
45    /// A waiter has materialized the promise, but no terminal has won.
46    Pending,
47    /// A terminal has already won the first-writer-wins race.
48    Resolved(Resolution),
49    /// The owning session has been durably revoked.
50    Revoked,
51}
52
53/// Pure decision returned for a proposed terminal transition.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum PromiseTransition {
56    /// Persist this terminal with a first-writer-wins compare-and-swap.
57    Store(Resolution),
58    /// A prior terminal remains authoritative.
59    AlreadyResolved(Resolution),
60    /// The key must use the common non-oracular unknown/revoked result.
61    UnknownOrRevoked,
62    /// The operation intentionally leaves this promise unchanged.
63    Unchanged,
64}
65
66impl PromiseTransition {
67    /// Convert a committed resolution decision to the public resolver result.
68    ///
69    /// `Unchanged` is not a resolve result; it is used only by cancel sweeps.
70    pub fn resolve_outcome(self) -> Option<ResolveOutcome> {
71        match self {
72            Self::Store(_) => Some(ResolveOutcome::Accepted),
73            Self::AlreadyResolved(terminal) => Some(ResolveOutcome::AlreadyResolved { terminal }),
74            Self::UnknownOrRevoked => Some(ResolveOutcome::UnknownOrRevoked),
75            Self::Unchanged => None,
76        }
77    }
78}
79
80/// Decide a normal first-writer-wins resolve.
81///
82/// Missing promises accept the terminal so signal-before-wait is buffered.
83pub fn resolve(state: PromiseState, proposed: Resolution) -> PromiseTransition {
84    match state {
85        PromiseState::Missing | PromiseState::Pending => PromiseTransition::Store(proposed),
86        PromiseState::Resolved(terminal) => PromiseTransition::AlreadyResolved(terminal),
87        PromiseState::Revoked => PromiseTransition::UnknownOrRevoked,
88    }
89}
90
91/// Decide the session cancel-sweep transition for one promise.
92///
93/// Turn-control promises are never swept: cancelling their observation must
94/// not manufacture a turn cancellation or terminal publication. Existing
95/// terminals are equally immutable.
96pub fn cancel_sweep(wait: &AwaitEventWaitIdentity, state: PromiseState) -> PromiseTransition {
97    if wait.is_turn_control() {
98        return PromiseTransition::Unchanged;
99    }
100    match state {
101        PromiseState::Missing => PromiseTransition::Unchanged,
102        PromiseState::Pending => PromiseTransition::Store(Resolution::Cancelled),
103        PromiseState::Resolved(terminal) => PromiseTransition::AlreadyResolved(terminal),
104        PromiseState::Revoked => PromiseTransition::UnknownOrRevoked,
105    }
106}
107
108/// Pure session-tombstone decision shared by in-memory and durable stores.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum SessionRevocationTransition {
111    MarkRevoked,
112    AlreadyRevoked,
113}
114
115pub fn revoke_session(already_revoked: bool) -> SessionRevocationTransition {
116    if already_revoked {
117        SessionRevocationTransition::AlreadyRevoked
118    } else {
119        SessionRevocationTransition::MarkRevoked
120    }
121}
122
123/// Whether mint and resolve may proceed for a session.
124///
125/// Both operations must consult the tombstone before touching promise rows.
126pub fn session_allows_access(revoked: bool) -> bool {
127    !revoked
128}
129
130/// Compare authentication bytes without branching on their contents.
131///
132/// Length is folded into the result and the loop covers the longer input, so
133/// malformed signatures use the same comparison shape as valid-length ones.
134pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
135    let mut difference = left.len() ^ right.len();
136    for index in 0..left.len().max(right.len()) {
137        let left_byte = left.get(index).copied().unwrap_or_default();
138        let right_byte = right.get(index).copied().unwrap_or_default();
139        difference |= usize::from(left_byte ^ right_byte);
140    }
141    difference == 0
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn resolve_buffers_before_wait_and_preserves_the_first_terminal() {
150        let first = Resolution::Ok(serde_json::json!("first"));
151        assert_eq!(
152            resolve(PromiseState::Missing, first.clone()),
153            PromiseTransition::Store(first.clone())
154        );
155        assert_eq!(
156            resolve(
157                PromiseState::Resolved(first.clone()),
158                Resolution::Ok(serde_json::json!("second")),
159            ),
160            PromiseTransition::AlreadyResolved(first)
161        );
162    }
163
164    #[test]
165    fn cancel_sweep_excludes_turn_control_and_existing_terminals() {
166        assert_eq!(
167            cancel_sweep(
168                &AwaitEventWaitIdentity::TurnCancelGate,
169                PromiseState::Pending,
170            ),
171            PromiseTransition::Unchanged
172        );
173        assert_eq!(
174            cancel_sweep(
175                &AwaitEventWaitIdentity::tool_completion("call"),
176                PromiseState::Pending,
177            ),
178            PromiseTransition::Store(Resolution::Cancelled)
179        );
180        assert_eq!(
181            cancel_sweep(
182                &AwaitEventWaitIdentity::tool_completion("call"),
183                PromiseState::Resolved(Resolution::Timeout),
184            ),
185            PromiseTransition::AlreadyResolved(Resolution::Timeout)
186        );
187    }
188
189    #[test]
190    fn revoked_sessions_reject_access_and_revoke_idempotently() {
191        assert!(session_allows_access(false));
192        assert!(!session_allows_access(true));
193        assert_eq!(
194            revoke_session(false),
195            SessionRevocationTransition::MarkRevoked
196        );
197        assert_eq!(
198            revoke_session(true),
199            SessionRevocationTransition::AlreadyRevoked
200        );
201        assert_eq!(
202            resolve(PromiseState::Revoked, Resolution::Cancelled),
203            PromiseTransition::UnknownOrRevoked
204        );
205    }
206
207    #[test]
208    fn authentication_comparison_covers_content_and_length_mismatches() {
209        assert!(constant_time_eq(b"same", b"same"));
210        assert!(!constant_time_eq(b"same", b"sale"));
211        assert!(!constant_time_eq(b"same", b"same-longer"));
212    }
213
214    #[test]
215    fn signing_material_is_the_canonical_scope_wait_key_tuple() {
216        let scope = ExecutionScope::turn("session", "turn");
217        let wait = AwaitEventWaitIdentity::tool_completion("call");
218        let key_id = derive_key_id(&scope, &wait).expect("derive key id");
219
220        assert_eq!(
221            sign_material(&scope, &wait, &key_id),
222            serde_json::to_vec(&(scope, wait, key_id)).expect("serialize tuple")
223        );
224    }
225}