Skip to main content

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    /// The AEAD key-id byte stamped on every payload sealed with the current
101    /// `ZEPH_DURABLE_KEY`. Bumped by `zeph durable rotate-key`; an operator-controlled
102    /// selector so rotation needs no recompile. Default `0` matches every payload sealed
103    /// before this field existed.
104    pub key_id: u8,
105    /// When set, the previous AEAD key (vault secret `ZEPH_DURABLE_KEY_PREVIOUS`) is
106    /// registered as the cipher's rotation-window read slot. `None` means no rotation
107    /// window is open. Set and cleared by `zeph durable rotate-key` / `--drop-previous` —
108    /// not intended for manual editing outside of documented crash recovery.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub previous_key_id: Option<u8>,
111}
112
113impl Default for DurableConfig {
114    fn default() -> Self {
115        Self {
116            enabled: false,
117            backend: DurableBackend::Local,
118            encrypt_payload: true,
119            shared_db: false,
120            agent_turns: true,
121            orchestration: true,
122            scheduler: true,
123            subagent: true,
124            journal_flush_interval_ms: 10,
125            journal_ack_timeout_ms: 5000,
126            max_steps_per_execution: 10_000,
127            max_payload_bytes: 1_048_576,
128            promise_poll_interval_secs: 2,
129            max_parked_promises: 1000,
130            retention: RetentionPolicy::default(),
131            key_id: 0,
132            previous_key_id: None,
133        }
134    }
135}
136
137/// Journal retention and compaction policy (`[durable.retention]`).
138///
139/// Drives the background prune sweep, which never runs on the dispatch hot path.
140///
141/// # Examples
142///
143/// ```
144/// use zeph_config::RetentionPolicy;
145///
146/// let policy = RetentionPolicy::default();
147/// assert_eq!(policy.ttl_completed_secs, 604_800); // 7 days
148/// assert_eq!(policy.ttl_failed_secs, 2_592_000); // 30 days
149/// ```
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(default)]
152pub struct RetentionPolicy {
153    /// Prune completed executions older than this, in seconds.
154    pub ttl_completed_secs: u64,
155    /// Prune failed or aborted executions older than this, in seconds.
156    pub ttl_failed_secs: u64,
157    /// LRU cap on the number of stored executions.
158    pub max_executions: u64,
159    /// Size cap on the journal in bytes; exceeding it triggers an LRU sweep.
160    pub max_journal_bytes: u64,
161    /// Rows deleted per transaction during a prune sweep; the task yields between batches.
162    pub prune_batch_size: u64,
163    /// Background prune poll interval, in seconds.
164    pub prune_interval_secs: u64,
165    /// Crash-orphan threshold, in seconds (#6254): a `status='running'` row whose `updated_at`
166    /// is older than this becomes a sweep candidate, subject to an INV-15 flock liveness check
167    /// before it is aborted. `0` disables the sweep entirely.
168    pub stale_running_after_secs: u64,
169}
170
171impl Default for RetentionPolicy {
172    fn default() -> Self {
173        Self {
174            ttl_completed_secs: 604_800,
175            ttl_failed_secs: 2_592_000,
176            max_executions: 10_000,
177            max_journal_bytes: 1_073_741_824,
178            prune_batch_size: 500,
179            prune_interval_secs: 3600,
180            stale_running_after_secs: 3600,
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn empty_table_yields_every_spec_default() {
191        let cfg: DurableConfig = toml::from_str("").unwrap();
192        assert!(!cfg.enabled);
193        assert_eq!(cfg.backend, DurableBackend::Local);
194        assert!(cfg.encrypt_payload);
195        assert!(!cfg.shared_db);
196        assert!(cfg.agent_turns);
197        assert!(cfg.orchestration);
198        assert!(cfg.scheduler);
199        assert!(cfg.subagent);
200        assert_eq!(cfg.journal_flush_interval_ms, 10);
201        assert_eq!(cfg.journal_ack_timeout_ms, 5000);
202        assert_eq!(cfg.max_steps_per_execution, 10_000);
203        assert_eq!(cfg.max_payload_bytes, 1_048_576);
204        assert_eq!(cfg.promise_poll_interval_secs, 2);
205        assert_eq!(cfg.max_parked_promises, 1000);
206        assert_eq!(cfg.key_id, 0);
207        assert_eq!(cfg.previous_key_id, None);
208    }
209
210    /// Round-trips the rotation-window pair a `zeph durable rotate-key` write produces, so the
211    /// config layer correctly deserializes both fields together (#6447).
212    #[test]
213    fn key_rotation_fields_round_trip_together() {
214        let cfg: DurableConfig = toml::from_str(
215            r"
216            key_id = 1
217            previous_key_id = 0
218            ",
219        )
220        .unwrap();
221        assert_eq!(cfg.key_id, 1);
222        assert_eq!(cfg.previous_key_id, Some(0));
223    }
224
225    /// `previous_key_id` is skipped when `None` (INV-5 style: no window means no field), so a
226    /// freshly-migrated config that never rotated stays visually unchanged (#6447).
227    #[test]
228    fn previous_key_id_is_omitted_from_serialization_when_none() {
229        let cfg = DurableConfig::default();
230        let toml = toml::to_string(&cfg).unwrap();
231        assert!(!toml.contains("previous_key_id"));
232    }
233
234    #[test]
235    fn empty_table_yields_retention_defaults() {
236        let cfg: DurableConfig = toml::from_str("").unwrap();
237        assert_eq!(cfg.retention.ttl_completed_secs, 604_800);
238        assert_eq!(cfg.retention.ttl_failed_secs, 2_592_000);
239        assert_eq!(cfg.retention.max_executions, 10_000);
240        assert_eq!(cfg.retention.max_journal_bytes, 1_073_741_824);
241        assert_eq!(cfg.retention.prune_batch_size, 500);
242        assert_eq!(cfg.retention.prune_interval_secs, 3600);
243        assert_eq!(cfg.retention.stale_running_after_secs, 3600);
244    }
245
246    #[test]
247    fn default_impl_matches_serde_default() {
248        let from_toml: DurableConfig = toml::from_str("").unwrap();
249        assert_eq!(from_toml, DurableConfig::default());
250    }
251
252    #[test]
253    fn partial_table_overrides_only_named_fields() {
254        let cfg: DurableConfig = toml::from_str(
255            r#"
256            enabled = true
257            backend = "restate"
258
259            [retention]
260            prune_batch_size = 999
261            "#,
262        )
263        .unwrap();
264        assert!(cfg.enabled);
265        assert_eq!(cfg.backend, DurableBackend::Restate);
266        // Untouched fields keep their defaults.
267        assert_eq!(cfg.journal_ack_timeout_ms, 5000);
268        assert_eq!(cfg.retention.prune_batch_size, 999);
269        assert_eq!(cfg.retention.ttl_completed_secs, 604_800);
270    }
271
272    /// Round-trips the INV-8 forbidden combination this issue's fix must reject at the
273    /// `encryption_gate` call site: `encrypt_payload = false` declared alongside `shared_db =
274    /// true`. This module only owns the pure data — the gate itself lives in `zeph-durable` — but
275    /// the config layer must still deserialize both fields correctly for the gate to see them.
276    #[test]
277    fn shared_db_and_disabled_encryption_round_trip_together() {
278        let cfg: DurableConfig = toml::from_str(
279            r"
280            encrypt_payload = false
281            shared_db = true
282            ",
283        )
284        .unwrap();
285        assert!(!cfg.encrypt_payload);
286        assert!(cfg.shared_db);
287    }
288}