pub struct LocalBackend { /* private fields */ }Expand description
The always-compiled durable backend that journals to a dedicated durable.db.
Construct it from a zeph_db::DbPool (or open one with LocalBackend::open), then attach an
optional PayloadCipher and HMAC key with the builder methods. Call LocalBackend::init
once before use to apply the schema migrations.
§Examples
use zeph_durable::LocalBackend;
// 1 MiB payload ceiling, matching the spec default.
let backend = LocalBackend::open("durable.db", 1_048_576).await?;
backend.init().await?;Implementations§
Source§impl LocalBackend
Construction, builders, and accessors.
impl LocalBackend
Construction, builders, and accessors.
Sourcepub fn new(pool: DbPool, max_payload_bytes: u64) -> Self
pub fn new(pool: DbPool, max_payload_bytes: u64) -> Self
Wrap an existing zeph_db::DbPool as a local backend with the given payload ceiling.
Call LocalBackend::init before any journal operation to apply the schema. Attach a
cipher and HMAC key with with_cipher and
with_hmac_key.
Sourcepub async fn open(
path: &str,
max_payload_bytes: u64,
) -> Result<Self, DurableError>
pub async fn open( path: &str, max_payload_bytes: u64, ) -> Result<Self, DurableError>
Open (or create) a backend on a dedicated durable.db file (or :memory:).
Connecting also applies the schema migrations, so a freshly opened backend is ready to use;
init may still be called and is idempotent.
On the SQLite backend, also derives the lock directory used by
open_execution_exclusive from path (a sibling
<path>.locks/ directory), unless path is :memory:. The Postgres backend never derives
one — path there is a connection URL (which may embed credentials), not a filesystem path.
§Errors
Returns DurableError::Storage if the pool cannot be opened or migrations fail.
Sourcepub fn with_cipher(self, cipher: Arc<dyn PayloadCipher>) -> Self
pub fn with_cipher(self, cipher: Arc<dyn PayloadCipher>) -> Self
Inject the AEAD payload cipher used to seal and open payload-bearing entries.
Sourcepub fn with_hmac_key(self, key: [u8; 32]) -> Self
pub fn with_hmac_key(self, key: [u8; 32]) -> Self
Configure the keyed-BLAKE3 HMAC key stamped over control entries on shared-database deployments, and used to verify them again on every read (INV-8).
Sourcepub fn with_previous_hmac_key(self, key: [u8; 32]) -> Self
pub fn with_previous_hmac_key(self, key: [u8; 32]) -> Self
Register a previous control-entry HMAC key for the rotation window (#6451), mirroring the
AEAD cipher’s with_previous window mechanism (zeph_core::durable::XChaCha20Poly1305Cipher).
The row-HMAC verification path tries this key when a row fails to
verify under the current with_hmac_key key, so pre-rotation
EffectIntent control entries stay readable until the window is closed with zeph durable rotate-key --drop-previous. Unlike the AEAD cipher’s key_id-tagged blob layout, the
stored hmac column carries no key selector — a deliberate divergence, since control rows
have no payload envelope to carry one; try-both is security-equivalent for a single-slot
window. Writes always stamp with the current key only, never this one.
Sourcepub fn with_hwm_key(self, epoch: u32, key: [u8; 32]) -> Self
pub fn with_hwm_key(self, epoch: u32, key: [u8; 32]) -> Self
Configure the current high-water-mark key (issue #6360), addressed by its non-secret
rotation epoch.
Unlike with_hmac_key, this is meant to be attached unconditionally
(FR-009) — attach it whenever ZEPH_DURABLE_KEY resolves from the vault, regardless of
shared_db. When set, every committed StepResult bumps the signed
{key_epoch, max_committed_step_id, committed_result_count} tuple in-transaction, and
open_execution verifies it on every resume (FR-004, US-003).
Sourcepub fn with_previous_hwm_key(self, epoch: u32, key: [u8; 32]) -> Self
pub fn with_previous_hwm_key(self, epoch: u32, key: [u8; 32]) -> Self
Register a previous high-water-mark key for the rotation window (FR-008).
Verification tries hwm_key first by epoch match, then this slot —
never the reverse. New writes always sign under the current key regardless of this slot.
Sourcepub fn with_integrity_sealed(self, sealed: bool) -> Self
pub fn with_integrity_sealed(self, sealed: bool) -> Self
Configure whether this backend has been sealed against pre-feature integrity-row absence
(issue #6449). Pass true only when the vault-stored ZEPH_DURABLE_INTEGRITY_SEALED
marker’s presence was confirmed at bootstrap — never derive this from any DB column
(that was the S1 defeat the vault-sealed design fixes; see check_high_water_mark’s
doc).
Sourcepub fn with_grandfather(self, ids: HashSet<ExecutionId>) -> Self
pub fn with_grandfather(self, ids: HashSet<ExecutionId>) -> Self
Register the vault-stored set of execution IDs grandfathered past the integrity seal (issue #6449). Each grandfathered id is a permanent forge-able slot (not merely a frozen pre-existing posture): an attacker with DB write access can delete and re-insert forged content under the same id. This is an accepted, bounded, documented operator opt-out — prefer draining a resumable execution to a terminal status where practical.
Sourcepub fn pool(&self) -> &DbPool
pub fn pool(&self) -> &DbPool
Borrow the underlying pool (for tests and adapters that need direct access).
Sourcepub async fn init(&self) -> Result<(), DurableError>
pub async fn init(&self) -> Result<(), DurableError>
Apply the durable schema migrations to the backing pool.
Idempotent: safe to call repeatedly. The schema is owned by zeph-db, not this crate.
§Errors
Returns DurableError::Storage if a migration fails.
Source§impl LocalBackend
Execution lifecycle: CRUD, journal-row mapping, checkpoints, and promise/timer resolution.
impl LocalBackend
Execution lifecycle: CRUD, journal-row mapping, checkpoints, and promise/timer resolution.
Sourcepub async fn list_executions(
&self,
status: Option<&str>,
kind: Option<&str>,
limit: i64,
) -> Result<Vec<ExecutionSummary>, DurableError>
pub async fn list_executions( &self, status: Option<&str>, kind: Option<&str>, limit: i64, ) -> Result<Vec<ExecutionSummary>, DurableError>
List execution summaries for operability surfaces (the zeph durable CLI and TUI).
Returns at most limit executions, newest first, optionally filtered by status and kind
(each is matched against the raw column tag; None disables that filter). Only execution-level
metadata is read — never payload bytes or resolver tokens (INV-5). The per-execution step
count is the number of journal entries recorded for it.
Span: durable.backend.list.
§Errors
Returns DurableError::Storage if the query fails, or DurableError::Decode if a stored
id or status cannot be reconstructed (schema corruption — the status column is
CHECK-constrained, so this is a fail-closed guard rather than a routine path).
Sourcepub async fn execution_status(
&self,
id: ExecutionId,
) -> Result<Option<ExecutionStatus>, DurableError>
pub async fn execution_status( &self, id: ExecutionId, ) -> Result<Option<ExecutionStatus>, DurableError>
Look up a single execution’s current status, without touching journal entries or payloads.
Backs the zeph durable resume CLI’s canceled-refusal check (FR-011): resume must report a
canceled execution distinctly from “no adapters wired”, which requires knowing the status
before deciding which message to print.
§Errors
Returns DurableError::Storage if the query fails, or DurableError::Decode if the
stored status cannot be reconstructed (schema corruption — the column is CHECK-constrained).
Sourcepub async fn read_execution_redacted(
&self,
id: ExecutionId,
) -> Result<Vec<RedactedEntry>, DurableError>
pub async fn read_execution_redacted( &self, id: ExecutionId, ) -> Result<Vec<RedactedEntry>, DurableError>
Read one execution’s journal entries as redaction-safe metadata, without decrypting payloads.
Unlike read_execution, this never touches the cipher, so it works
against a journal whose AEAD key is unavailable and never exposes plaintext (INV-5). It backs
the default (redacted) zeph durable show/inspect output. Entries are returned in append
order.
Span: durable.backend.read_redacted.
§Errors
Returns DurableError::Storage if the query fails.
Sourcepub async fn open_execution(
&self,
id: ExecutionId,
kind: ExecutionKind,
) -> Result<bool, DurableError>
pub async fn open_execution( &self, id: ExecutionId, kind: ExecutionKind, ) -> Result<bool, DurableError>
Ensure a durable_executions row exists for id, returning whether this is a resume.
Inserts a fresh running row for a new execution (returning false) or detects an existing
row for a resumed one (returning true). The journal’s foreign key requires this row before
any entry is appended, so callers open the execution first.
Reopening a row previously finalized as completed, failed, or
aborted un-finalizes it: status resets to running and finalized_at clears (INV-16′,
#6254). A canceled row is the deliberate exception — see the canceled branch below
(INV-16′, #6362). A caller reopening an execution is, by definition, still using it, so the
retention sweep (gated on finalized_at) must not consider it prunable while it does — without this,
a long-lived execution finalized at one process’s graceful shutdown and legitimately resumed
by a later process (e.g. a per-conversation AgentTurn execution) would keep a stale
finalized_at and could be pruned out from under its still-active journal. aborted rows
are included because the crash-orphan sweep (INV-17) makes aborted the common outcome of a
resumable crash: a resumed execution whose row keeps finalized_at set is prunable out from
under the active resume — the exact hazard this un-finalize prevents for completed/failed.
This is also strictly safer for the pre-existing divergence-recovery case, which reopens an
aborted row on purpose: it now also protects that fresh re-drive from prune.
The un-finalize is attempted as a single guarded UPDATE (no preceding SELECT) so there
is no read-then-write window against a concurrent prune sweep (#6251 critic S1): if the row
was deleted by prune between an earlier observation and this call, the UPDATE simply
matches zero rows rather than silently resurrecting a half-deleted row. A zero-row UPDATE
falls back to checking whether the row exists at all (already running/aborted, or
genuinely gone) before deciding between reporting a resume or inserting a fresh execution —
so this never reports is_resume = true for a row that turned out not to exist.
Every path that resolves to is_resume = true verifies the signed high-water-mark
(issue #6360) before returning: this is the single production call site every durable resume goes through (P1 agent-turn,
P2 orchestration, scheduler, sub-agent), so it is also the one place the HWM check needs to
live to cover unattended crash-resume (FR-004, US-003) uniformly.
Span: durable.backend.open.
§Errors
Returns DurableError::Storage if the lookup, reset, or insert fails,
DurableError::HighWaterMarkIntegrity if a resumed execution’s signed high-water-mark
does not verify — this is a hard abort with no override (FR-004) — or
DurableError::ExecutionCanceled if the row is canceled (INV-16′, #6362): checked
before the HWM verification, since a canceled execution must never be resumed regardless
of whether its journal is otherwise intact.
Sourcepub async fn open_execution_exclusive(
&self,
id: ExecutionId,
kind: ExecutionKind,
) -> Result<(bool, Option<ExecutionLock>), DurableError>
pub async fn open_execution_exclusive( &self, id: ExecutionId, kind: ExecutionKind, ) -> Result<(bool, Option<ExecutionLock>), DurableError>
Like open_execution, but additionally takes a non-blocking,
exclusive, process-scoped advisory lock on id before touching the row (INV-15, #6122).
Closes the race two processes deriving the same ExecutionId (e.g. two CLI instances
pointed at the same memory.sqlite_path and the same ConversationId) would otherwise hit
in open_execution’s unsynchronized SELECT-then-INSERT: both could
observe “no existing row”, both insert, and both then drive next_step from 0 against the
same journal, corrupting it. The lock is acquired first, so the loser never reaches the
row check at all.
Returns (is_resume, lock). The caller MUST hold lock for as long as it drives the
execution — dropping it releases the lock and allows another process to open the same
id. lock is None when this backend has no on-disk lock directory (a :memory:
database, a backend built via LocalBackend::new, or a Postgres deployment), in which
case process exclusivity is not enforced — the caller degrades the same way it already does
for open_execution’s other failure modes.
§Errors
Returns DurableError::ExecutionLocked if another process already holds id’s lock, or
any error open_execution can return.
Sourcepub async fn cancel_execution(
&self,
id: ExecutionId,
) -> Result<CancelOutcome, DurableError>
pub async fn cancel_execution( &self, id: ExecutionId, ) -> Result<CancelOutcome, DurableError>
Cancel a running execution so it is deliberately, permanently stopped and never resumed
(#6362, FR-003/006/012/014).
Unlike finalize, which blindly flips status under the caller’s
authority, this is the operator-facing entry point: it first tries to establish that no
live process still owns the execution, so a cancel never races a genuinely active owner’s
own finalize into an inconsistent state.
Liveness probe (SQLite/Unix only). When this backend has an on-disk lock_dir
(opened via LocalBackend::open against a real file), a non-blocking acquire of id’s
ExecutionLock distinguishes a live owner from a dead one:
- Lock held by another process →
CancelOutcome::LiveOwner, row untouched. - Lock free → held across the write below (a restart cannot race in mid-window), then released.
No lock_dir (:memory: or a backend built via LocalBackend::new). The safety
argument here rests on ExecutionBackend::capabilities’s cross_process flag, which
this crate only ever sets from cfg!(feature = "postgres") — i.e. it assumes “no
lock_dir on a SQLite build” implies “no other process can hold this row”, true for
:memory: but not for a file-backed pool handed to LocalBackend::new directly
(which never derives a lock_dir); that programmatic path is not reachable from the CLI
(which always uses LocalBackend::open), but a future caller of ::new on a shared file
should not assume the immediate-cancel path is probe-safe there.
cross_process == false→ provably single-process; proceed directly to the write.cross_process == true(Postgres) → a live owner cannot be ruled out and there is no flock to probe →CancelOutcome::LivenessUnverifiable, row untouched (F3).
Write. A conditional UPDATE … WHERE status = 'running' (the same single-writer-wins
pattern as finalize) — no read-then-write window (NFR-001). Zero rows affected then
disambiguates via a follow-up SELECT into CancelOutcome::NotFound or
CancelOutcome::AlreadyTerminal (idempotent for an already-canceled row, NFR-003).
Span: durable.backend.cancel.
§Errors
Returns DurableError::Storage if a query fails, or propagates any
DurableError other than DurableError::ExecutionLocked from the lock acquisition
(ExecutionLocked itself is caught and converted into CancelOutcome::LiveOwner, never
surfaced as an Err).
Sourcepub async fn find_unsealed_resumable_executions(
&self,
) -> Result<Vec<ExecutionId>, DurableError>
pub async fn find_unsealed_resumable_executions( &self, ) -> Result<Vec<ExecutionId>, DurableError>
Find every resumable (status = 'running') execution that has committed at least one
StepResult but carries no durable_execution_integrity row (issue #6449).
This is the drain-before-seal precondition scan for zeph durable seal-integrity: the
returned set is exactly the executions that would be silently downgraded to
unconditional-tamper the moment this backend seals, unless drained to a terminal status
first or explicitly grandfathered. A non-resumable (terminal) execution missing its row is
not a concern — it can never be resumed again, sealed or not.
§Errors
Returns DurableError::Storage if the query fails.
Source§impl LocalBackend
Retention: prunable/orphan counting and batch pruning.
impl LocalBackend
Retention: prunable/orphan counting and batch pruning.
Sourcepub async fn count_prunable(
&self,
policy: &RetentionPolicy,
) -> Result<u64, DurableError>
pub async fn count_prunable( &self, policy: &RetentionPolicy, ) -> Result<u64, DurableError>
Count terminal executions a prune sweep would delete under policy.
Read-only: backs zeph durable prune --dry-run. It applies the same TTL cutoffs as the
delete path, so the count is exactly what a real sweep would remove now.
§Errors
Returns DurableError::Storage if the query fails.
Sourcepub async fn count_orphans(
&self,
policy: &RetentionPolicy,
) -> Result<u64, DurableError>
pub async fn count_orphans( &self, policy: &RetentionPolicy, ) -> Result<u64, DurableError>
Count crash-orphaned executions a sweep_orphans sweep would
abort under policy (#6254).
Read-only: backs zeph durable prune --dry-run. Mirrors the real sweep’s staleness scan
and INV-15 flock liveness check (acquiring and immediately releasing each candidate’s
ExecutionLock, exactly as the real sweep does, so the count reflects genuinely
unowned rows rather than staleness alone) — but never mutates status. Returns 0 when
the sweep is disabled (stale_running_after_secs == 0) or this backend has no lock_dir.
§Errors
Returns DurableError::Storage if the query fails.
Sourcepub async fn count_sealed_under_key_id(
&self,
key_id: u8,
) -> Result<u64, DurableError>
pub async fn count_sealed_under_key_id( &self, key_id: u8, ) -> Result<u64, DurableError>
Count sealed-payload rows across durable_journal and durable_promises whose leading
on-disk byte — the AEAD key-id selector (zeph_core::durable::XChaCha20Poly1305Cipher’s
key_id(1) || nonce(24) || ciphertext || tag(16) layout) — equals key_id.
Read-only; backs zeph durable rotate-key --drop-previous’s default-on safety scan
(#6447): a nonzero count means payloads still sealed under the previous key would become
permanently unreadable (UnknownKeyId) if that key were dropped now. Filters payload IS NOT NULL on both tables — control entries (EffectIntent) carry no payload and are
irrelevant to this scan.
The predicate is dialect-specific because SQLite’s substr on a BLOB returns a 1-byte
BLOB (compared here against a bound single-byte blob) while PostgreSQL’s bytea
cannot be compared against an integer at all (get_byte(payload, 0) extracts it as an
INTEGER instead).
May over-count in a mixed-mode deployment where some rows were written while
encrypt_payload = false (plaintext, no key-id prefix): a plaintext row’s leading byte is
arbitrary content that can coincidentally equal key_id. This is intentionally fail-safe
— it can only cause an unnecessary refusal (resolved with --force), never a missed match
that would let a genuinely-sealed row be dropped silently.
§Errors
Returns DurableError::Storage if either query fails.
Sourcepub async fn count_control_entries_under_previous_hmac(
&self,
) -> Result<u64, DurableError>
pub async fn count_control_entries_under_previous_hmac( &self, ) -> Result<u64, DurableError>
Count EffectIntent control entries whose row HMAC (INV-8) verifies only under the
registered previous_hmac_key, not the current
hmac_key (#6451).
The read-side counterpart to count_sealed_under_key_id
for the control-entry HMAC’s own rotation window, and not redundant with it: the AEAD
blob-scan only sees payload-bearing rows, but a pre-rotation EffectIntent whose
StepResult was never committed (a crash between intent and result, in a still-retained
non-terminal execution) has a previous-key HMAC and no payload at all — the blob-scan
cannot see it, so dropping the previous key without this scan would silently orphan its
HMAC verification. Only EffectIntent rows carry a persisted+verified HMAC:
PromiseCreated/TimerArmed/TimerFired/Checkpoint all return
DurableError::UnsupportedEntryKind in prepare_row, and
durable_promises has no hmac column.
Backs zeph durable rotate-key --drop-previous’s safety scan alongside the AEAD blob-scan
— refuse the drop while either is nonzero. This is a fourth, dedicated key-attach site
distinct from the three runtime read paths (agent replay, scheduler daemon, CLI read):
the caller must attach both with_hmac_key (current) and
with_previous_hmac_key (previous) to this backend before
calling, or every row’s HMAC is unrecomputable and this returns
DurableError::ControlIntegrity rather than a (silently wrong) count.
Uses the precise variant — recompute-and-compare against both keys — rather than a pure
“fails under current” fail-safe: a genuinely corrupt/forged row (matches neither key) is
not counted here, since it is not something dropping the previous key would newly break;
read_execution already rejects it on every read regardless of
which key is dropped.
Cold path (runs only at --drop-previous); control rows are sparse.
§Errors
Returns DurableError::Storage if the query fails, or DurableError::ControlIntegrity
if matching control rows exist but this backend is missing the current or previous HMAC
key needed to recompute them.
Sourcepub async fn count_integrity_rows_under_epoch(
&self,
epoch: u32,
) -> Result<u64, DurableError>
pub async fn count_integrity_rows_under_epoch( &self, epoch: u32, ) -> Result<u64, DurableError>
Count durable_execution_integrity rows whose high-water-mark was signed under epoch.
The --drop-previous HWM scan (addendum to #6451, spec-081 FR-008): before permanently
removing the previous rotation key, refuse if any surviving execution’s HWM row is still
addressed to the previous epoch. Unlike
count_control_entries_under_previous_hmac,
the HWM row carries key_epoch in the clear, so this is a plain indexed COUNT — no key
material, no per-row recompute. This is also the only one of the three --drop-previous
scans that catches a checkpoint-folded pre-rotation execution: checkpoint_fold never
re-signs the HWM, so a folded execution’s integrity row keeps
key_epoch = previous_key_id even though its old-key-id payloads are gone — invisible to
both the AEAD blob-scan
(count_sealed_under_key_id) and the control-HMAC scan
(EffectIntent-only). Terminal-but-unpruned executions are counted too (the row is deleted
only by the retention prune sweep, never on finalize) — fail-safe over-refusal, resolvable
with --force, mirroring the other two scans’ coarseness.
Cold path (runs only at --drop-previous); integrity rows are sparse (one per execution).
§Errors
Returns DurableError::Storage if the query fails.
Trait Implementations§
Source§impl Debug for LocalBackend
impl Debug for LocalBackend
Source§impl ExecutionBackend for LocalBackend
impl ExecutionBackend for LocalBackend
Source§fn capabilities(&self) -> BackendCapabilities
fn capabilities(&self) -> BackendCapabilities
Source§async fn lookup_committed_result(
&self,
id: ExecutionId,
idem_key: IdempotencyKey,
) -> Result<Option<JournalEntry>, DurableError>
async fn lookup_committed_result( &self, id: ExecutionId, idem_key: IdempotencyKey, ) -> Result<Option<JournalEntry>, DurableError>
Source§impl Journal for LocalBackend
impl Journal for LocalBackend
Source§async fn sweep_orphans(
&self,
policy: &RetentionPolicy,
) -> Result<u64, DurableError>
async fn sweep_orphans( &self, policy: &RetentionPolicy, ) -> Result<u64, DurableError>
Crash-orphan reclamation (INV-17, #6254). See Journal::sweep_orphans for the contract.
Source§async fn append(&self, entry: JournalEntry) -> Result<JournalSeq, DurableError>
async fn append(&self, entry: JournalEntry) -> Result<JournalSeq, DurableError>
Source§async fn read_execution(
&self,
id: ExecutionId,
) -> Result<Vec<JournalEntry>, DurableError>
async fn read_execution( &self, id: ExecutionId, ) -> Result<Vec<JournalEntry>, DurableError>
Source§async fn read_execution_range(
&self,
id: ExecutionId,
from_step_id: u32,
limit: usize,
) -> Result<Vec<JournalEntry>, DurableError>
async fn read_execution_range( &self, id: ExecutionId, from_step_id: u32, limit: usize, ) -> Result<Vec<JournalEntry>, DurableError>
Source§async fn finalize(
&self,
id: ExecutionId,
status: ExecutionStatus,
) -> Result<(), DurableError>
async fn finalize( &self, id: ExecutionId, status: ExecutionStatus, ) -> Result<(), DurableError>
Source§async fn prune(&self, policy: &RetentionPolicy) -> Result<u64, DurableError>
async fn prune(&self, policy: &RetentionPolicy) -> Result<u64, DurableError>
policy and return the number of rows deleted. Read moreAuto Trait Implementations§
impl !Freeze for LocalBackend
impl !RefUnwindSafe for LocalBackend
impl !UnwindSafe for LocalBackend
impl Send for LocalBackend
impl Sync for LocalBackend
impl Unpin for LocalBackend
impl UnsafeUnpin for LocalBackend
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self>
fn instrument(self, span: Span) -> Instrumented<Self>
Source§fn in_current_span(self) -> Instrumented<Self>
fn in_current_span(self) -> Instrumented<Self>
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self>
fn into_either(self, into_left: bool) -> Either<Self, Self>
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more