zeph_config/durable.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pure-data configuration for the durable execution layer (`[durable]`).
5//!
6//! These types mirror the `[durable]` TOML section and are the single source of truth for the
7//! durable execution configuration. They live in `zeph-config` (alongside every other subsystem
8//! config) so the aggregate [`Config`](crate::Config) can hold them without forcing the heavy
9//! `zeph-db`/`sqlx` dependency tree of `zeph-durable` onto the config layer. The `zeph-durable`
10//! crate re-exports these types and applies the AEAD enforcement policy (the `encryption_gate`)
11//! on top of them.
12//!
13//! Every field carries a spec default via the container-level `#[serde(default)]` attribute backed
14//! by [`Default`], so deserializing an empty table yields a fully-populated, spec-compliant
15//! configuration. No credentials appear inline — the AEAD key and any Restate endpoints are
16//! resolved from the vault by key name (spec-038 vault contract), never stored here.
17
18use serde::{Deserialize, Serialize};
19
20/// Which journal backend an execution uses.
21///
22/// `Restate` is only meaningful when the `restate` feature and an external Restate server are
23/// available; the variant is accepted in configuration regardless so a config can be authored
24/// ahead of the backend being compiled in.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum DurableBackend {
28 /// Dedicated `durable.db` SQLite/Postgres file managed in-process. The default.
29 #[default]
30 Local,
31 /// External Restate server (feature-gated, server deployments only).
32 Restate,
33}
34
35/// Configuration for the durable execution layer (`[durable]`).
36///
37/// # Examples
38///
39/// ```
40/// use zeph_config::DurableConfig;
41///
42/// // An empty table deserializes to the spec defaults.
43/// let cfg: DurableConfig = toml::from_str("").unwrap();
44/// assert!(!cfg.enabled);
45/// assert_eq!(cfg.journal_ack_timeout_ms, 5000);
46/// assert_eq!(cfg.max_payload_bytes, 1_048_576);
47/// ```
48#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(default)]
51pub struct DurableConfig {
52 /// Master opt-in. When `false`, no journal is opened and behavior is identical to a build
53 /// without the durable layer.
54 pub enabled: bool,
55 /// Selected journal backend.
56 pub backend: DurableBackend,
57 /// Encrypt payloads with AEAD. A `false` value is a development-only override (it emits a
58 /// startup warning) and is forbidden for non-local backends (INV-8).
59 pub encrypt_payload: bool,
60 /// Operator-declared flag: the durable journal database is reachable by more than one
61 /// process/client (a network-shared volume, or any future Postgres-backed durable
62 /// deployment). Required `true` for such deployments — enforced by `encryption_gate`
63 /// (INV-8), which forbids `encrypt_payload = false` when this is `true`. Default `false`
64 /// (an ordinary single-user local deployment).
65 pub shared_db: bool,
66 /// P1 adapter: wrap agent-loop steps in durable steps.
67 ///
68 /// When `true` (with `enabled = true`), every ordinary agent turn's LLM call is journaled
69 /// via a `DurableContext` opened lazily on the session's first turn (see
70 /// `zeph_core::agent::Agent::ensure_session_durable_ctx`, #5452). The execution is keyed on
71 /// the session's `ConversationId`, so this adapter requires semantic memory (`[memory]`) to
72 /// be enabled — without it, `conversation_id` is never set and the agent degrades to
73 /// non-durable with a one-time `tracing::warn!` at bootstrap, even though `agent_turns = true`
74 /// looks fully enabled in the config.
75 pub agent_turns: bool,
76 /// P2 adapter: journal the orchestration `/plan resume` replan budget.
77 pub orchestration: bool,
78 /// P3 adapter: exactly-once scheduler job fire.
79 pub scheduler: bool,
80 /// P4 adapter: durable promise for subagent spawn/await.
81 ///
82 /// Requires `agent_turns = true` as well: the durable seat is only attached when the
83 /// session's `DurableContext` (populated by the `agent_turns` adapter) is already `Some`
84 /// (#5452).
85 pub subagent: bool,
86 /// Group-commit interval for buffered appends, in milliseconds.
87 pub journal_flush_interval_ms: u64,
88 /// Timeout for an acknowledged append before degrading to non-durable mode, in milliseconds.
89 pub journal_ack_timeout_ms: u64,
90 /// In-execution step cap (soft fold at 90%, hard abort at 100%).
91 pub max_steps_per_execution: u32,
92 /// Maximum payload size in bytes, enforced on both append and read.
93 pub max_payload_bytes: u64,
94 /// Database fallback poll interval for parked promises, in seconds.
95 pub promise_poll_interval_secs: u64,
96 /// Above this many parked promises, resolution falls back to pure polling.
97 pub max_parked_promises: u32,
98 /// Journal retention and compaction policy (`[durable.retention]`).
99 pub retention: RetentionPolicy,
100}
101
102impl Default for DurableConfig {
103 fn default() -> Self {
104 Self {
105 enabled: false,
106 backend: DurableBackend::Local,
107 encrypt_payload: true,
108 shared_db: false,
109 agent_turns: true,
110 orchestration: true,
111 scheduler: true,
112 subagent: true,
113 journal_flush_interval_ms: 10,
114 journal_ack_timeout_ms: 5000,
115 max_steps_per_execution: 10_000,
116 max_payload_bytes: 1_048_576,
117 promise_poll_interval_secs: 2,
118 max_parked_promises: 1000,
119 retention: RetentionPolicy::default(),
120 }
121 }
122}
123
124/// Journal retention and compaction policy (`[durable.retention]`).
125///
126/// Drives the background prune sweep, which never runs on the dispatch hot path.
127///
128/// # Examples
129///
130/// ```
131/// use zeph_config::RetentionPolicy;
132///
133/// let policy = RetentionPolicy::default();
134/// assert_eq!(policy.ttl_completed_secs, 604_800); // 7 days
135/// assert_eq!(policy.ttl_failed_secs, 2_592_000); // 30 days
136/// ```
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(default)]
139pub struct RetentionPolicy {
140 /// Prune completed executions older than this, in seconds.
141 pub ttl_completed_secs: u64,
142 /// Prune failed or aborted executions older than this, in seconds.
143 pub ttl_failed_secs: u64,
144 /// LRU cap on the number of stored executions.
145 pub max_executions: u64,
146 /// Size cap on the journal in bytes; exceeding it triggers an LRU sweep.
147 pub max_journal_bytes: u64,
148 /// Rows deleted per transaction during a prune sweep; the task yields between batches.
149 pub prune_batch_size: u64,
150 /// Background prune poll interval, in seconds.
151 pub prune_interval_secs: u64,
152 /// Crash-orphan threshold, in seconds (#6254): a `status='running'` row whose `updated_at`
153 /// is older than this becomes a sweep candidate, subject to an INV-15 flock liveness check
154 /// before it is aborted. `0` disables the sweep entirely.
155 pub stale_running_after_secs: u64,
156}
157
158impl Default for RetentionPolicy {
159 fn default() -> Self {
160 Self {
161 ttl_completed_secs: 604_800,
162 ttl_failed_secs: 2_592_000,
163 max_executions: 10_000,
164 max_journal_bytes: 1_073_741_824,
165 prune_batch_size: 500,
166 prune_interval_secs: 3600,
167 stale_running_after_secs: 3600,
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn empty_table_yields_every_spec_default() {
178 let cfg: DurableConfig = toml::from_str("").unwrap();
179 assert!(!cfg.enabled);
180 assert_eq!(cfg.backend, DurableBackend::Local);
181 assert!(cfg.encrypt_payload);
182 assert!(!cfg.shared_db);
183 assert!(cfg.agent_turns);
184 assert!(cfg.orchestration);
185 assert!(cfg.scheduler);
186 assert!(cfg.subagent);
187 assert_eq!(cfg.journal_flush_interval_ms, 10);
188 assert_eq!(cfg.journal_ack_timeout_ms, 5000);
189 assert_eq!(cfg.max_steps_per_execution, 10_000);
190 assert_eq!(cfg.max_payload_bytes, 1_048_576);
191 assert_eq!(cfg.promise_poll_interval_secs, 2);
192 assert_eq!(cfg.max_parked_promises, 1000);
193 }
194
195 #[test]
196 fn empty_table_yields_retention_defaults() {
197 let cfg: DurableConfig = toml::from_str("").unwrap();
198 assert_eq!(cfg.retention.ttl_completed_secs, 604_800);
199 assert_eq!(cfg.retention.ttl_failed_secs, 2_592_000);
200 assert_eq!(cfg.retention.max_executions, 10_000);
201 assert_eq!(cfg.retention.max_journal_bytes, 1_073_741_824);
202 assert_eq!(cfg.retention.prune_batch_size, 500);
203 assert_eq!(cfg.retention.prune_interval_secs, 3600);
204 assert_eq!(cfg.retention.stale_running_after_secs, 3600);
205 }
206
207 #[test]
208 fn default_impl_matches_serde_default() {
209 let from_toml: DurableConfig = toml::from_str("").unwrap();
210 assert_eq!(from_toml, DurableConfig::default());
211 }
212
213 #[test]
214 fn partial_table_overrides_only_named_fields() {
215 let cfg: DurableConfig = toml::from_str(
216 r#"
217 enabled = true
218 backend = "restate"
219
220 [retention]
221 prune_batch_size = 999
222 "#,
223 )
224 .unwrap();
225 assert!(cfg.enabled);
226 assert_eq!(cfg.backend, DurableBackend::Restate);
227 // Untouched fields keep their defaults.
228 assert_eq!(cfg.journal_ack_timeout_ms, 5000);
229 assert_eq!(cfg.retention.prune_batch_size, 999);
230 assert_eq!(cfg.retention.ttl_completed_secs, 604_800);
231 }
232
233 /// Round-trips the INV-8 forbidden combination this issue's fix must reject at the
234 /// `encryption_gate` call site: `encrypt_payload = false` declared alongside `shared_db =
235 /// true`. This module only owns the pure data — the gate itself lives in `zeph-durable` — but
236 /// the config layer must still deserialize both fields correctly for the gate to see them.
237 #[test]
238 fn shared_db_and_disabled_encryption_round_trip_together() {
239 let cfg: DurableConfig = toml::from_str(
240 r"
241 encrypt_payload = false
242 shared_db = true
243 ",
244 )
245 .unwrap();
246 assert!(!cfg.encrypt_payload);
247 assert!(cfg.shared_db);
248 }
249}