Skip to main content

LocalBackend

Struct LocalBackend 

Source
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

Source

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.

Source

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.

Source

pub fn with_cipher(self, cipher: Arc<dyn PayloadCipher>) -> Self

Inject the AEAD payload cipher used to seal and open payload-bearing entries.

Source

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).

Source

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.

Source

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).

Source

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.

Source

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).

Source

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.

Source

pub fn pool(&self) -> &DbPool

Borrow the underlying pool (for tests and adapters that need direct access).

Source

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

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).

Source

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).

Source

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.

Source

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.

Source

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.

Source

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.

Source

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.

Source

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.

Source

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.

Source

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.

Source

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).

Source

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.

Trait Implementations§

Source§

impl Debug for LocalBackend

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Redacts the cipher and HMAC/HWM key material — never print key bytes or a cipher handle.

Source§

impl ExecutionBackend for LocalBackend

Source§

fn capabilities(&self) -> BackendCapabilities

Return this backend’s stable capability description.
Source§

async fn lookup_committed_result( &self, id: ExecutionId, idem_key: IdempotencyKey, ) -> Result<Option<JournalEntry>, DurableError>

Look up a committed StepResult anywhere in an execution by its IdempotencyKey. Read more
Source§

impl Journal for LocalBackend

Source§

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>

Append an entry and return its database-assigned global sequence number. Read more
Source§

async fn read_execution( &self, id: ExecutionId, ) -> Result<Vec<JournalEntry>, DurableError>

Read every entry of an execution in append order. Read more
Source§

async fn read_execution_range( &self, id: ExecutionId, from_step_id: u32, limit: usize, ) -> Result<Vec<JournalEntry>, DurableError>

Read up to limit entries of an execution starting at from_step_id. Read more
Source§

async fn finalize( &self, id: ExecutionId, status: ExecutionStatus, ) -> Result<(), DurableError>

Transition an execution to a terminal status. Read more
Source§

async fn prune(&self, policy: &RetentionPolicy) -> Result<u64, DurableError>

Prune terminal executions according to policy and return the number of rows deleted. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts 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 more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts 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
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more