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}