Skip to main content

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}