feat(durable): durable promises, timers, and retention/compaction#4971
Merged
Conversation
Complete the DurableContext API surface (C5 of epic #4707, spec-064). Promises (FR-DE-05, INV-9): ctx.promise() mints a position-derived PromiseId and a 32-byte CSPRNG resolver token (zeroized on drop); only its domain- separated BLAKE3 hash, bound to (promise_id, execution_id), is persisted. DurableHandle::resolve authenticates the token in constant time before sealing and committing the value. The token is the sole capability, so the LLM cannot resolve its own promises. await_promise parks on a Notify with a DB-poll fallback and a max_parked_promises cap. Timers (FR-DE-06): sleep_until arms a durable_timers row at a deterministic TimerId derived from the program position; DurableTimerService fires due timers and wakes parked waiters. A timer that elapsed during downtime fires on the first poll after restart, and a resumed sleep_until returns immediately. Retention: DurableRetentionService prunes terminal executions past their TTL in yielding batches (never the dispatch hot path). Crossing the soft step cap (90%) folds the committed-idempotent prefix into one AEAD-sealed Checkpoint on a background task and deletes the folded rows; the hard cap aborts with StepCapExceeded. A resume replays folded steps from the snapshot without re-running them, preserving each step's idempotency key for the divergence guard. Promise/timer state lives in the dedicated durable_promises/durable_timers tables via inherent DurableBackendEnum forwarders (local-specific, off the sealed trait). 102 crate tests pass; clippy/doc/RUSTFLAGS clean on sqlite and postgres.
bug-ops
enabled auto-merge (squash)
June 7, 2026 00:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Completes the
DurableContextAPI surface — C5 of epic #4707 (spec-064,zeph-durable). Builds on C1–C4 (#4944/#4945/#4946/#4947, all merged) and adds durable promises, durable timers, and the retention/compaction machinery. Pure additive change tozeph-durable;zeph-core(the only reverse dependency) is unaffected.Closes #4948.
What landed
Durable promises (
promise.rs, FR-DE-05, INV-9)ctx.promise::<T>()mints aPromiseIdderived from(execution_id, step_id)plus a 32-byte CSPRNG resolver token (Zeroizing, zeroized on drop). Only the domain-separated BLAKE3 hash — bound to(promise_id, execution_id)— is stored indurable_promises.resolver_token_hash.DurableHandle::resolve(id, token, value)authenticates the token in constant time (blake3::Hashequality) before sealing the value with a(execution_id, promise_id)-bound AAD and committing it. Wrong token →PromiseRejected; unknown promise →UnknownPromise; double-resolve is a no-op.PromiseIdis derivable from the journal and appears in spans, but the LLM never sees the token, so it cannot resolve its own promises (INV-9).DurableHandlemust be kept off any LLM-reachable tool surface.ctx.await_promise()parks on atokio::sync::Notifykeyed by promise id with apromise_poll_interval_secsDB-poll fallback; abovemax_parked_promisesparked waiters it polls without registering.Durable timers (
timer.rs, FR-DE-06)ctx.sleep_until(due)arms adurable_timersrow at a deterministicTimerId::derive(exec, step)and parks until due; it fires its own timer even with no service running.DurableTimerServicepollsdue_timers(now)(covered byidx_durable_timers_due), fires them idempotently, and wakes parked waiters. A timer whose instant elapsed during downtime fires on the first poll after restart; a resumedsleep_untilreturns immediately because it re-derives the sameTimerId.Retention & compaction (
retention.rs, spec NEVER: never on the hot path)DurableRetentionServiceprunes terminal executions pastttl_completed_secs/ttl_failed_secsinprune_batch_sizebatches (children-then-parent FK order), yielding between batches.max_steps_per_execution) spawns a background checkpoint fold (tracked in aJoinSet, drainable viactx.drain_background()): the committed-idempotent prefix is packed into one AEAD-sealedCheckpoint(budget-capped atmax_payload_bytes) and the folded rows are deleted. The hard cap (100%) aborts withStepCapExceeded.StepResult(idempotency key preserved) so folded idempotent steps replay their journaled value without re-running the op and without tripping the divergence guard (INV-3).Design notes
(execution_id, step_id)(UUIDv8) rather than a random UUIDv7, so a resumed program re-attaches to the pending row instead of minting an orphan. This is security-neutral: INV-9 makes the resolver token — not the id — the capability.DurableBackendEnum(forwarding toLocalBackend's dedicateddurable_promises/durable_timerstables), not on the sealedExecutionBackendtrait — they are local-table-specific; a future Restate backend would satisfy them via its own SDK, and the closedmatchmakes that a compile-time prompt.rand(resolver-token CSPRNG),zeroize(Zeroizingtoken),uuidv8feature.Tests & gates
cargo nextest run -p zeph-durable→ 102 pass (+10): promise auth (right/wrong/unknown token, constant-time), notify-wake, resumed-promise await, timer arm/due/fire + restart re-arm, prune, checkpoint fold + replay-through-checkpoint, soft/hard cap.cargo clippy --all-targets -- -D warningsclean on sqlite and postgres.RUSTDOCFLAGS="--deny rustdoc::broken_intra_doc_links" cargo doc --no-depsclean;cargo test --doc34 pass.RUSTFLAGS="-D warnings" cargo check --all-targetsclean on both backends.cargo +nightly fmt --checkclean;zeph-core(consumer) 1532 tests pass.Spans added
durable.promise.create/await/resolve,durable.timer.arm/fire,durable.journal.prune(real body),durable.journal.checkpoint,durable.replay.cursor.preload.Out of scope (other epic children)
CLI/TUI/config/init/migrate wiring (C6 #4949); the agent-loop adapter (A1 #4951, LLM live-API gate); subagent promise wiring (A4 #4954); the long-running
service::runinterval loops are exercised via the deterministicfire_due/single-prunepaths, not a live tick; Postgres-server parity (testcontainers).