Skip to main content

mati_core/store/
durability.rs

1//! Write durability levels for the SurrealKV store.
2//!
3//! **Never mix these.** The split is load-bearing for performance:
4//! - `Immediate`: fsync on every write — slow but crash-safe.
5//! - `Eventual`: OS write buffer — fast but may lose the last ~10ms on crash.
6//!
7//! Assignment by key prefix (from ARCHITECTURE.md section 4):
8//! ```text
9//! Immediate  gotcha:*   decision:*   file:*   stage:*   dev_note:*   policy:*
10//! Eventual   session:*  analytics:*  hook_event:*  compliance:*  graph:edge:*
11//!            health:*   parse:*      audit:session:*
12//! ```
13//!
14//! `graph:edge:*` is Eventual because edges are derived data (re-computed from
15//! source on `mati init`) — they are not irreplaceable like user-authored records.
16//! Losing a few edges on an OS crash costs one `mati init` re-run, not lost knowledge.
17
18/// How a key's value is encoded on disk.
19///
20/// The store keeps two encodings in one flat keyspace and distinguishes them by
21/// prefix alone. Mixing them is not a type error, it is a silent one: a raw
22/// scalar written into a `Record` namespace is skipped by `scan_prefix` with a
23/// warning, and a `Record` written over a raw scalar is unreadable by the
24/// getter that expects bytes. `policy:mode` did exactly this, colliding with
25/// `policy:<slug>` records so that setting the posture destroyed a policy.
26///
27/// This table is the invariant. `Store` debug-asserts against it, so a new key
28/// in the wrong namespace fails a test run rather than corrupting a store.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Encoding {
31    /// msgpack-serialized `Record`. Written by `put`, read by `get`/`scan_prefix`.
32    Record,
33    /// Bare bytes whose meaning is prefix-specific: a timestamp, a counter, a
34    /// config scalar, or a JSON blob. Written by `put_raw`, read by
35    /// `get_raw_bytes`/`scan_keys`.
36    Raw,
37}
38
39impl Encoding {
40    /// Infer a key's value encoding from its prefix.
41    ///
42    /// Unknown prefixes default to `Record`, which matches `for_key` defaulting
43    /// to `Immediate`: a new knowledge namespace is the common case, and the
44    /// assertion below turns a wrong guess into a failing test.
45    pub fn for_key(key: &str) -> Self {
46        // `system:` is mixed and cannot be classified by prefix:
47        // `system:schema_version` is a Record while `system:installation_id` is
48        // a raw string. That is the same hazard `policy:mode` had, and it only
49        // stays harmless because nothing scans a bare `system:` prefix. The
50        // exact-key exception below is the honest encoding of that; splitting
51        // the namespace would be cleaner but installation_id shipped in v0.1.3
52        // and moving it needs a migration, not a rename.
53        if key == "system:installation_id" {
54            return Self::Raw;
55        }
56        if key.starts_with("graph:edge:")
57            || key.starts_with("audit:")
58            || key.starts_with("enforcement:")
59        {
60            Self::Raw
61        } else {
62            Self::Record
63        }
64    }
65}
66
67/// Controls whether a `Store::put` call fsyncs before returning.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Durability {
70    /// fsync after write. Use for all user-visible knowledge records.
71    /// Correct for: `gotcha:*`, `decision:*`, `file:*`, `stage:*`, `dev_note:*`,
72    /// `policy:*`. `policy:*` reaches this through `for_key`'s unknown-prefix
73    /// default, not an explicit branch — an explicit branch must keep it here.
74    Immediate,
75    /// OS write buffer only. Fast path for high-frequency internal writes.
76    /// Correct for: `session:*`, `analytics:*`, `hook_event:*`, `compliance:*`,
77    /// `graph:edge:*`, `health:*`, `parse:*`, `audit:session:*`.
78    Eventual,
79}
80
81impl Durability {
82    /// Infer durability from a record key prefix.
83    ///
84    /// Unknown prefixes default to `Immediate` (safe over sorry).
85    pub fn for_key(key: &str) -> Self {
86        if key.starts_with("session:")
87            || key.starts_with("analytics:")
88            || key.starts_with("hook_event:")
89            || key.starts_with("compliance:")
90            || key.starts_with("graph:edge:")
91            || key.starts_with("health:") // derived/computed data, fully recomputable
92            || key.starts_with("parse:")  // file content hashes — recomputable on re-init
93            || key.starts_with("audit:session:")
94        // session-side audit — co-located with session mutations
95        {
96            Self::Eventual
97        } else {
98            Self::Immediate
99        }
100    }
101}
102
103#[cfg(test)]
104mod encoding_tests {
105    use super::Encoding;
106
107    #[test]
108    fn record_namespaces_are_record_encoded() {
109        for key in [
110            "gotcha:x",
111            "file:src/main.rs",
112            "decision:y",
113            "dev_note:z",
114            "dep:cargo:serde",
115            "stage:current",
116            "policy:production-query-safety",
117            "policy:mode",
118            "session:consulted:schema:x",
119            "analytics:hit_2026-07-22",
120            "health:score",
121            "parse:src/main.rs",
122            "compliance:miss_2026-07-22",
123            "system:schema_version",
124        ] {
125            assert_eq!(Encoding::for_key(key), Encoding::Record, "{key}");
126        }
127    }
128
129    #[test]
130    fn raw_namespaces_are_raw_encoded() {
131        for key in [
132            "graph:edge:file:a:imports:file:b",
133            "audit:knowledge:1",
134            "audit:session:1",
135            "enforcement:seq",
136            "enforcement:event:00000000000000000001",
137            "enforcement:mode",
138            "enforcement:policy_mode",
139            "enforcement:retention_days",
140            "system:installation_id",
141        ] {
142            assert_eq!(Encoding::for_key(key), Encoding::Raw, "{key}");
143        }
144    }
145
146    /// The bug this table exists to prevent: a config scalar named into the
147    /// policy record namespace. `policy:mode` must read as Record, so writing a
148    /// raw posture value there trips the assertion in `Store::put_raw` instead
149    /// of silently destroying the policy slugged `mode`.
150    #[test]
151    fn a_config_scalar_may_not_hide_in_a_record_namespace() {
152        assert_eq!(Encoding::for_key("policy:mode"), Encoding::Record);
153        assert_eq!(Encoding::for_key("enforcement:policy_mode"), Encoding::Raw);
154    }
155
156    /// `system:` is mixed, so it must not be classified by prefix alone.
157    #[test]
158    fn system_namespace_is_split_by_exact_key() {
159        assert_eq!(Encoding::for_key("system:installation_id"), Encoding::Raw);
160        assert_eq!(Encoding::for_key("system:schema_version"), Encoding::Record);
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn immediate_keys() {
170        assert_eq!(
171            Durability::for_key("gotcha:inference-async"),
172            Durability::Immediate
173        );
174        assert_eq!(
175            Durability::for_key("decision:storage-engine"),
176            Durability::Immediate
177        );
178        assert_eq!(
179            Durability::for_key("file:src/main.rs"),
180            Durability::Immediate
181        );
182        assert_eq!(Durability::for_key("stage:current"), Durability::Immediate);
183        assert_eq!(
184            Durability::for_key("dev_note:dont-refactor"),
185            Durability::Immediate
186        );
187        assert_eq!(
188            Durability::for_key("policy:production-query-safety"),
189            Durability::Immediate
190        );
191    }
192
193    #[test]
194    fn eventual_keys() {
195        assert_eq!(
196            Durability::for_key("session:1710520800"),
197            Durability::Eventual
198        );
199        assert_eq!(
200            Durability::for_key("analytics:tokens_saved_total"),
201            Durability::Eventual
202        );
203        assert_eq!(
204            Durability::for_key("hook_event:pre_read"),
205            Durability::Eventual
206        );
207        assert_eq!(
208            Durability::for_key("compliance:2026-03-15"),
209            Durability::Eventual
210        );
211    }
212
213    #[test]
214    fn unknown_prefix_defaults_to_immediate() {
215        assert_eq!(
216            Durability::for_key("unknown:something"),
217            Durability::Immediate
218        );
219    }
220
221    // ── Key shape edge cases ─────────────────────────────────────────────────
222
223    #[test]
224    fn graph_edge_key_is_eventual() {
225        // Edges are derived data — Eventual so bulk init avoids per-edge fsyncs.
226        assert_eq!(
227            Durability::for_key("graph:edge:src/main.rs:HasGotcha:gotcha:inference-async"),
228            Durability::Eventual
229        );
230    }
231
232    #[test]
233    fn empty_key_defaults_to_immediate() {
234        // Empty string matches no eventual prefix → safe fallback.
235        assert_eq!(Durability::for_key(""), Durability::Immediate);
236    }
237
238    #[test]
239    fn key_without_colon_defaults_to_immediate() {
240        // A bare word with no colon cannot match any "prefix:" pattern.
241        assert_eq!(Durability::for_key("gotcha"), Durability::Immediate);
242        assert_eq!(Durability::for_key("session"), Durability::Immediate);
243    }
244
245    #[test]
246    fn prefix_only_eventual_keys_are_eventual() {
247        // The bare prefix (no timestamp/slug suffix) still routes correctly.
248        assert_eq!(Durability::for_key("session:"), Durability::Eventual);
249        assert_eq!(Durability::for_key("analytics:"), Durability::Eventual);
250        assert_eq!(Durability::for_key("hook_event:"), Durability::Eventual);
251        assert_eq!(Durability::for_key("compliance:"), Durability::Eventual);
252    }
253
254    #[test]
255    fn all_immediate_prefixes_from_architecture_doc() {
256        // Every Immediate prefix listed in ARCHITECTURE.md section 4 must route correctly.
257        let cases = [
258            "gotcha:inference-async",
259            "decision:storage-engine",
260            "file:src/store/db.rs",
261            "stage:current",
262            "dev_note:no-refactor",
263            "dep:cargo:tokio",
264        ];
265        for key in cases {
266            assert_eq!(
267                Durability::for_key(key),
268                Durability::Immediate,
269                "expected Immediate for '{key}'"
270            );
271        }
272    }
273
274    #[test]
275    fn eventual_prefix_requires_exact_colon_boundary() {
276        // "session_v2:x" starts with "session" but NOT "session:" → Immediate.
277        // This guards against accidental prefix collision with future namespaces.
278        assert_eq!(
279            Durability::for_key("session_v2:something"),
280            Durability::Immediate
281        );
282        assert_eq!(
283            Durability::for_key("analytics_v2:something"),
284            Durability::Immediate
285        );
286        assert_eq!(
287            Durability::for_key("hook_event_extra:x"),
288            Durability::Immediate
289        );
290    }
291
292    #[test]
293    fn all_eventual_prefixes_from_architecture_doc() {
294        let cases = [
295            "session:1710520800",
296            "analytics:tokens_saved_total",
297            "hook_event:pre_read",
298            "compliance:2026-03-15",
299            "graph:edge:file:src/main.rs:imports:file:src/lib.rs",
300        ];
301        for key in cases {
302            assert_eq!(
303                Durability::for_key(key),
304                Durability::Eventual,
305                "expected Eventual for '{key}'"
306            );
307        }
308    }
309
310    #[test]
311    fn key_containing_eventual_prefix_as_embedded_substring_is_immediate() {
312        // "gotcha:session:something" contains "session:" but does NOT start_with it.
313        // Must route to Immediate (knowledge tree), not Eventual (sessions tree).
314        assert_eq!(
315            Durability::for_key("gotcha:session:something"),
316            Durability::Immediate,
317            "embedded 'session:' must not trigger Eventual routing"
318        );
319        assert_eq!(
320            Durability::for_key("file:analytics:performance.rs"),
321            Durability::Immediate,
322            "embedded 'analytics:' must not trigger Eventual routing"
323        );
324        assert_eq!(
325            Durability::for_key("decision:hook_event:design"),
326            Durability::Immediate,
327            "embedded 'hook_event:' must not trigger Eventual routing"
328        );
329    }
330
331    // ── Audit routing tests ─────────────────────────────────────────────
332
333    #[test]
334    fn audit_session_is_eventual() {
335        // Session-side audit co-locates with session mutations.
336        assert_eq!(
337            Durability::for_key("audit:session:1234567890"),
338            Durability::Eventual,
339            "audit:session:* must route to sessions tree"
340        );
341    }
342
343    #[test]
344    fn audit_knowledge_is_immediate() {
345        // Knowledge-side audit co-locates with knowledge mutations.
346        // "audit:knowledge:*" does NOT match any Eventual prefix → Immediate.
347        assert_eq!(
348            Durability::for_key("audit:knowledge:1234567890"),
349            Durability::Immediate,
350            "audit:knowledge:* must route to knowledge tree"
351        );
352    }
353
354    #[test]
355    fn audit_bare_prefix_is_immediate() {
356        // "audit:" alone doesn't match "audit:session:" → Immediate.
357        assert_eq!(
358            Durability::for_key("audit:something"),
359            Durability::Immediate,
360            "unknown audit prefix must default to Immediate"
361        );
362    }
363}