1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
//! Shape-stable storage diagnosis vocabulary and the [`StorageMigrator`]
//! diagnose seam (Phase 1 of the storage unification arc).
//!
//! `rkat storage doctor` renders a [`StorageDiagnosis`]; the disk
//! implementation lives in `meerkat-store::doctor`. The vocabulary lives
//! here, below every store crate, because remote storage providers and the
//! mobkit companion (its M1 doctor phase) bind to this shape *before* the
//! full Phase 4 provider trait exists — the report format is wire-stable
//! from Phase 1 on.
//!
//! Shape-stability rules:
//!
//! - Structs are `#[non_exhaustive]` with `#[serde(default)]` on optional
//! fields, so fields can be added compatibly. Construct via the provided
//! constructors, then set public fields.
//! - Finding `code`s are stable kebab-case strings (`"split-brain-realm"`,
//! `"schema-from-the-future"`, ...). Codes are added over time, never
//! renamed; consumers must tolerate unknown codes.
//! - [`StorageMigrator`] carries only the read-only `diagnose` verb in this
//! phase. Mutation verbs (migrate/prune) arrive with the Phase 6
//! migration framework as *defaulted* trait methods, so implementations
//! written against this trait stay source-compatible.
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
/// Severity of one storage finding.
///
/// `Error`-severity findings drive the doctor exit code (a report with at
/// least one error exits nonzero).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
/// Inventory-grade observation; no action needed.
Info,
/// Something an operator should look at; not necessarily broken.
Warning,
/// A broken or refusal-grade condition (split-brain twin, dangling blob
/// reference, schema from the future, ...).
Error,
}
/// One diagnostic finding.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageFinding {
/// Severity class.
pub severity: FindingSeverity,
/// Stable kebab-case code (e.g. `"split-brain-realm"`,
/// `"schema-from-the-future"`, `"legacy-unverified-sessions"`,
/// `"dangling-blob-reference"`, `"orphaned-lease"`,
/// `"no-schema-ledger"`, `"backup-artifact"`,
/// `"maintenance-fence-lock"`). Consumers must tolerate unknown codes.
pub code: String,
/// Human-readable description.
pub message: String,
/// Filesystem path the finding is about, when one exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
/// Realm id the finding is scoped to, when realm-scoped.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realm: Option<String>,
}
impl StorageFinding {
/// Construct a finding (path/realm attach via the builders).
pub fn new(
severity: FindingSeverity,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity,
code: code.into(),
message: message.into(),
path: None,
realm: None,
}
}
/// Attach the filesystem path this finding is about.
#[must_use]
pub fn with_path(mut self, path: PathBuf) -> Self {
self.path = Some(path);
self
}
/// Attach the realm id this finding is scoped to.
#[must_use]
pub fn with_realm(mut self, realm: impl Into<String>) -> Self {
self.realm = Some(realm.into());
self
}
}
/// Schema-ledger state of one database file.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseInventory {
/// Path of the database file.
pub path: PathBuf,
/// Ledger domain → version. `None` means the domain has no ledger row
/// (a pre-ledger file, or a domain whose store never touched this file).
#[serde(default)]
pub domains: Vec<(String, Option<i64>)>,
}
impl DatabaseInventory {
/// Construct an inventory row for one database file.
pub fn new(path: PathBuf) -> Self {
Self {
path,
domains: Vec::new(),
}
}
}
/// Inventory of one materialized realm under one state root.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageInventoryEntry {
/// Realm id (from the manifest; the sanitized directory name when the
/// manifest is unreadable).
pub realm: String,
/// The realm directory.
pub root: PathBuf,
/// Backend pinned in the realm manifest (`"sqlite"`, `"jsonl"`,
/// `"memory"`, ...); `None` when the manifest is unreadable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<String>,
/// Database files found under the realm directory.
#[serde(default)]
pub databases: Vec<DatabaseInventory>,
}
impl StorageInventoryEntry {
/// Construct an inventory entry for one realm directory.
pub fn new(realm: impl Into<String>, root: PathBuf) -> Self {
Self {
realm: realm.into(),
root,
backend: None,
databases: Vec::new(),
}
}
}
/// The full diagnosis report.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StorageDiagnosis {
/// Findings across all swept roots and realms.
#[serde(default)]
pub findings: Vec<StorageFinding>,
/// Per-realm inventory across all swept roots.
#[serde(default)]
pub inventory: Vec<StorageInventoryEntry>,
}
impl StorageDiagnosis {
/// Count findings of one severity.
pub fn count(&self, severity: FindingSeverity) -> usize {
self.findings
.iter()
.filter(|f| f.severity == severity)
.count()
}
/// True when at least one `Error`-severity finding exists (doctor exits
/// nonzero).
pub fn has_errors(&self) -> bool {
self.findings
.iter()
.any(|f| f.severity == FindingSeverity::Error)
}
}
/// What to diagnose.
///
/// Hermeticity contract: implementations read **only** the given
/// `state_roots` — never ambient candidate roots. The caller (CLI bootstrap,
/// a gateway) decides the candidate set.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiagnoseScope {
/// Candidate state roots to sweep (typically the project-local
/// `<context>/.rkat/realms` plus the user-global data root; or exactly
/// the roots an operator passed).
pub state_roots: Vec<PathBuf>,
/// Restrict the sweep to one realm id (twins for that realm are still
/// reported across all roots). `None` sweeps every realm.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realm: Option<String>,
}
impl DiagnoseScope {
/// Scope over the given candidate roots, sweeping all realms.
pub fn new(state_roots: Vec<PathBuf>) -> Self {
Self {
state_roots,
realm: None,
}
}
/// Restrict the sweep to one realm id.
#[must_use]
pub fn with_realm(mut self, realm: impl Into<String>) -> Self {
self.realm = Some(realm.into());
self
}
}
/// Failure producing a diagnosis at all.
///
/// Per-entry faults (a corrupt manifest, an unreadable database) are
/// *findings inside the report*, never this error — implementations must be
/// fault-tolerant per entry. This error is for total failures only (a remote
/// provider that cannot reach its backend, an unsupported scope).
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum StorageDiagnosticsError {
/// The provider does not support diagnosis for this scope.
#[error("storage diagnosis unsupported: {0}")]
Unsupported(String),
/// The provider failed to produce a report at all.
#[error("storage diagnosis failed: {0}")]
Backend(String),
}
/// Storage maintenance seam a storage provider exposes.
///
/// Phase 1 deliberately ships only the read-only `diagnose` verb, defined as
/// a small standalone trait so downstream consumers (mobkit M1, remote
/// providers) bind to the hook shape before the Phase 4 provider trait
/// (`RealmStorageProvider::migrator()`) exists. Do **not** add mutation
/// verbs here outside the Phase 6 migration framework; when they land they
/// will be defaulted methods so existing implementations stay
/// source-compatible.
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait StorageMigrator: Send + Sync {
/// Produce a read-only diagnosis of the given scope.
///
/// Implementations must be safe against live storage (no leases, no
/// exclusive locks, no writes) and fault-tolerant per entry: one corrupt
/// realm yields findings for that realm, never a total failure.
async fn diagnose(
&self,
scope: &DiagnoseScope,
) -> Result<StorageDiagnosis, StorageDiagnosticsError>;
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn finding_json_shape_is_stable() {
let finding = StorageFinding::new(
FindingSeverity::Error,
"split-brain-realm",
"realm 'team' exists under two roots",
)
.with_path(PathBuf::from("/a/team"))
.with_realm("team");
let json = serde_json::to_value(&finding).expect("serialize");
assert_eq!(
json,
serde_json::json!({
"severity": "error",
"code": "split-brain-realm",
"message": "realm 'team' exists under two roots",
"path": "/a/team",
"realm": "team",
})
);
}
#[test]
fn optional_fields_are_omitted_and_default_on_read() {
let finding = StorageFinding::new(FindingSeverity::Info, "no-schema-ledger", "pre-arc db");
let json = serde_json::to_string(&finding).expect("serialize");
assert!(!json.contains("path"));
assert!(!json.contains("realm"));
// Forward-compat: unknown fields tolerated, missing optionals default.
let parsed: StorageFinding = serde_json::from_str(
r#"{"severity":"warning","code":"orphaned-lease","message":"m","future_field":1}"#,
)
.expect("deserialize with unknown field");
assert_eq!(parsed.severity, FindingSeverity::Warning);
assert!(parsed.path.is_none());
}
#[test]
fn diagnosis_error_detection() {
let mut diagnosis = StorageDiagnosis::default();
assert!(!diagnosis.has_errors());
diagnosis.findings.push(StorageFinding::new(
FindingSeverity::Warning,
"orphaned-lease",
"1 stale lease",
));
assert!(!diagnosis.has_errors());
diagnosis.findings.push(StorageFinding::new(
FindingSeverity::Error,
"dangling-blob-reference",
"missing blob",
));
assert!(diagnosis.has_errors());
assert_eq!(diagnosis.count(FindingSeverity::Error), 1);
assert_eq!(diagnosis.count(FindingSeverity::Warning), 1);
assert_eq!(diagnosis.count(FindingSeverity::Info), 0);
}
#[test]
fn diagnosis_round_trips_through_json() {
let mut diagnosis = StorageDiagnosis::default();
let mut entry = StorageInventoryEntry::new("team", PathBuf::from("/roots/a/team"));
entry.backend = Some("sqlite".to_string());
let mut db = DatabaseInventory::new(PathBuf::from("/roots/a/team/sessions.sqlite3"));
db.domains.push(("session-store".to_string(), Some(1)));
db.domains.push(("schedule-store".to_string(), None));
entry.databases.push(db);
diagnosis.inventory.push(entry);
let json = serde_json::to_string(&diagnosis).expect("serialize");
let parsed: StorageDiagnosis = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.inventory.len(), 1);
assert_eq!(parsed.inventory[0].backend.as_deref(), Some("sqlite"));
assert_eq!(
parsed.inventory[0].databases[0].domains,
vec![
("session-store".to_string(), Some(1)),
("schedule-store".to_string(), None),
]
);
}
}