Skip to main content

meerkat_mobkit/
storage_health.rs

1//! Composition-time storage durability resolution (H1/H2 hotfixes plus the
2//! M4 per-slot census of the storage-unification arc).
3//!
4//! Every durable slot must resolve to a configured backend, an explicitly
5//! declared ephemeral choice, or a startup error — never a silent fallback.
6//! This module carries the vocabulary:
7//!
8//! - **Blobs (H1)**: [`BlobDurability`] records what the blob slot resolved
9//!   to; [`BlobStoreResolutionError`] is the fail-closed startup error a
10//!   persistent-mode runtime returns instead of the former silent in-memory
11//!   fallback.
12//! - **Session persistence (H2)**: [`probe_session_store_incremental`]
13//!   duplicates the capability probe `PersistentSessionService` runs
14//!   privately, so the whole-blob degradation it silently accepts is logged
15//!   at startup and visible on the health surfaces.
16//! - **Runtime store (M4)**: [`RuntimeStoreResolutionError`] is the
17//!   fail-closed startup error replacing the former silent
18//!   `SqliteRuntimeStore` → `InMemoryRuntimeStore` fallback; an in-memory
19//!   runtime store is constructible only by declaration.
20//! - **Per-slot census (M4)**: [`StorageSlotSummary`] records what every
21//!   composed storage slot resolved to (backend, durability class,
22//!   resolution, sanctioned degradations) using meerkat's machine-readable
23//!   [`meerkat_core::DurabilityDeclaration`] vocabulary.
24//!
25//! The resolved [`ResolvedStorageSummary`] rides the bootstrap spec onto the
26//! runtime and is reported by `mobkit/status` / `mobkit/capabilities`.
27
28use std::path::PathBuf;
29use std::sync::Arc;
30
31use meerkat::SessionStore;
32use meerkat_core::{DurabilityClass, DurabilityDeclaration, DurabilityResolution};
33
34/// How the runtime's blob slot was resolved at composition time.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum BlobDurability {
37    /// Disk-backed object store under the state directory.
38    PersistentDisk,
39    /// In-memory blobs as an explicitly declared choice — the ephemeral
40    /// launch modes, or `UnifiedRuntimeBuilder::ephemeral_blobs(true)`.
41    DeclaredEphemeral,
42    /// Caller-injected blob store; `persistent` mirrors its
43    /// `is_persistent()` report.
44    Custom { persistent: bool },
45}
46
47impl BlobDurability {
48    /// Stable wire spelling used by the health surfaces.
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            Self::PersistentDisk => "persistent_disk",
52            Self::DeclaredEphemeral => "declared_ephemeral",
53            Self::Custom { .. } => "custom",
54        }
55    }
56
57    /// Whether the resolved blob store survives process restart.
58    pub fn is_persistent(&self) -> bool {
59        match self {
60            Self::PersistentDisk => true,
61            Self::DeclaredEphemeral => false,
62            Self::Custom { persistent } => *persistent,
63        }
64    }
65}
66
67/// One composed storage slot's resolution record: meerkat's machine-readable
68/// durability declaration plus the concrete backend and any sanctioned
69/// degradation detail. Recorded per slot at composition time (M4).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct StorageSlotSummary {
72    /// Domain name, durability class, and resolution (meerkat vocabulary).
73    pub declaration: DurabilityDeclaration,
74    /// Human-readable backend name (`"SqliteRuntimeStore"`,
75    /// `"InMemoryConsoleLogStore (declared default)"`, ...).
76    pub backend: String,
77    /// Extra context: the sanctioned boot-without degradation reason, the
78    /// declared-default rationale, or a provider note.
79    pub detail: Option<String>,
80    /// True for the sanctioned boot-without degradations (schedule /
81    /// workgraph store open failure): the feature is disabled and the slot
82    /// is health-visible instead of a warn line, per the storage plan.
83    pub degraded: bool,
84}
85
86impl StorageSlotSummary {
87    /// A slot backed by persistent storage.
88    pub fn persistent(domain: &str, backend: impl Into<String>) -> Self {
89        Self {
90            declaration: DurabilityDeclaration::durable(domain, DurabilityResolution::Persistent),
91            backend: backend.into(),
92            detail: None,
93            degraded: false,
94        }
95    }
96
97    /// A durable-class slot resolving non-persistent as an explicit,
98    /// documented choice (declared ephemeral / declared default).
99    pub fn declared_ephemeral(
100        domain: &str,
101        backend: impl Into<String>,
102        detail: impl Into<String>,
103    ) -> Self {
104        Self {
105            declaration: DurabilityDeclaration::durable(
106                domain,
107                DurabilityResolution::DeclaredEphemeral,
108            ),
109            backend: backend.into(),
110            detail: Some(detail.into()),
111            degraded: false,
112        }
113    }
114
115    /// The sanctioned boot-without degradation (schedule / workgraph store
116    /// open failure): the feature is disabled, the slot resolves
117    /// non-persistent, and the record is health-visible.
118    pub fn degraded(domain: &str, detail: impl Into<String>) -> Self {
119        Self {
120            declaration: DurabilityDeclaration::durable(
121                domain,
122                DurabilityResolution::NonPersistent,
123            ),
124            backend: "disabled".to_string(),
125            detail: Some(detail.into()),
126            degraded: true,
127        }
128    }
129
130    /// Attach (or replace) the free-form detail note.
131    #[must_use]
132    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
133        self.detail = Some(detail.into());
134        self
135    }
136
137    /// A Scratch-class slot: ephemeral by design (the in-process ring
138    /// buffers), classified explicitly so the non-durability is a documented
139    /// decision rather than an accident.
140    pub fn scratch(domain: &str, backend: impl Into<String>, detail: impl Into<String>) -> Self {
141        Self {
142            declaration: DurabilityDeclaration {
143                domain: domain.to_string(),
144                class: DurabilityClass::Scratch,
145                resolution: DurabilityResolution::DeclaredEphemeral,
146            },
147            backend: backend.into(),
148            detail: Some(detail.into()),
149            degraded: false,
150        }
151    }
152
153    fn status_json(&self) -> serde_json::Value {
154        let mut object = serde_json::json!({
155            "domain": self.declaration.domain,
156            "class": serde_json::to_value(self.declaration.class)
157                .unwrap_or(serde_json::Value::Null),
158            "resolution": serde_json::to_value(self.declaration.resolution)
159                .unwrap_or(serde_json::Value::Null),
160            "backend": self.backend,
161            "degraded": self.degraded,
162        });
163        if let (Some(detail), Some(map)) = (self.detail.as_ref(), object.as_object_mut()) {
164            map.insert(
165                "detail".to_string(),
166                serde_json::Value::String(detail.clone()),
167            );
168        }
169        object
170    }
171}
172
173/// Project the H1 [`BlobDurability`] resolution onto its slot-census entry.
174pub fn blob_slot_summary(durability: BlobDurability) -> StorageSlotSummary {
175    match durability {
176        BlobDurability::PersistentDisk => {
177            StorageSlotSummary::persistent("blobs", "ObjectStoreBlobStore (local disk)")
178        }
179        BlobDurability::DeclaredEphemeral => StorageSlotSummary::declared_ephemeral(
180            "blobs",
181            "ObjectStoreBlobStore (memory)",
182            "explicitly declared (ephemeral launch mode or ephemeral_blobs(true))",
183        ),
184        BlobDurability::Custom { persistent: true } => {
185            StorageSlotSummary::persistent("blobs", "custom blob store")
186                .with_detail("caller-injected store reporting is_persistent()")
187        }
188        BlobDurability::Custom { persistent: false } => StorageSlotSummary::declared_ephemeral(
189            "blobs",
190            "custom blob store",
191            "caller-injected store reports !is_persistent()",
192        ),
193    }
194}
195
196/// The three in-process ring buffers (`MobkitRuntimeHandle` state), classified
197/// `Scratch` explicitly: bounded drop-oldest retention, no store seam. A
198/// durable gating-audit slot is a flagged candidate follow-up of the storage
199/// plan, deliberately not part of this arc.
200pub fn scratch_ring_buffer_slots() -> Vec<StorageSlotSummary> {
201    vec![
202        StorageSlotSummary::scratch(
203            "gating_audit",
204            "in-process ring buffer",
205            "drop-oldest retention (512 entries); durable audit slot is a flagged follow-up",
206        ),
207        StorageSlotSummary::scratch(
208            "delivery_history",
209            "in-process ring buffer",
210            "drop-oldest retention (200 entries)",
211        ),
212        StorageSlotSummary::scratch(
213            "routing_resolutions",
214            "in-process ring buffer",
215            "drop-oldest retention (512 entries)",
216        ),
217    ]
218}
219
220/// Composition-time storage resolution summary, recorded when the runtime's
221/// stores are composed and surfaced through `mobkit/status` and
222/// `mobkit/capabilities`.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct ResolvedStorageSummary {
225    /// What the blob slot resolved to (H1).
226    pub blob_durability: BlobDurability,
227    /// Whether the session store behind the persistent session service
228    /// advertises the incremental-persistence capability (H2). `None` when
229    /// the runtime persists no sessions (ephemeral session service).
230    pub session_store_incremental: Option<bool>,
231    /// Per-slot durability census (M4). Additive: surfaces emitting the
232    /// summary before the census existed keep their wire shape and gain a
233    /// `"slots"` array.
234    pub slots: Vec<StorageSlotSummary>,
235    /// The state directory this runtime's stores resolved under (`None` =
236    /// the composing surface recorded none). Consumed by the storage-doctor
237    /// RPC to prove a requested state_dir is this runtime's own before
238    /// attaching the live census; never part of `status_json`.
239    pub state_dir: Option<PathBuf>,
240}
241
242impl ResolvedStorageSummary {
243    /// The pre-census (H1/H2) summary shape: blob durability + the
244    /// incremental probe, with an empty slot census.
245    pub fn new(blob_durability: BlobDurability, session_store_incremental: Option<bool>) -> Self {
246        Self {
247            blob_durability,
248            session_store_incremental,
249            slots: Vec::new(),
250            state_dir: None,
251        }
252    }
253
254    /// Attach the per-slot census.
255    #[must_use]
256    pub fn with_slots(mut self, slots: Vec<StorageSlotSummary>) -> Self {
257        self.slots = slots;
258        self
259    }
260
261    /// Record the state directory the stores resolved under (the doctor
262    /// RPC's own-directory census guard).
263    #[must_use]
264    pub fn with_state_dir(mut self, state_dir: impl Into<PathBuf>) -> Self {
265        self.state_dir = Some(state_dir.into());
266        self
267    }
268
269    /// The `"storage"` object shared by every `mobkit/status` /
270    /// `mobkit/capabilities` handler, so the three status shapes stay
271    /// field-consistent.
272    pub fn status_json(&self) -> serde_json::Value {
273        serde_json::json!({
274            "blob_durability": self.blob_durability.as_str(),
275            "blob_store_persistent": self.blob_durability.is_persistent(),
276            "session_store_incremental": self.session_store_incremental,
277            "slots": self
278                .slots
279                .iter()
280                .map(StorageSlotSummary::status_json)
281                .collect::<Vec<_>>(),
282        })
283    }
284}
285
286/// Fail-closed blob-slot resolution failure (H1).
287#[derive(Debug)]
288pub enum BlobStoreResolutionError {
289    /// The local blob directory under the persistent state path failed to
290    /// open. Formerly a silent in-memory fallback; now a startup error.
291    OpenFailed { path: PathBuf, message: String },
292    /// Persistent mode resolved a blob store that reports
293    /// `!is_persistent()` without the explicit ephemeral-blobs declaration.
294    NonPersistentUndeclared,
295}
296
297impl std::fmt::Display for BlobStoreResolutionError {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        match self {
300            Self::OpenFailed { path, message } => write!(
301                f,
302                "failed to open persistent binary blob store at {}: {message} \
303                 (fix the blob directory, or declare in-memory blobs explicitly \
304                 via ephemeral_blobs(true))",
305                path.display()
306            ),
307            Self::NonPersistentUndeclared => write!(
308                f,
309                "persistent mode resolved a blob store that reports \
310                 !is_persistent(); blobs would silently vanish on restart. \
311                 Provide a persistent blob store, or declare the ephemeral \
312                 choice explicitly via ephemeral_blobs(true)"
313            ),
314        }
315    }
316}
317
318impl std::error::Error for BlobStoreResolutionError {}
319
320/// Fail-closed runtime-store resolution failure (M4, the "fifth fallback").
321///
322/// Formerly a `tracing::warn!` plus a silent `InMemoryRuntimeStore` twin —
323/// a degraded mode in which resume across restart and archive operations
324/// fail long after boot. Now a startup error; an in-memory runtime store
325/// remains constructible only as an explicit declaration
326/// (`UnifiedRuntimeBuilder::ephemeral_runtime_store(true)`, or the gateway's
327/// `runtime_options.runtime_store = {"storage": "memory"}`).
328#[derive(Debug)]
329pub struct RuntimeStoreResolutionError {
330    pub path: PathBuf,
331    pub message: String,
332}
333
334impl std::fmt::Display for RuntimeStoreResolutionError {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        write!(
337            f,
338            "failed to open the persistent runtime store at {}: {} \
339             (sessions would not survive restart and archive operations would \
340             fail; fix the database file, or declare an in-memory runtime \
341             store explicitly via ephemeral_runtime_store(true) / \
342             runtime_options.runtime_store = {{\"storage\": \"memory\"}})",
343            self.path.display(),
344            self.message
345        )
346    }
347}
348
349impl std::error::Error for RuntimeStoreResolutionError {}
350
351/// Fail-closed canonical detached-job store open failure.
352#[derive(Debug)]
353pub struct JobStoreResolutionError {
354    pub path: PathBuf,
355    pub message: String,
356}
357
358impl std::fmt::Display for JobStoreResolutionError {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        write!(
361            f,
362            "failed to open the canonical detached-job store at {}: {} \
363             (durable detached admission is unavailable; fix the database file)",
364            self.path.display(),
365            self.message
366        )
367    }
368}
369
370impl std::error::Error for JobStoreResolutionError {}
371
372/// Composite fail-closed storage composition failure for the persistent
373/// bootstrap path: any durable slot that can refuse at composition time.
374#[derive(Debug)]
375pub enum StorageResolutionError {
376    Blob(BlobStoreResolutionError),
377    RuntimeStore(RuntimeStoreResolutionError),
378    JobStore(JobStoreResolutionError),
379}
380
381impl std::fmt::Display for StorageResolutionError {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            Self::Blob(error) => error.fmt(f),
385            Self::RuntimeStore(error) => error.fmt(f),
386            Self::JobStore(error) => error.fmt(f),
387        }
388    }
389}
390
391impl std::error::Error for StorageResolutionError {}
392
393impl From<BlobStoreResolutionError> for StorageResolutionError {
394    fn from(error: BlobStoreResolutionError) -> Self {
395        Self::Blob(error)
396    }
397}
398
399impl From<RuntimeStoreResolutionError> for StorageResolutionError {
400    fn from(error: RuntimeStoreResolutionError) -> Self {
401        Self::RuntimeStore(error)
402    }
403}
404
405impl From<JobStoreResolutionError> for StorageResolutionError {
406    fn from(error: JobStoreResolutionError) -> Self {
407        Self::JobStore(error)
408    }
409}
410
411/// Probe a session store's incremental-persistence capability before handing
412/// it to `PersistentSessionService`.
413///
414/// The service runs the same probe privately and silently degrades to
415/// whole-blob persistence (O(session) written per turn) when the capability
416/// is absent; this duplicate probe on the same `Arc` makes that degradation
417/// loud at startup and feeds the `session_store_incremental` health flag.
418/// `store_kind` names the concrete store in the warning (the caller knows
419/// which store it composed; the trait object does not).
420pub fn probe_session_store_incremental(store: &Arc<dyn SessionStore>, store_kind: &str) -> bool {
421    let incremental = Arc::clone(store).as_incremental().is_some();
422    if !incremental {
423        tracing::warn!(
424            session_store = store_kind,
425            "session store does not advertise incremental persistence; \
426             session persistence degrades to whole-blob saves on every turn \
427             (incremental capability absent)"
428        );
429    }
430    incremental
431}
432
433#[cfg(test)]
434#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
435mod tests {
436    use super::*;
437
438    /// Captures formatted tracing output so the tests can assert on the
439    /// startup warning text.
440    #[derive(Clone, Default)]
441    struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
442
443    impl CaptureWriter {
444        fn contents(&self) -> String {
445            String::from_utf8_lossy(
446                &self
447                    .0
448                    .lock()
449                    .unwrap_or_else(std::sync::PoisonError::into_inner),
450            )
451            .into_owned()
452        }
453    }
454
455    impl std::io::Write for CaptureWriter {
456        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
457            self.0
458                .lock()
459                .unwrap_or_else(std::sync::PoisonError::into_inner)
460                .extend_from_slice(buf);
461            Ok(buf.len())
462        }
463
464        fn flush(&mut self) -> std::io::Result<()> {
465            Ok(())
466        }
467    }
468
469    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
470        type Writer = CaptureWriter;
471
472        fn make_writer(&'a self) -> Self::Writer {
473            self.clone()
474        }
475    }
476
477    fn probe_with_captured_warnings(
478        store: &Arc<dyn SessionStore>,
479        store_kind: &str,
480    ) -> (bool, String) {
481        let writer = CaptureWriter::default();
482        let subscriber = tracing_subscriber::fmt()
483            .with_writer(writer.clone())
484            .with_max_level(tracing::Level::WARN)
485            .finish();
486        let incremental = tracing::subscriber::with_default(subscriber, || {
487            probe_session_store_incremental(store, store_kind)
488        });
489        (incremental, writer.contents())
490    }
491
492    /// H2 (flipped by M4b): the continuity adapter over the bundled local
493    /// store now advertises the session-delta channel, so the probe reports
494    /// incremental persistence with NO whole-blob degradation warning.
495    #[tokio::test]
496    async fn probe_reports_continuity_adapter_as_incremental_without_warning() {
497        let dir = tempfile::tempdir().expect("temp dir");
498        let (store, _fencing_floor) =
499            crate::identity_first::LocalContinuityStore::open_with_fencing_floor(
500                dir.path().join("continuity.sqlite"),
501            )
502            .await
503            .expect("open continuity store");
504        let adapter: Arc<dyn SessionStore> = Arc::new(
505            crate::identity_first::ContinuitySessionStoreAdapter::new(Arc::new(store)),
506        );
507
508        let (incremental, warnings) =
509            probe_with_captured_warnings(&adapter, "ContinuitySessionStoreAdapter");
510        assert!(
511            incremental,
512            "the continuity adapter over the bundled store advertises incremental persistence"
513        );
514        assert!(
515            warnings.is_empty(),
516            "no whole-blob degradation warning expected, got: {warnings}"
517        );
518    }
519
520    /// The degradation warning is still load-bearing for substrates that
521    /// genuinely have no delta channel (the wire-verb `GatewayContinuityStore`
522    /// shape): the probe must still say so loudly, naming the store kind and
523    /// the whole-blob consequence.
524    #[test]
525    fn probe_still_warns_for_a_whole_blob_only_store() {
526        struct WholeBlobOnlyStore;
527
528        #[async_trait::async_trait]
529        impl SessionStore for WholeBlobOnlyStore {
530            async fn save(
531                &self,
532                _session: &meerkat_core::Session,
533            ) -> Result<(), meerkat_core::SessionStoreError> {
534                Ok(())
535            }
536
537            async fn load(
538                &self,
539                _id: &meerkat_core::types::SessionId,
540            ) -> Result<Option<meerkat_core::Session>, meerkat_core::SessionStoreError>
541            {
542                Ok(None)
543            }
544
545            async fn list(
546                &self,
547                _filter: meerkat_core::SessionFilter,
548            ) -> Result<Vec<meerkat_core::SessionMeta>, meerkat_core::SessionStoreError>
549            {
550                Ok(Vec::new())
551            }
552
553            async fn delete(
554                &self,
555                _id: &meerkat_core::types::SessionId,
556            ) -> Result<(), meerkat_core::SessionStoreError> {
557                Ok(())
558            }
559
560            async fn delete_if_current_revision(
561                &self,
562                _id: &meerkat_core::types::SessionId,
563                _expected_current_revision: &str,
564            ) -> Result<bool, meerkat_core::SessionStoreError> {
565                Ok(false)
566            }
567        }
568
569        let store: Arc<dyn SessionStore> = Arc::new(WholeBlobOnlyStore);
570        let (incremental, warnings) = probe_with_captured_warnings(&store, "WholeBlobOnlyStore");
571        assert!(!incremental);
572        assert!(
573            warnings.contains("whole-blob"),
574            "the startup warning must name the consequence, got: {warnings}"
575        );
576        assert!(
577            warnings.contains("WholeBlobOnlyStore"),
578            "the startup warning must name the store kind, got: {warnings}"
579        );
580    }
581
582    /// H2: an incremental-capable store probes true with no warning.
583    #[test]
584    fn probe_reports_incremental_sqlite_store_without_warning() {
585        let dir = tempfile::tempdir().expect("temp dir");
586        let store: Arc<dyn SessionStore> = Arc::new(
587            meerkat_store::SqliteSessionStore::open(dir.path().join("sessions.db"))
588                .expect("open sqlite session store"),
589        );
590
591        let (incremental, warnings) = probe_with_captured_warnings(&store, "SqliteSessionStore");
592        assert!(incremental, "SqliteSessionStore advertises as_incremental");
593        assert!(
594            warnings.is_empty(),
595            "no degradation warning expected, got: {warnings}"
596        );
597    }
598
599    #[test]
600    fn blob_durability_wire_spellings_are_stable() {
601        assert_eq!(BlobDurability::PersistentDisk.as_str(), "persistent_disk");
602        assert_eq!(
603            BlobDurability::DeclaredEphemeral.as_str(),
604            "declared_ephemeral"
605        );
606        assert_eq!(
607            BlobDurability::Custom { persistent: true }.as_str(),
608            "custom"
609        );
610        assert!(BlobDurability::PersistentDisk.is_persistent());
611        assert!(!BlobDurability::DeclaredEphemeral.is_persistent());
612        assert!(BlobDurability::Custom { persistent: true }.is_persistent());
613        assert!(!BlobDurability::Custom { persistent: false }.is_persistent());
614    }
615
616    #[test]
617    fn status_json_carries_all_health_fields() {
618        let summary = ResolvedStorageSummary::new(BlobDurability::DeclaredEphemeral, None);
619        let json = summary.status_json();
620        assert_eq!(json["blob_durability"], "declared_ephemeral");
621        assert_eq!(json["blob_store_persistent"], false);
622        assert!(json["session_store_incremental"].is_null());
623        assert_eq!(json["slots"], serde_json::json!([]));
624
625        let summary = ResolvedStorageSummary::new(BlobDurability::PersistentDisk, Some(true));
626        let json = summary.status_json();
627        assert_eq!(json["blob_durability"], "persistent_disk");
628        assert_eq!(json["blob_store_persistent"], true);
629        assert_eq!(json["session_store_incremental"], true);
630    }
631
632    /// M4: the per-slot census rides the same `"storage"` object additively —
633    /// pre-census fields keep their spellings; each slot entry carries the
634    /// meerkat durability vocabulary plus backend and degradation facts.
635    #[test]
636    fn status_json_slot_census_is_additive_and_machine_readable() {
637        let summary = ResolvedStorageSummary::new(BlobDurability::PersistentDisk, Some(true))
638            .with_slots(vec![
639                StorageSlotSummary::persistent("runtime", "SqliteRuntimeStore"),
640                StorageSlotSummary::declared_ephemeral(
641                    "metadata",
642                    "InMemoryMetadataStore (declared default)",
643                    "this surface keeps metadata in-memory by contract",
644                ),
645                StorageSlotSummary::degraded("schedule", "schedule store failed to open: disk"),
646                StorageSlotSummary::scratch("gating_audit", "in-process ring buffer", "512"),
647            ]);
648        let json = summary.status_json();
649        let slots = json["slots"].as_array().expect("slots array");
650        assert_eq!(slots.len(), 4);
651        assert_eq!(slots[0]["domain"], "runtime");
652        assert_eq!(slots[0]["class"], "durable");
653        assert_eq!(slots[0]["resolution"], "persistent");
654        assert_eq!(slots[0]["backend"], "SqliteRuntimeStore");
655        assert_eq!(slots[0]["degraded"], false);
656        assert!(slots[0].get("detail").is_none());
657        assert_eq!(slots[1]["resolution"], "declared_ephemeral");
658        assert_eq!(slots[2]["resolution"], "non_persistent");
659        assert_eq!(slots[2]["degraded"], true);
660        assert_eq!(slots[2]["backend"], "disabled");
661        assert_eq!(slots[3]["class"], "scratch");
662    }
663
664    #[test]
665    fn runtime_store_resolution_error_names_the_remediation() {
666        let error = RuntimeStoreResolutionError {
667            path: PathBuf::from("/state/runtime.sqlite"),
668            message: "disk I/O error".to_string(),
669        };
670        let text = error.to_string();
671        assert!(text.contains("/state/runtime.sqlite"));
672        assert!(text.contains("ephemeral_runtime_store(true)"));
673        assert!(text.contains("runtime_options.runtime_store"));
674    }
675}