zeph_durable/promise.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Durable promises: externally-completed handles that survive a crash-resume.
5//!
6//! A [`DurablePromise`] represents a value that an *out-of-band* party will supply later — a
7//! human-in-the-loop approval, an async A2A reply, or a subagent result. The awaiting execution can
8//! crash and resume while the promise is still pending; on resume it re-derives the same
9//! [`PromiseId`] for its program position (see [`PromiseId::derive`]) and re-attaches to the pending
10//! `durable_promises` row rather than minting a fresh one.
11//!
12//! # The resolver token is the capability (INV-9)
13//!
14//! A `PromiseId` is **not** a bearer capability — it is derivable from the execution journal and may
15//! appear in traces. The authority to resolve a promise is a separate 32-byte high-entropy *resolver
16//! token*, generated once when the promise is created and held only inside the [`DurablePromise`]
17//! value (zeroized on drop). Only its BLAKE3 hash — domain-separated and bound to
18//! `(promise_id, execution_id)` — is persisted. [`DurableHandle::resolve`] re-derives that hash from
19//! a presented token and compares it in **constant time**; a wrong token is rejected without ever
20//! revealing whether it was close.
21//!
22//! The consumer is responsible for the INV-9 channel rule: a [`DurableHandle`] is an operator/A2A
23//! surface and MUST NOT be reachable from an LLM tool. The LLM never sees the resolver token (it is
24//! handed out of band when the promise is created), so it cannot resolve its own pending promises.
25
26use std::marker::PhantomData;
27use std::sync::Arc;
28
29use serde::Serialize;
30use zeroize::Zeroizing;
31
32use crate::backend::DurableBackendEnum;
33use crate::backend::local::now_unix_millis;
34use crate::error::DurableError;
35use crate::ids::{ExecutionId, PromiseId};
36
37/// Length of a resolver token in bytes.
38pub(crate) const RESOLVER_TOKEN_LEN: usize = 32;
39
40/// Domain-separation context for the resolver-token hash (BLAKE3 `derive_key` mode).
41const RESOLVER_CONTEXT: &str = "zeph-durable v1 promise resolver-token 2026";
42
43/// The persisted state of a promise, read back by the resolve and await paths.
44///
45/// The `payload` is the AEAD-sealed resolved value when `resolved` is `true`; the backend opens it
46/// with the promise-bound AAD. The stored `resolver_token_hash` is compared against a presented
47/// token during resolution.
48#[derive(Debug, Clone)]
49pub(crate) struct PromiseRecord {
50 /// The execution that created the promise — half of the resolver-token binding.
51 pub(crate) execution_id: ExecutionId,
52 /// BLAKE3 hash of the bound resolver token (the token itself is never stored).
53 pub(crate) resolver_token_hash: [u8; 32],
54 /// Whether the promise has been resolved.
55 pub(crate) resolved: bool,
56 /// The AEAD-sealed resolved value, present once `resolved` is `true`.
57 pub(crate) payload: Option<Vec<u8>>,
58}
59
60/// Compute the domain-separated, position-bound hash of a resolver token.
61///
62/// Binding `(promise_id, execution_id)` into the hash means a token is meaningless against any other
63/// promise even if leaked, and the fixed `derive_key` context keeps these hashes disjoint from every
64/// other BLAKE3 use in the workspace. The returned [`blake3::Hash`] compares in constant time.
65pub(crate) fn resolver_token_hash(
66 promise_id: PromiseId,
67 execution_id: ExecutionId,
68 token: &[u8; RESOLVER_TOKEN_LEN],
69) -> blake3::Hash {
70 let mut input = [0u8; 16 + 16 + RESOLVER_TOKEN_LEN];
71 input[..16].copy_from_slice(promise_id.as_uuid().as_bytes());
72 input[16..32].copy_from_slice(execution_id.as_bytes());
73 input[32..].copy_from_slice(token);
74 blake3::Hash::from(blake3::derive_key(RESOLVER_CONTEXT, &input))
75}
76
77/// A typed handle to a value that an out-of-band party will resolve later.
78///
79/// Created by [`DurableContext::promise`](crate::DurableContext::promise) and consumed by
80/// [`DurableContext::await_promise`](crate::DurableContext::await_promise). The type parameter `T`
81/// ties the awaited result type to the creation site; `T` is a phantom (`fn() -> T`, so the handle
82/// is unconditionally `Send + Sync` and owns no `T`).
83///
84/// On a *fresh* creation the handle carries the resolver token; hand it to the resolving channel via
85/// [`resolver_token`](DurablePromise::resolver_token). On *resume* the original token was already
86/// delivered out of band before the crash and is unrecoverable, so a resumed handle carries no token
87/// ([`is_resumed`](DurablePromise::is_resumed) is `true`) — it can still be awaited.
88#[derive(Debug)]
89pub struct DurablePromise<T> {
90 id: PromiseId,
91 resolver_token: Option<Zeroizing<[u8; RESOLVER_TOKEN_LEN]>>,
92 _t: PhantomData<fn() -> T>,
93}
94
95impl<T> DurablePromise<T> {
96 /// Construct a freshly-created promise holding its resolver token.
97 pub(crate) fn fresh(
98 id: PromiseId,
99 resolver_token: Zeroizing<[u8; RESOLVER_TOKEN_LEN]>,
100 ) -> Self {
101 Self {
102 id,
103 resolver_token: Some(resolver_token),
104 _t: PhantomData,
105 }
106 }
107
108 /// Construct a resumed promise whose token lives out of band (delivered before the crash).
109 pub(crate) fn resumed(id: PromiseId) -> Self {
110 Self {
111 id,
112 resolver_token: None,
113 _t: PhantomData,
114 }
115 }
116
117 /// The promise's identifier.
118 #[must_use]
119 pub fn id(&self) -> PromiseId {
120 self.id
121 }
122
123 /// Borrow the resolver token to hand to the out-of-band resolving channel.
124 ///
125 /// Returns `None` for a resumed promise (the token was delivered before the crash and cannot be
126 /// recovered). The token is secret: deliver it only over the operator/A2A channel, never to the
127 /// LLM (INV-9).
128 #[must_use]
129 pub fn resolver_token(&self) -> Option<&[u8; RESOLVER_TOKEN_LEN]> {
130 self.resolver_token.as_deref()
131 }
132
133 /// Whether this handle was reconstructed on resume (and therefore holds no token).
134 #[must_use]
135 pub fn is_resumed(&self) -> bool {
136 self.resolver_token.is_none()
137 }
138}
139
140/// The out-of-band entry point that resolves pending promises.
141///
142/// Cheap to clone (it holds only an `Arc` to the shared backend) and `Send + Sync`, so it can be
143/// handed to an operator command handler or an A2A reply path. It deliberately exposes *only*
144/// [`resolve`](DurableHandle::resolve): a holder can complete a promise given the matching token but
145/// can neither create nor inspect executions.
146#[derive(Clone, Debug)]
147pub struct DurableHandle {
148 backend: Arc<DurableBackendEnum>,
149}
150
151impl DurableHandle {
152 /// Build a resolver handle over the shared backend.
153 #[must_use]
154 pub fn new(backend: Arc<DurableBackendEnum>) -> Self {
155 Self { backend }
156 }
157
158 /// Resolve a promise by presenting its resolver token and the completion value (FR-DE-05).
159 ///
160 /// The token is hashed (domain-separated, bound to `(promise_id, execution_id)`) and compared in
161 /// constant time against the stored hash. On a match the value is sealed and committed and any
162 /// in-process waiter is woken; resolving an already-resolved promise with the correct token is a
163 /// no-op success. A wrong token is rejected with [`DurableError::PromiseRejected`] and leaves the
164 /// promise untouched.
165 ///
166 /// # Errors
167 ///
168 /// - [`DurableError::UnknownPromise`] if no promise with `id` exists.
169 /// - [`DurableError::PromiseRejected`] if `resolver_token` does not authenticate.
170 /// - [`DurableError::Serialize`] if `value` cannot be serialized, or a storage error from the
171 /// backend.
172 #[tracing::instrument(
173 name = "durable.promise.resolve",
174 skip(self, resolver_token, value),
175 fields(promise_id = %id.as_uuid())
176 )]
177 pub async fn resolve<T: Serialize>(
178 &self,
179 id: PromiseId,
180 resolver_token: &[u8; RESOLVER_TOKEN_LEN],
181 value: T,
182 ) -> Result<(), DurableError> {
183 let record = self
184 .backend
185 .promise_state(id)
186 .await?
187 .ok_or(DurableError::UnknownPromise)?;
188
189 // Constant-time authentication (INV-9): blake3::Hash equality is constant-time, so a wrong
190 // token reveals no timing signal. Authenticate *before* the already-resolved short-circuit so
191 // an attacker cannot use a resolved promise as an oracle.
192 let presented = resolver_token_hash(id, record.execution_id, resolver_token);
193 if presented != blake3::Hash::from(record.resolver_token_hash) {
194 return Err(DurableError::PromiseRejected);
195 }
196 if record.resolved {
197 return Ok(());
198 }
199
200 let payload = serde_json::to_vec(&value).map_err(|_| DurableError::Serialize {
201 step: "promise.resolve",
202 })?;
203 self.backend
204 .resolve_promise(id, record.execution_id, &payload, now_unix_millis())
205 .await?;
206 Ok(())
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::ids::StepId;
214
215 #[test]
216 fn resolver_hash_binds_promise_and_execution() {
217 let exec = ExecutionId::new();
218 let promise = PromiseId::derive(exec, StepId::new(0));
219 let token = [7u8; RESOLVER_TOKEN_LEN];
220
221 let base = resolver_token_hash(promise, exec, &token);
222 assert_eq!(
223 base,
224 resolver_token_hash(promise, exec, &token),
225 "deterministic for fixed inputs"
226 );
227 // A different promise, execution, or token all change the hash.
228 let other_promise = PromiseId::derive(exec, StepId::new(1));
229 assert_ne!(base, resolver_token_hash(other_promise, exec, &token));
230 assert_ne!(
231 base,
232 resolver_token_hash(promise, ExecutionId::new(), &token)
233 );
234 assert_ne!(
235 base,
236 resolver_token_hash(promise, exec, &[8u8; RESOLVER_TOKEN_LEN])
237 );
238 }
239
240 #[test]
241 fn fresh_promise_carries_token_resumed_does_not() {
242 let fresh: DurablePromise<u32> =
243 DurablePromise::fresh(PromiseId::new(), Zeroizing::new([1u8; RESOLVER_TOKEN_LEN]));
244 assert!(fresh.resolver_token().is_some());
245 assert!(!fresh.is_resumed());
246
247 let resumed: DurablePromise<u32> = DurablePromise::resumed(PromiseId::new());
248 assert!(resumed.resolver_token().is_none());
249 assert!(resumed.is_resumed());
250 }
251}