Skip to main content

agentd/state/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **durable state model** (RFC 0025 §3, §5–§7): entity kinds, the
3//! manifest, the write-ahead inbox, timers, the checkpoint policy and the
4//! restore protocol — one façade ([`Durable`]) over a [`crate::store::Store`]
5//! that the runtime (RFC 0026) is the single writer of.
6//!
7//! Every entity is a versioned [`Envelope`] under `<prefix>/<instance>/<kind>/<id>`;
8//! `Durable::put` allocates the next `seq` per key and treats a CAS conflict
9//! on a key it already owns as **fatal** (a second writer). The manifest indexes
10//! the live entities so a store without `list` can still be restored; it is
11//! flushed **debounced** (`store.checkpoint.debounce_ms`) and at drain.
12
13pub mod ulid;
14
15use crate::obs::log::Logger;
16use crate::store::{Envelope, KeySeq, PutOutcome, SharedStore, StoreError};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use std::collections::BTreeSet;
20use std::collections::{BTreeMap, HashMap};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Mutex, OnceLock};
23use std::time::{Duration, Instant};
24
25pub(crate) use crate::store::now_ms;
26
27/// The entity kinds (RFC 0025 §3.3).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
29pub enum Kind {
30    Manifest,
31    Inbox,
32    Context,
33    Run,
34    Subagent,
35    Task,
36    Memory,
37    Artifact,
38    Timer,
39    Audit,
40    /// A cached endpoint credential (RFC 0031): an OAuth/OIDC/AWS/SPIFFE access +
41    /// refresh token with its expiry, keyed by a hash of (endpoint, provider,
42    /// principal). Redaction-excluded — never logged, audited, or read-surfaced.
43    Cred,
44}
45
46impl Kind {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Kind::Manifest => "manifest",
50            Kind::Inbox => "inbox",
51            Kind::Context => "context",
52            Kind::Run => "run",
53            Kind::Subagent => "subagent",
54            Kind::Task => "task",
55            Kind::Memory => "memory",
56            Kind::Artifact => "artifact",
57            Kind::Timer => "timer",
58            Kind::Audit => "audit",
59            Kind::Cred => "cred",
60        }
61    }
62    pub fn parse(s: &str) -> Option<Kind> {
63        Some(match s {
64            "manifest" => Kind::Manifest,
65            "inbox" => Kind::Inbox,
66            "context" => Kind::Context,
67            "run" => Kind::Run,
68            "subagent" => Kind::Subagent,
69            "task" => Kind::Task,
70            "memory" => Kind::Memory,
71            "artifact" => Kind::Artifact,
72            "timer" => Kind::Timer,
73            "audit" => Kind::Audit,
74            "cred" => Kind::Cred,
75            _ => return None,
76        })
77    }
78    /// Kinds the manifest indexes (restorable without `list`). Memory keys keep
79    /// their own index record; audit records are append-only history; cred records
80    /// are a self-keyed credential cache (not manifest-indexed).
81    pub fn indexed(self) -> bool {
82        !matches!(
83            self,
84            Kind::Manifest | Kind::Memory | Kind::Audit | Kind::Cred
85        )
86    }
87}
88
89/// One live entity in the manifest index.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct EntityRef {
92    pub kind: String,
93    pub id: String,
94    pub seq: u64,
95}
96
97/// The instance manifest (RFC 0025 §3.3 `manifest`).
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
99pub struct Manifest {
100    #[serde(default)]
101    pub generation: u64,
102    #[serde(default)]
103    pub created: u64,
104    #[serde(default)]
105    pub updated: u64,
106    #[serde(default)]
107    pub entities: Vec<EntityRef>,
108    /// Start-node state per `<workflow>.<node>` (last fired, iteration, missed).
109    #[serde(default)]
110    pub starts: BTreeMap<String, Value>,
111    /// Budget counters per window/scope (RFC 0026 §7).
112    #[serde(default)]
113    pub budget: Value,
114    #[serde(default)]
115    pub lifecycle: Value,
116    /// The digest of the settings that shaped this state (RFC 0033 §3.3),
117    /// section name -> hex. A **signal, not a key**: a mismatch is reported at
118    /// restore and the state is resumed anyway. Empty on a manifest written
119    /// before this existed, which is why an empty side never compares (an
120    /// upgrade must not announce that everything moved).
121    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
122    pub config_digest: BTreeMap<String, String>,
123    /// Records an earlier `--fresh` abandoned (RFC 0033 §3.2). They are still in
124    /// the store — `--fresh` deletes nothing — but they belong to a superseded
125    /// generation, so the `list` reconciliation in [`Durable::restore`] must not
126    /// re-adopt them and undo the flag one boot later.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub retired: Vec<EntityRef>,
129}
130
131impl Manifest {
132    fn upsert(&mut self, kind: &str, id: &str, seq: u64) {
133        match self
134            .entities
135            .iter_mut()
136            .find(|e| e.kind == kind && e.id == id)
137        {
138            Some(e) => e.seq = seq,
139            None => self.entities.push(EntityRef {
140                kind: kind.to_string(),
141                id: id.to_string(),
142                seq,
143            }),
144        }
145    }
146    fn remove(&mut self, kind: &str, id: &str) {
147        self.entities.retain(|e| !(e.kind == kind && e.id == id));
148    }
149}
150
151/// A write-ahead inbox event (RFC 0025 §5).
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct InboxEvent {
154    pub id: String,
155    pub kind: String,
156    pub ts: u64,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub principal: Option<String>,
159    pub payload: Value,
160    #[serde(default)]
161    pub status: InboxStatus,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
165#[serde(rename_all = "lowercase")]
166pub enum InboxStatus {
167    #[default]
168    Pending,
169    Done,
170}
171
172impl InboxEvent {
173    pub fn new(kind: &str, principal: Option<String>, payload: Value) -> InboxEvent {
174        InboxEvent {
175            id: ulid::new(),
176            kind: kind.to_string(),
177            ts: now_ms(),
178            principal,
179            payload,
180            status: InboxStatus::Pending,
181        }
182    }
183}
184
185/// A durable timer (RFC 0025 §3.3 `timer`): an absolute deadline + who owns it.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct TimerRecord {
188    pub id: String,
189    pub deadline_ms: u64,
190    pub owner: Value,
191    #[serde(default)]
192    pub payload: Value,
193}
194
195/// The checkpoint policy knobs (`store.checkpoint`, `store.durability`,
196/// `store.on_error`).
197#[derive(Debug, Clone)]
198pub struct Policy {
199    pub debounce: Duration,
200    pub on_error: crate::config::v2::StoreOnError,
201    pub retries: u32,
202}
203
204impl Default for Policy {
205    fn default() -> Self {
206        Policy {
207            debounce: Duration::from_millis(250),
208            on_error: crate::config::v2::StoreOnError::Halt,
209            retries: 3,
210        }
211    }
212}
213
214impl Policy {
215    pub fn from_settings(s: &crate::config::v2::Store) -> Policy {
216        Policy {
217            debounce: Duration::from_millis(s.checkpoint.debounce_ms.unwrap_or(250)),
218            on_error: s.on_error,
219            retries: 3,
220        }
221    }
222}
223
224// ---- startup intent (RFC 0033 §3.2–§3.3) ------------------------------------
225//
226// Two facts belong to *this process's life* rather than to the settings
227// document: "do not resume prior state" (`--fresh`) and "here is the
228// configuration we are about to run under" (the digest). Neither is a setting —
229// a file or an env var that pinned an instance to never resuming would be a
230// footgun, and the digest is derived, not authored — so neither has a document
231// path to bind to. The entry point knows both before the reactor exists; the
232// reactor reaches `restore()` holding only a store and a policy. Rather than
233// threading an argv fact through constructors that have no other reason to know
234// about argv, `main` records them here once, and `Durable::new` reads them.
235// Everything downstream works off the per-`Durable` copy, so a library embedder
236// (and every unit test) can set them explicitly instead.
237
238static FRESH: AtomicBool = AtomicBool::new(false);
239static CONFIG_DIGEST: OnceLock<BTreeMap<String, String>> = OnceLock::new();
240
241/// `--fresh` was given: the next [`Durable`] opened in this process starts a new
242/// generation instead of resuming (RFC 0033 §3.2).
243pub fn request_fresh() {
244    FRESH.store(true, Ordering::Relaxed);
245}
246
247/// Whether `--fresh` was given.
248pub fn fresh_requested() -> bool {
249    FRESH.load(Ordering::Relaxed)
250}
251
252/// Record the digest of the configuration this process runs under, for
253/// [`Durable::restore`] to compare against the manifest's (RFC 0033 §3.3).
254/// First call wins — the configuration is loaded once, before any side effect.
255pub fn record_config_digest(settings: &crate::config::v2::Settings) {
256    let _ = CONFIG_DIGEST.set(config_digest(settings));
257}
258
259fn recorded_config_digest() -> BTreeMap<String, String> {
260    CONFIG_DIGEST.get().cloned().unwrap_or_default()
261}
262
263/// The digest of the settings that **shaped the durable state** (RFC 0033 §3.3):
264/// section name → SHA-256 hex of that section's canonical JSON.
265///
266/// Deliberately *not* the whole document. Only the three sections whose meaning
267/// the stored records depend on are digested — a different `intelligence.model`
268/// or a new MCP server does not make yesterday's inbox mean something else, and
269/// including them would make the signal fire on every ordinary edit until an
270/// operator learned to ignore it.
271///
272/// Nothing secret-bearing goes in. `store.http.headers` and the endpoint URLs
273/// that can carry credentials in userinfo or a query are excluded by
274/// construction (see [`store_shape`]) — they are auth, not layout, and a digest
275/// of a low-entropy secret is a secret. The hash uses the crate's dependency-free
276/// SHA-256 ([`crate::sha::sha256_hex`], already the workflow/artifact content
277/// hash), so this adds no dependency and no feature gate.
278pub fn config_digest(settings: &crate::config::v2::Settings) -> BTreeMap<String, String> {
279    let mut out = BTreeMap::new();
280    // serde_json's Map is a BTreeMap here (no `preserve_order`), so `to_string`
281    // is already canonical: key order cannot make an unchanged config look moved.
282    let digest = |v: &Value| crate::sha::sha256_hex(v.to_string().as_bytes());
283    out.insert(
284        "workflows".to_string(),
285        digest(&Value::Array(settings.workflows.clone())),
286    );
287    out.insert("store".to_string(), digest(&store_shape(&settings.store)));
288    out.insert(
289        "limits".to_string(),
290        digest(&limits_shape(&settings.limits)),
291    );
292    out
293}
294
295/// The secret-free projection of `store` that shapes the state: where records
296/// go and how they are checkpointed. `Store` is deserialize-only, so this is
297/// spelled out field by field — which is the point: a new secret-bearing field
298/// cannot silently join the digest.
299fn store_shape(s: &crate::config::v2::Store) -> Value {
300    json!({
301        "kind": format!("{:?}", s.kind),
302        "prefix": s.prefix(),
303        "on_error": format!("{:?}", s.on_error),
304        "audit": s.audit,
305        "checkpoint_debounce_ms": s.checkpoint.debounce_ms,
306        "durability": format!("{:?}", s.durability),
307        "timeout_ms": s.timeout.map(|d| d.0.as_millis() as u64),
308        // The MCP server *name* is a config-local label, never a credential; the
309        // HTTP adapter contributes only its presence (base_url and headers are
310        // auth surface, §7).
311        "mcp_server": s.mcp.as_ref().map(|m| m.server.clone()),
312        "http": s.http.is_some(),
313    })
314}
315
316/// The projection of `limits` — all numbers and durations, nothing secret. The
317/// resolved values (not the `Option`s) so that writing a default explicitly does
318/// not read as a change.
319fn limits_shape(s: &crate::config::v2::Limits) -> Value {
320    json!({
321        "max_runs": s.max_runs,
322        "run_steps": s.run.steps(),
323        "run_tokens": s.run.tokens(),
324        "run_deadline_ms": s.run.deadline().as_millis() as u64,
325        "subagents": format!("{:?}", s.subagents),
326        "inline_max_bytes": s.inline_max_bytes,
327        "step_timeout_ms": s.step_timeout.map(|d| d.0.as_millis() as u64),
328    })
329}
330
331/// Which digested sections moved between the manifest's record and this run.
332///
333/// An empty side never compares: a manifest written before the digest existed
334/// carries none, and a `Durable` built without settings (an embedder, a test)
335/// computes none — reporting "everything changed" in either case would train
336/// the operator to ignore the one event that matters.
337fn changed_sections(
338    recorded: &BTreeMap<String, String>,
339    current: &BTreeMap<String, String>,
340) -> Vec<String> {
341    if recorded.is_empty() || current.is_empty() {
342        return Vec::new();
343    }
344    let mut out: Vec<String> = current
345        .iter()
346        .filter(|(k, v)| recorded.get(*k) != Some(*v))
347        .map(|(k, _)| k.clone())
348        .collect();
349    out.extend(
350        recorded
351            .keys()
352            .filter(|k| !current.contains_key(*k))
353            .cloned(),
354    );
355    out.sort();
356    out.dedup();
357    out
358}
359
360/// The kinds the restore reconciles against `list` — the entity kinds a crash
361/// can leave in the store ahead of the manifest that indexes them.
362const RECONCILED: [Kind; 7] = [
363    Kind::Inbox,
364    Kind::Context,
365    Kind::Run,
366    Kind::Subagent,
367    Kind::Task,
368    Kind::Timer,
369    Kind::Artifact,
370];
371
372/// What a restore found (RFC 0025 §6).
373#[derive(Debug, Default)]
374pub struct Restored {
375    /// `None` ⇒ a fresh instance (no manifest).
376    pub manifest: Option<Manifest>,
377    /// Live entities by kind (tombstones excluded), each the latest envelope.
378    pub entities: BTreeMap<String, Vec<Envelope>>,
379    /// Indexed but missing from the store.
380    pub lost: Vec<EntityRef>,
381    /// Entities found by `list` that the manifest did not index (written after
382    /// the last flush — entity-first write order).
383    pub unindexed: Vec<EntityRef>,
384}
385
386impl Restored {
387    pub fn inbox_pending(&self) -> Vec<InboxEvent> {
388        let mut out: Vec<InboxEvent> = self
389            .entities
390            .get("inbox")
391            .map(|v| {
392                v.iter()
393                    .filter_map(|e| serde_json::from_value::<InboxEvent>(e.state.clone()).ok())
394                    .filter(|e| e.status == InboxStatus::Pending)
395                    .collect()
396            })
397            .unwrap_or_default();
398        out.sort_by(|a, b| a.ts.cmp(&b.ts).then(a.id.cmp(&b.id)));
399        out
400    }
401    pub fn timers(&self) -> Vec<TimerRecord> {
402        self.entities
403            .get("timer")
404            .map(|v| {
405                v.iter()
406                    .filter_map(|e| serde_json::from_value(e.state.clone()).ok())
407                    .collect()
408            })
409            .unwrap_or_default()
410    }
411    pub fn of(&self, kind: Kind) -> &[Envelope] {
412        self.entities
413            .get(kind.as_str())
414            .map(Vec::as_slice)
415            .unwrap_or(&[])
416    }
417    pub fn count(&self) -> usize {
418        self.entities.values().map(Vec::len).sum()
419    }
420}
421
422/// The durability façade: the single writer's view of the store.
423pub struct Durable {
424    store: SharedStore,
425    prefix: String,
426    instance: String,
427    policy: Policy,
428    /// Last known seq per key (warmed by restore; a key not here starts at 1
429    /// and adopts a stale record's seq once).
430    seqs: Mutex<HashMap<String, u64>>,
431    manifest: Mutex<Manifest>,
432    manifest_dirty: AtomicBool,
433    last_flush: Mutex<Instant>,
434    degraded: AtomicBool,
435    log: Option<Logger>,
436    /// `--fresh`: open a new generation instead of resuming (RFC 0033 §3.2).
437    fresh: bool,
438    /// The digest of the configuration this life runs under (RFC 0033 §3.3);
439    /// empty when nothing recorded one, which disables the comparison.
440    config_digest: BTreeMap<String, String>,
441}
442
443impl Durable {
444    pub fn new(
445        store: SharedStore,
446        prefix: &str,
447        instance: &str,
448        policy: Policy,
449        log: Option<Logger>,
450    ) -> Durable {
451        Durable {
452            store,
453            prefix: prefix.to_string(),
454            instance: instance.to_string(),
455            policy,
456            seqs: Mutex::new(HashMap::new()),
457            manifest: Mutex::new(Manifest::default()),
458            manifest_dirty: AtomicBool::new(false),
459            last_flush: Mutex::new(Instant::now()),
460            degraded: AtomicBool::new(false),
461            log,
462            fresh: fresh_requested(),
463            config_digest: recorded_config_digest(),
464        }
465    }
466
467    /// Override the `--fresh` intent this `Durable` was built with — for an
468    /// embedder that drives the façade directly, and for the tests, neither of
469    /// which goes through the CLI that sets the process-wide default.
470    pub fn with_fresh(mut self, fresh: bool) -> Durable {
471        self.fresh = fresh;
472        self
473    }
474
475    /// Override the configuration digest (see [`with_fresh`](Durable::with_fresh)).
476    pub fn with_config_digest(mut self, digest: BTreeMap<String, String>) -> Durable {
477        self.config_digest = digest;
478        self
479    }
480
481    pub fn store_kind(&self) -> &'static str {
482        self.store.kind()
483    }
484    pub fn instance(&self) -> &str {
485        &self.instance
486    }
487    pub fn prefix(&self) -> &str {
488        &self.prefix
489    }
490    pub fn key(&self, kind: Kind, id: &str) -> String {
491        crate::store::key(&self.prefix, &self.instance, kind.as_str(), id)
492    }
493    /// Whether the store has failed persistently and the policy chose to go on.
494    pub fn is_degraded(&self) -> bool {
495        self.degraded.load(Ordering::Relaxed)
496    }
497    pub fn policy(&self) -> &Policy {
498        &self.policy
499    }
500
501    // ---- entities -----------------------------------------------------------
502
503    /// Write an entity: allocates the next seq for its key, CAS-puts the
504    /// envelope, indexes it in the manifest (debounced flush). A conflict on a
505    /// key this instance already owns is fatal (`StoreError::Conflict`); on a
506    /// key first seen now, the stored seq is adopted once (a restore gap).
507    pub fn put(
508        &self,
509        kind: Kind,
510        id: &str,
511        state: Value,
512        hash: Option<String>,
513    ) -> Result<u64, StoreError> {
514        let key = self.key(kind, id);
515        let mut adopted = false;
516        let started = std::time::Instant::now();
517        loop {
518            let (seq, warmed) = {
519                let seqs = self.seqs.lock().unwrap_or_else(|e| e.into_inner());
520                match seqs.get(&key).copied() {
521                    Some(s) => (s + 1, true),
522                    None => (1, false),
523                }
524            };
525            let env = Envelope::new(
526                kind.as_str(),
527                id,
528                seq,
529                &self.instance,
530                hash.clone(),
531                state.clone(),
532            );
533            kill_point("state.before_put");
534            let outcome = crate::store::with_retry(
535                || self.store.put(&key, seq, &env.to_value()),
536                self.policy.retries,
537            );
538            match outcome {
539                Ok(PutOutcome::Ok) => {
540                    self.seqs
541                        .lock()
542                        .unwrap_or_else(|e| e.into_inner())
543                        .insert(key.clone(), seq);
544                    if kind.indexed() {
545                        let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
546                        m.upsert(kind.as_str(), id, seq);
547                        m.updated = now_ms();
548                        self.manifest_dirty.store(true, Ordering::Relaxed);
549                    }
550                    self.degraded.store(false, Ordering::Relaxed);
551                    kill_point("state.after_put");
552                    crate::obs::metrics::record_store_op(
553                        "ok",
554                        started.elapsed().as_millis() as u64,
555                    );
556                    return Ok(seq);
557                }
558                Ok(PutOutcome::Conflict { latest_seq }) => {
559                    if !warmed && !adopted {
560                        // First touch of a key that already exists in the store
561                        // (a record written before a restore gap): adopt its
562                        // seq and retry once.
563                        if let Some(l) = latest_seq {
564                            self.seqs
565                                .lock()
566                                .unwrap_or_else(|e| e.into_inner())
567                                .insert(key.clone(), l);
568                            adopted = true;
569                            self.log_event("store.seq_adopted", json!({"key": key, "latest": l}));
570                            continue;
571                        }
572                    }
573                    self.log_event(
574                        "store.conflict",
575                        json!({"key": key, "seq": seq, "latest": latest_seq}),
576                    );
577                    crate::obs::metrics::record_store_op(
578                        "conflict",
579                        started.elapsed().as_millis() as u64,
580                    );
581                    return Err(StoreError::Conflict(format!(
582                        "key {key}: another writer owns it (our seq {seq}, latest {latest_seq:?})"
583                    )));
584                }
585                Err(e) => {
586                    self.log_event("store.put.fail", json!({"key": key, "err": e.to_string()}));
587                    crate::obs::metrics::record_store_op(
588                        "error",
589                        started.elapsed().as_millis() as u64,
590                    );
591                    if self.policy.on_error == crate::config::v2::StoreOnError::Degrade {
592                        self.degraded.store(true, Ordering::Relaxed);
593                        // Degraded: remember the seq we intended so a later put
594                        // does not reuse it, and go on.
595                        self.seqs
596                            .lock()
597                            .unwrap_or_else(|e| e.into_inner())
598                            .insert(key.clone(), seq);
599                        return Ok(seq);
600                    }
601                    return Err(e);
602                }
603            }
604        }
605    }
606
607    /// The latest envelope of an entity (tombstones read as absent).
608    pub fn get(&self, kind: Kind, id: &str) -> Result<Option<Envelope>, StoreError> {
609        let key = self.key(kind, id);
610        let v = crate::store::with_retry(|| self.store.get(&key, None), self.policy.retries)?;
611        match v {
612            None => Ok(None),
613            Some(v) => {
614                let env = Envelope::from_value(v)?;
615                self.seqs
616                    .lock()
617                    .unwrap_or_else(|e| e.into_inner())
618                    .insert(key, env.seq);
619                Ok(if env.is_tombstone() { None } else { Some(env) })
620            }
621        }
622    }
623
624    /// Remove an entity: `delete` when the store supports it, else a tombstone
625    /// (a `put` with `state: null`); drops it from the manifest index.
626    pub fn delete(&self, kind: Kind, id: &str) -> Result<(), StoreError> {
627        let key = self.key(kind, id);
628        match crate::store::with_retry(|| self.store.delete(&key), self.policy.retries) {
629            Ok(()) => {
630                self.seqs
631                    .lock()
632                    .unwrap_or_else(|e| e.into_inner())
633                    .remove(&key);
634            }
635            Err(StoreError::Unsupported(_)) => {
636                self.put(kind, id, Value::Null, None)?;
637            }
638            Err(e) => return Err(e),
639        }
640        if kind.indexed() {
641            let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
642            m.remove(kind.as_str(), id);
643            m.updated = now_ms();
644            self.manifest_dirty.store(true, Ordering::Relaxed);
645        }
646        Ok(())
647    }
648
649    /// The store's `list` for a kind (optional).
650    pub fn list(&self, kind: Kind) -> Result<Vec<KeySeq>, StoreError> {
651        let prefix = format!("{}/{}/{}/", self.prefix, self.instance, kind.as_str());
652        self.store.list(&prefix)
653    }
654
655    // ---- inbox / timers -----------------------------------------------------
656
657    /// Write-ahead an event (before it is acted on / acknowledged).
658    pub fn inbox_put(&self, ev: &InboxEvent) -> Result<u64, StoreError> {
659        let seq = self.put(
660            Kind::Inbox,
661            &ev.id,
662            serde_json::to_value(ev).unwrap_or(Value::Null),
663            None,
664        )?;
665        kill_point("inbox.after_put");
666        Ok(seq)
667    }
668
669    /// Mark an event processed: deleted (or tombstoned) — it will not replay.
670    pub fn inbox_done(&self, id: &str) -> Result<(), StoreError> {
671        self.delete(Kind::Inbox, id)
672    }
673
674    pub fn timer_arm(&self, t: &TimerRecord) -> Result<u64, StoreError> {
675        self.put(
676            Kind::Timer,
677            &t.id,
678            serde_json::to_value(t).unwrap_or(Value::Null),
679            None,
680        )
681    }
682
683    pub fn timer_disarm(&self, id: &str) -> Result<(), StoreError> {
684        self.delete(Kind::Timer, id)
685    }
686
687    // ---- manifest -----------------------------------------------------------
688
689    pub fn manifest(&self) -> Manifest {
690        self.manifest
691            .lock()
692            .unwrap_or_else(|e| e.into_inner())
693            .clone()
694    }
695
696    /// Mutate the manifest (start-node state, budget counters, lifecycle) —
697    /// flushed debounced.
698    pub fn manifest_update(&self, f: impl FnOnce(&mut Manifest)) {
699        let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
700        f(&mut m);
701        m.updated = now_ms();
702        self.manifest_dirty.store(true, Ordering::Relaxed);
703    }
704
705    /// Flush the manifest if dirty and (forced or the debounce elapsed).
706    pub fn flush(&self, force: bool) -> Result<bool, StoreError> {
707        if !self.manifest_dirty.load(Ordering::Relaxed) {
708            return Ok(false);
709        }
710        {
711            let last = self.last_flush.lock().unwrap_or_else(|e| e.into_inner());
712            if !force && last.elapsed() < self.policy.debounce {
713                return Ok(false);
714            }
715        }
716        let snapshot = self.manifest();
717        self.put(
718            Kind::Manifest,
719            "agent",
720            serde_json::to_value(&snapshot).unwrap_or(Value::Null),
721            None,
722        )?;
723        self.manifest_dirty.store(false, Ordering::Relaxed);
724        *self.last_flush.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
725        Ok(true)
726    }
727
728    // ---- restore ------------------------------------------------------------
729
730    /// The restore protocol (RFC 0025 §6): read the manifest, then every indexed
731    /// entity (verifying envelopes), reconcile with `list` where supported, warm
732    /// the seq map, bump the generation. A fresh instance (no manifest) writes
733    /// generation 1.
734    ///
735    /// Under `--fresh` (RFC 0033 §3.2) the middle is skipped: see
736    /// [`restore_fresh`](Durable::restore_fresh).
737    pub fn restore(&self) -> Result<Restored, StoreError> {
738        let mut out = Restored::default();
739        let (manifest, fresh) = match self.get(Kind::Manifest, "agent")? {
740            None => (
741                Manifest {
742                    generation: 0,
743                    created: now_ms(),
744                    updated: now_ms(),
745                    ..Manifest::default()
746                },
747                true,
748            ),
749            Some(env) => (
750                serde_json::from_value::<Manifest>(env.state.clone())
751                    .map_err(|e| StoreError::Corrupt(format!("manifest does not parse: {e}")))?,
752                false,
753            ),
754        };
755        // `--fresh` reads the manifest and stops there: the generation counter is
756        // the one thing a new life must inherit (otherwise "which life am I in?"
757        // resets on every use of the flag), and knowing what is being left behind
758        // is what lets the new generation retire it instead of deleting it.
759        if self.fresh {
760            return self.restore_fresh(manifest, fresh);
761        }
762        // The configuration digest (RFC 0033 §3.3) is a **signal, not a gate**.
763        // Identity is `agent.name` (§3.1): keying it on a config hash would start
764        // the agent fresh and orphan its in-flight workflows the first time
765        // someone raised a limit or fixed a typo — silently, which is exactly the
766        // outcome durability exists to prevent. So a difference is reported and
767        // the state is resumed regardless; the operator decides.
768        let moved = changed_sections(&manifest.config_digest, &self.config_digest);
769        if !moved.is_empty() {
770            self.log_event(
771                "store.config_changed",
772                json!({
773                    "sections": moved,
774                    "msg": "state was written under a different configuration — resuming anyway; --fresh to start a new generation",
775                }),
776            );
777        }
778        // Indexed entities.
779        for r in &manifest.entities {
780            let Some(kind) = Kind::parse(&r.kind) else {
781                out.lost.push(r.clone());
782                continue;
783            };
784            match self.get(kind, &r.id)? {
785                Some(env) => out.entities.entry(r.kind.clone()).or_default().push(env),
786                None => out.lost.push(r.clone()),
787            }
788        }
789        // Reconcile with `list` (entity-first write order can leave records the
790        // manifest never indexed). `seen` doubles as the ground truth for
791        // pruning the retired set below; it stays `None` on a store without
792        // `list`, where "gone" and "invisible" cannot be told apart.
793        let mut seen: Option<BTreeSet<(String, String)>> = None;
794        for kind in RECONCILED {
795            match self.list(kind) {
796                Ok(keys) => {
797                    let seen = seen.get_or_insert_with(BTreeSet::new);
798                    for ks in keys {
799                        let Some((_, id)) =
800                            crate::store::parse_key(&self.prefix, &self.instance, &ks.key)
801                        else {
802                            continue;
803                        };
804                        seen.insert((kind.as_str().to_string(), id.to_string()));
805                        let indexed = manifest
806                            .entities
807                            .iter()
808                            .any(|e| e.kind == kind.as_str() && e.id == id);
809                        if indexed {
810                            continue;
811                        }
812                        // A record a previous `--fresh` retired (RFC 0033 §3.2):
813                        // still on the store because nothing was deleted, but it
814                        // belongs to an abandoned generation. Adopting it here
815                        // would undo the flag on the next ordinary start.
816                        if manifest
817                            .retired
818                            .iter()
819                            .any(|r| r.kind == kind.as_str() && r.id == id)
820                        {
821                            continue;
822                        }
823                        if let Some(env) = self.get(kind, id)? {
824                            out.unindexed.push(EntityRef {
825                                kind: kind.as_str().to_string(),
826                                id: id.to_string(),
827                                seq: env.seq,
828                            });
829                            out.entities
830                                .entry(kind.as_str().to_string())
831                                .or_default()
832                                .push(env);
833                        }
834                    }
835                }
836                Err(StoreError::Unsupported(_)) => {}
837                Err(e) => return Err(e),
838            }
839        }
840        // Adopt the manifest, re-index what we found, bump the generation. A
841        // fresh instance (no manifest) starts at generation 1 — but any records
842        // `list` found (a crash before the first flush) are adopted, not lost.
843        let mut m = manifest.clone();
844        m.entities.retain(|e| !out.lost.iter().any(|l| l == e));
845        for u in &out.unindexed {
846            m.upsert(&u.kind, &u.id, u.seq);
847        }
848        m.generation += 1;
849        m.updated = now_ms();
850        // Carry the digest of what we actually ran under, so the next life
851        // compares against this configuration rather than re-reporting the same
852        // move forever. An unrecorded digest leaves the manifest's alone — an
853        // embedder must not erase an operator's signal.
854        if !self.config_digest.is_empty() {
855            m.config_digest = self.config_digest.clone();
856        }
857        // Prune the retired set to what the store still holds: once an operator
858        // has cleaned out the abandoned generation, its ghost list should not be
859        // carried forever.
860        if let Some(seen) = &seen {
861            m.retired
862                .retain(|r| seen.contains(&(r.kind.clone(), r.id.clone())));
863        }
864        *self.manifest.lock().unwrap_or_else(|e| e.into_inner()) = m.clone();
865        self.manifest_dirty.store(true, Ordering::Relaxed);
866        self.flush(true)?;
867        if fresh && out.count() == 0 {
868            self.log_event("restore.fresh", json!({"generation": m.generation}));
869            return Ok(out);
870        }
871        self.log_event(
872            "restore.done",
873            json!({
874                "generation": m.generation,
875                "fresh_manifest": fresh,
876                "entities": out.count(),
877                "lost": out.lost.len(),
878                "unindexed": out.unindexed.len(),
879                "inbox_pending": out.inbox_pending().len(),
880            }),
881        );
882        out.manifest = Some(m);
883        Ok(out)
884    }
885
886    /// `--fresh` (RFC 0033 §3.2): open the NEXT generation without resuming.
887    ///
888    /// Nothing is unlinked. A flag that silently destroys durable state is a
889    /// footgun — the operator who types `--fresh` to get past a wedged run is
890    /// exactly the one who will want yesterday's conversation back — so the new
891    /// generation starts *alongside* the old one:
892    ///
893    /// * the outgoing manifest is copied to `manifest/agent.gen<N>`, because it
894    ///   is the index of the retired records and without it they are a heap of
895    ///   ULIDs no one can map back to anything;
896    /// * every record still in the store is named in the new manifest's
897    ///   `retired`, so the next ordinary start does not re-adopt them through the
898    ///   `list` reconciliation and quietly undo the flag one boot later;
899    /// * the generation counter is inherited and bumped, so the log says which
900    ///   life is live.
901    fn restore_fresh(
902        &self,
903        prior: Manifest,
904        no_prior_manifest: bool,
905    ) -> Result<Restored, StoreError> {
906        // What the new generation is walking away from: whatever `list` can see,
907        // plus the prior index (a store without `list` still has one).
908        let mut retired: Vec<EntityRef> = Vec::new();
909        let mut push = |kind: &str, id: &str, seq: u64| {
910            if !retired.iter().any(|r| r.kind == kind && r.id == id) {
911                retired.push(EntityRef {
912                    kind: kind.to_string(),
913                    id: id.to_string(),
914                    seq,
915                });
916            }
917        };
918        for kind in RECONCILED {
919            match self.list(kind) {
920                Ok(keys) => {
921                    for ks in keys {
922                        if let Some((_, id)) =
923                            crate::store::parse_key(&self.prefix, &self.instance, &ks.key)
924                        {
925                            push(kind.as_str(), id, ks.seq.unwrap_or(0));
926                        }
927                    }
928                }
929                Err(StoreError::Unsupported(_)) => {}
930                Err(e) => return Err(e),
931            }
932        }
933        for e in &prior.entities {
934            push(&e.kind, &e.id, e.seq);
935        }
936        for e in &prior.retired {
937            push(&e.kind, &e.id, e.seq);
938        }
939        // Preserve the outgoing index BEFORE overwriting `manifest/agent`: dying
940        // between the two writes then leaves a stray copy, never a lost one.
941        if !no_prior_manifest {
942            self.put(
943                Kind::Manifest,
944                &format!("agent.gen{}", prior.generation),
945                serde_json::to_value(&prior).unwrap_or(Value::Null),
946                None,
947            )?;
948        }
949        // Every field spelled out rather than `..prior`: the whole point of the
950        // flag is that nothing carries over except the counter and the birth date.
951        let m = Manifest {
952            generation: prior.generation + 1,
953            created: if prior.created == 0 {
954                now_ms()
955            } else {
956                prior.created
957            },
958            updated: now_ms(),
959            entities: Vec::new(),
960            starts: BTreeMap::new(),
961            budget: Value::Null,
962            lifecycle: Value::Null,
963            config_digest: self.config_digest.clone(),
964            retired,
965        };
966        *self.manifest.lock().unwrap_or_else(|e| e.into_inner()) = m.clone();
967        self.manifest_dirty.store(true, Ordering::Relaxed);
968        self.flush(true)?;
969        self.log_event(
970            "restore.fresh",
971            json!({
972                "generation": m.generation,
973                "superseded": prior.generation,
974                "retired": m.retired.len(),
975                "msg": "--fresh: this generation starts empty; the previous one's records were kept, not deleted",
976            }),
977        );
978        Ok(Restored::default())
979    }
980
981    fn log_event(&self, event: &str, fields: Value) {
982        if let Some(l) = &self.log {
983            match event {
984                e if e.ends_with(".fail")
985                    || e == "store.conflict"
986                    // A resumed state written under a different configuration is
987                    // the operator's cue to check what moved — warn, not info.
988                    || e == "store.config_changed" =>
989                {
990                    l.warn(event, fields)
991                }
992                _ => l.info(event, fields),
993            }
994        }
995    }
996}
997
998/// A test **kill point** (RFC test strategy §2): with `AGENTD_TEST_KILL_AT=<name>`
999/// set (debug / `internal-mocks` builds only), the process SIGKILLs itself
1000/// here — the chaos suite's way of dying between two durable writes.
1001pub fn kill_point(name: &str) {
1002    #[cfg(any(feature = "internal-mocks", debug_assertions))]
1003    {
1004        if std::env::var("AGENTD_TEST_KILL_AT").as_deref() == Ok(name) {
1005            #[cfg(unix)]
1006            unsafe {
1007                libc::raise(libc::SIGKILL);
1008            }
1009            std::process::abort();
1010        }
1011    }
1012    #[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
1013    {
1014        let _ = name;
1015    }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021    use crate::store::Store;
1022    use crate::store::memory::MemoryStore;
1023    use std::sync::Arc;
1024
1025    fn durable(store: Arc<MemoryStore>) -> Durable {
1026        Durable::new(
1027            store,
1028            "agentd",
1029            "inst",
1030            Policy {
1031                debounce: Duration::from_millis(0),
1032                ..Policy::default()
1033            },
1034            None,
1035        )
1036    }
1037
1038    #[test]
1039    fn put_allocates_seqs_indexes_and_flushes_manifest() {
1040        let mem = Arc::new(MemoryStore::new());
1041        let d = durable(mem.clone());
1042        assert!(d.restore().unwrap().manifest.is_none(), "fresh");
1043        assert_eq!(
1044            d.put(
1045                Kind::Run,
1046                "r1",
1047                json!({"status": "running"}),
1048                Some("h".into())
1049            )
1050            .unwrap(),
1051            1
1052        );
1053        assert_eq!(
1054            d.put(Kind::Run, "r1", json!({"status": "done"}), Some("h".into()))
1055                .unwrap(),
1056            2
1057        );
1058        assert_eq!(
1059            d.put(Kind::Context, "root", json!({"v": 1}), None).unwrap(),
1060            1
1061        );
1062        let env = d.get(Kind::Run, "r1").unwrap().unwrap();
1063        assert_eq!(env.seq, 2);
1064        assert_eq!(env.state["status"], json!("done"));
1065        assert_eq!(env.hash.as_deref(), Some("h"));
1066        // Manifest indexes both, flushed on demand.
1067        assert!(d.flush(true).unwrap());
1068        let m = d.manifest();
1069        assert_eq!(m.entities.len(), 2);
1070        assert!(
1071            m.entities
1072                .iter()
1073                .any(|e| e.kind == "run" && e.id == "r1" && e.seq == 2)
1074        );
1075        assert!(!d.flush(true).unwrap(), "clean after a flush");
1076        // delete removes + un-indexes.
1077        d.delete(Kind::Context, "root").unwrap();
1078        assert!(d.get(Kind::Context, "root").unwrap().is_none());
1079        assert_eq!(d.manifest().entities.len(), 1);
1080    }
1081
1082    #[test]
1083    fn conflicts_are_fatal_on_owned_keys_but_adopted_on_first_touch() {
1084        let mem = Arc::new(MemoryStore::new());
1085        // A record from a previous life the manifest never indexed.
1086        let stale = Envelope::new("run", "old", 5, "inst", None, json!({"x": 1}));
1087        mem.put("agentd/inst/run/old", 5, &stale.to_value())
1088            .unwrap();
1089        let d = durable(mem.clone());
1090        // First touch adopts seq 5 → writes 6.
1091        assert_eq!(d.put(Kind::Run, "old", json!({"x": 2}), None).unwrap(), 6);
1092        // A genuine second writer bumping the key behind our back is fatal.
1093        let other = Envelope::new("run", "old", 7, "other", None, json!({"x": 3}));
1094        mem.put("agentd/inst/run/old", 7, &other.to_value())
1095            .unwrap();
1096        assert!(matches!(
1097            d.put(Kind::Run, "old", json!({"x": 4}), None),
1098            Err(StoreError::Conflict(_))
1099        ));
1100    }
1101
1102    #[test]
1103    fn inbox_write_ahead_timers_and_restore() {
1104        let mem = Arc::new(MemoryStore::new());
1105        {
1106            let d = durable(mem.clone());
1107            d.restore().unwrap();
1108            let e1 = InboxEvent::new(
1109                "a2a_message",
1110                Some("user:andrii".into()),
1111                json!({"text": "hi"}),
1112            );
1113            let e2 = InboxEvent::new("start_fired", None, json!({"workflow": "w"}));
1114            d.inbox_put(&e1).unwrap();
1115            d.inbox_put(&e2).unwrap();
1116            d.inbox_done(&e1.id).unwrap();
1117            d.timer_arm(&TimerRecord {
1118                id: "t1".into(),
1119                deadline_ms: 42,
1120                owner: json!({"run": "r"}),
1121                payload: Value::Null,
1122            })
1123            .unwrap();
1124            d.put(
1125                Kind::Run,
1126                "r",
1127                json!({"status": "running"}),
1128                Some("hash".into()),
1129            )
1130            .unwrap();
1131            d.put(Kind::Task, "task-1", json!({"state": "working"}), None)
1132                .unwrap();
1133            d.manifest_update(|m| {
1134                m.starts.insert("w.s".into(), json!({"last_fired": 1}));
1135            });
1136            // A lost entity: indexed but gone from the store.
1137            d.put(Kind::Subagent, "gone", json!({}), None).unwrap();
1138            d.flush(true).unwrap();
1139            mem.delete("agentd/inst/subagent/gone").unwrap();
1140            // An entity written AFTER the last flush (entity-first order): not
1141            // indexed; the restore's `list` reconciliation finds it.
1142            d.put(Kind::Run, "r2", json!({"status": "running"}), None)
1143                .unwrap();
1144        }
1145        // "restart": a fresh Durable over the same store.
1146        let d2 = durable(mem.clone());
1147        let r = d2.restore().unwrap();
1148        let m = r.manifest.as_ref().unwrap();
1149        assert_eq!(m.generation, 2, "generation bumped");
1150        assert_eq!(m.starts["w.s"]["last_fired"], json!(1));
1151        let pending = r.inbox_pending();
1152        assert_eq!(pending.len(), 1, "the done event does not replay");
1153        assert_eq!(pending[0].kind, "start_fired");
1154        assert_eq!(r.timers().len(), 1);
1155        assert_eq!(r.timers()[0].deadline_ms, 42);
1156        assert_eq!(
1157            r.of(Kind::Run).len(),
1158            2,
1159            "indexed + unindexed runs restored"
1160        );
1161        assert!(r.unindexed.iter().any(|u| u.id == "r2"));
1162        assert!(
1163            r.lost
1164                .iter()
1165                .any(|l| l.kind == "subagent" && l.id == "gone")
1166        );
1167        assert_eq!(r.of(Kind::Task).len(), 1);
1168        // The seq map is warm: the next put of `r` continues the sequence.
1169        assert_eq!(
1170            d2.put(
1171                Kind::Run,
1172                "r",
1173                json!({"status": "done"}),
1174                Some("hash".into())
1175            )
1176            .unwrap(),
1177            2
1178        );
1179        // And the re-indexed manifest no longer lists the lost entity.
1180        assert!(!d2.manifest().entities.iter().any(|e| e.id == "gone"));
1181    }
1182
1183    #[test]
1184    fn degrade_policy_keeps_going_and_flags_it() {
1185        let mem = Arc::new(MemoryStore::new());
1186        let d = Durable::new(
1187            mem.clone(),
1188            "agentd",
1189            "inst",
1190            Policy {
1191                debounce: Duration::from_millis(0),
1192                on_error: crate::config::v2::StoreOnError::Degrade,
1193                retries: 1,
1194            },
1195            None,
1196        );
1197        mem.fail_next(1);
1198        assert_eq!(
1199            d.put(Kind::Run, "r", json!({}), None).unwrap(),
1200            1,
1201            "degraded write reports the intended seq"
1202        );
1203        assert!(d.is_degraded());
1204        assert_eq!(
1205            d.put(Kind::Run, "r", json!({}), None).unwrap(),
1206            2,
1207            "seq not reused"
1208        );
1209        assert!(!d.is_degraded(), "a successful write clears the flag");
1210        // Halt policy surfaces the error.
1211        let d2 = durable(mem.clone());
1212        mem.fail_next(5);
1213        assert!(matches!(
1214            d2.put(Kind::Run, "x", json!({}), None),
1215            Err(StoreError::Io(_))
1216        ));
1217    }
1218
1219    /// RFC 0033 §3.2: `--fresh` opens the NEXT generation without resuming, and
1220    /// destroys nothing — the abandoned records stay readable, the outgoing index
1221    /// is preserved, and a later ordinary start does not quietly re-adopt them
1222    /// through the `list` reconciliation.
1223    #[test]
1224    fn fresh_opens_a_new_generation_without_resuming_and_deletes_nothing() {
1225        let mem = Arc::new(MemoryStore::new());
1226
1227        // Life 1: a run and a pending inbox event.
1228        let d = durable(mem.clone());
1229        assert!(d.restore().unwrap().manifest.is_none());
1230        d.put(Kind::Run, "r1", json!({"status": "running"}), None)
1231            .unwrap();
1232        let ev = InboxEvent::new("a2a.message", None, json!({"n": 1}));
1233        d.inbox_put(&ev).unwrap();
1234        assert_eq!(d.manifest().generation, 1);
1235
1236        // Life 2, `--fresh`: a new generation that resumes none of it.
1237        let f = durable(mem.clone()).with_fresh(true);
1238        let r = f.restore().unwrap();
1239        assert!(
1240            r.manifest.is_none(),
1241            "a new generation reports no prior life"
1242        );
1243        assert_eq!(r.count(), 0);
1244        assert!(r.inbox_pending().is_empty(), "the inbox does not replay");
1245        let m = f.manifest();
1246        assert_eq!(m.generation, 2, "the counter is inherited, not reset");
1247        assert!(m.entities.is_empty());
1248
1249        // Nothing was deleted, and the previous index is still findable.
1250        assert!(
1251            f.get(Kind::Run, "r1").unwrap().is_some(),
1252            "--fresh keeps the abandoned records"
1253        );
1254        let kept = f
1255            .get(Kind::Manifest, "agent.gen1")
1256            .unwrap()
1257            .expect("the outgoing manifest is preserved");
1258        let kept: Manifest = serde_json::from_value(kept.state).unwrap();
1259        assert_eq!(kept.generation, 1);
1260        assert!(m.retired.iter().any(|e| e.kind == "run" && e.id == "r1"));
1261        assert!(m.retired.iter().any(|e| e.kind == "inbox" && e.id == ev.id));
1262
1263        // Life 3, ordinary: the retired generation is not re-adopted — otherwise
1264        // the flag would come undone one boot later.
1265        let d3 = durable(mem.clone());
1266        let r3 = d3.restore().unwrap();
1267        assert_eq!(r3.count(), 0, "retired records stay retired");
1268        assert!(r3.unindexed.is_empty());
1269        assert_eq!(d3.manifest().generation, 3);
1270    }
1271
1272    /// RFC 0033 §3.3: the configuration digest is a **signal, not a key** — a
1273    /// difference is reported and the state is resumed anyway (§3.1: keying
1274    /// identity on a config hash would orphan a live workflow on a typo fix).
1275    #[test]
1276    fn a_moved_config_digest_reports_but_never_gates_the_resume() {
1277        let before: BTreeMap<String, String> = [
1278            ("workflows".to_string(), "aaa".to_string()),
1279            ("store".to_string(), "sss".to_string()),
1280        ]
1281        .into_iter()
1282        .collect();
1283        let mut after = before.clone();
1284        after.insert("workflows".to_string(), "bbb".to_string());
1285        assert_eq!(changed_sections(&before, &after), vec!["workflows"]);
1286        // An empty side never compares: a pre-digest manifest, or a `Durable`
1287        // built without settings, must not announce that everything moved.
1288        assert!(changed_sections(&BTreeMap::new(), &after).is_empty());
1289        assert!(changed_sections(&before, &BTreeMap::new()).is_empty());
1290
1291        let mem = Arc::new(MemoryStore::new());
1292        let d = durable(mem.clone()).with_config_digest(before.clone());
1293        d.restore().unwrap();
1294        d.put(Kind::Run, "r1", json!({"status": "running"}), None)
1295            .unwrap();
1296        assert_eq!(d.manifest().config_digest, before);
1297
1298        let d2 = durable(mem.clone()).with_config_digest(after.clone());
1299        let r = d2.restore().unwrap();
1300        assert_eq!(r.of(Kind::Run).len(), 1, "state is still resumed");
1301        assert_eq!(d2.manifest().generation, 2);
1302        assert_eq!(
1303            d2.manifest().config_digest,
1304            after,
1305            "the next life compares against what this one ran under"
1306        );
1307    }
1308
1309    /// The digest covers only the sections whose meaning the stored records
1310    /// depend on (RFC 0033 §3.3) — an edit anywhere else must not fire it.
1311    #[test]
1312    fn the_digest_covers_workflows_store_and_limits_only() {
1313        let doc = json!({
1314            "config_version": "2",
1315            "agent": {"name": "a", "instruction": "one"},
1316            "workflows": [{"name": "w", "version": 3, "steps": {"s": {"kind": "once"}}}],
1317            "limits": {"run": {"steps": 10}},
1318        });
1319        let settings = |patch: &dyn Fn(&mut Value)| {
1320            let mut d = doc.clone();
1321            patch(&mut d);
1322            serde_json::from_value::<crate::config::v2::Settings>(d).expect("settings")
1323        };
1324        let base = config_digest(&settings(&|_| {}));
1325        assert_eq!(
1326            base.keys().collect::<Vec<_>>(),
1327            ["limits", "store", "workflows"]
1328        );
1329
1330        let elsewhere = config_digest(&settings(&|d| d["agent"]["instruction"] = json!("two")));
1331        assert_eq!(base, elsewhere, "an instruction edit is not a state change");
1332
1333        let wf = config_digest(&settings(&|d| {
1334            d["workflows"][0]["steps"]["t"] = json!({"kind": "noop"})
1335        }));
1336        assert_eq!(changed_sections(&base, &wf), vec!["workflows"]);
1337
1338        let lim = config_digest(&settings(&|d| d["limits"]["run"]["steps"] = json!(11)));
1339        assert_eq!(changed_sections(&base, &lim), vec!["limits"]);
1340    }
1341}