Skip to main content

plecto_control/manifest/
state.rs

1//! The host state backend (`[state]`, ADR 000041).
2
3use serde::{Deserialize, Serialize};
4
5/// The host state backend (`[state]`, ADR 000041): the single knob choosing where the three
6/// state capabilities (`host-kv` / `host-counter` / `host-ratelimit`) keep their bytes.
7/// `memory` (the default) keeps zero-config startup — state dies with the process; `redb`
8/// makes it durable at `path`, so counters and rate-limit windows survive a restart
9/// (fail-closed direction, ADR 000004). One backend serves all three capabilities; fixed at
10/// construction like `[trust]` (`PartialEq` backs the reload rejection — restart to apply).
11#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
12#[serde(deny_unknown_fields)]
13pub struct State {
14    #[serde(default)]
15    pub backend: StateBackendKind,
16    /// Manifest-relative path of the redb database file. Required iff `backend = "redb"`;
17    /// the parent directory must already exist (operator responsibility).
18    #[serde(default)]
19    pub path: Option<String>,
20}
21
22/// Manifest spelling of the state backend (ADR 000041). Defaults to `memory`.
23#[derive(
24    Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
25)]
26#[serde(rename_all = "lowercase")]
27pub enum StateBackendKind {
28    #[default]
29    Memory,
30    Redb,
31}
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36    use crate::manifest::Manifest;
37
38    // ----- ADR 000041: [state] — host state backend selection -----
39
40    #[test]
41    fn state_defaults_memory_and_parses_redb() {
42        // Absent [state] → memory, no path (zero-config startup keeps working).
43        let bare = Manifest::from_toml("").unwrap();
44        assert_eq!(bare.state.backend, StateBackendKind::Memory);
45        assert_eq!(bare.state.path, None);
46
47        // Explicit redb reads the knobs.
48        let m = Manifest::from_toml(
49            r#"
50[state]
51backend = "redb"
52path = "state/plecto.redb"
53"#,
54        )
55        .unwrap();
56        assert_eq!(m.state.backend, StateBackendKind::Redb);
57        assert_eq!(m.state.path.as_deref(), Some("state/plecto.redb"));
58
59        // deny_unknown_fields holds inside the section.
60        assert!(
61            Manifest::from_toml("[state]\nbackedn = \"redb\"\n").is_err(),
62            "a typo inside [state] is rejected"
63        );
64    }
65
66    #[test]
67    fn state_rides_the_content_hash_and_the_default_is_canonical() {
68        // An explicit default ([state] backend = "memory") and an absent section are the same
69        // config → same hash (determinism invariant, like an explicit isolation = "untrusted").
70        let absent = Manifest::from_toml("").unwrap();
71        let explicit = Manifest::from_toml("[state]\nbackend = \"memory\"\n").unwrap();
72        assert_eq!(
73            absent.content_hash().unwrap(),
74            explicit.content_hash().unwrap(),
75            "an explicit default [state] must not change the content hash"
76        );
77
78        // Choosing redb is a real config change → the hash flips. (Like [trust], the reload
79        // path rejects the change before hashing; the hash still reflects config identity.)
80        let redb = Manifest::from_toml("[state]\nbackend = \"redb\"\npath = \"s.redb\"\n").unwrap();
81        assert_ne!(
82            absent.content_hash().unwrap(),
83            redb.content_hash().unwrap(),
84            "a state-backend change must flip the content hash"
85        );
86    }
87}