lash_core/runtime/effect/
promise_semantics.rs1use super::{AwaitEventWaitIdentity, ExecutionScope, Resolution, ResolveOutcome};
8use crate::RuntimeError;
9
10pub 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
25pub 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#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum PromiseState {
43 Missing,
45 Pending,
47 Resolved(Resolution),
49 Revoked,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum PromiseTransition {
56 Store(Resolution),
58 AlreadyResolved(Resolution),
60 UnknownOrRevoked,
62 Unchanged,
64}
65
66impl PromiseTransition {
67 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
80pub 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
91pub 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#[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
123pub fn session_allows_access(revoked: bool) -> bool {
127 !revoked
128}
129
130pub 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}