commit 39a6ba93872ae22c1fae115e8a48a6ec45026599
parent 8bbfba3f9b89e7373f8d0e76a99c496bdab3ee4e
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 10:17:21 +0000
service-sqlite: add error and status contracts
- define stable SQLite error kinds, codes, and safe projections
- add sealed storage health, integrity, and status contracts
- preserve trusted causes behind redacted display and debug surfaces
- verify exact exports, dependency boundaries, and wire inventories
Diffstat:
6 files changed, 534 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3908,6 +3908,10 @@ dependencies = [
[[package]]
name = "radroots_service_sqlite"
version = "0.1.0-alpha"
+dependencies = [
+ "serde",
+ "serde_json",
+]
[[package]]
name = "radroots_signing"
diff --git a/crates/service_sqlite/Cargo.toml b/crates/service_sqlite/Cargo.toml
@@ -12,6 +12,10 @@ homepage.workspace = true
readme = "README.md"
[dependencies]
+serde = { workspace = true, features = ["derive", "std"] }
+
+[dev-dependencies]
+serde_json = { workspace = true }
[lints]
workspace = true
diff --git a/crates/service_sqlite/src/error.rs b/crates/service_sqlite/src/error.rs
@@ -0,0 +1,320 @@
+//! Stable safe errors for shared SQLite mechanics.
+
+use core::fmt;
+use std::error::Error;
+
+use serde::{Serialize, Serializer};
+
+const MAX_SAFE_ERROR_MESSAGE_BYTES: usize = 96;
+
+/// Stable public codes for service-neutral SQLite failures.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ServiceSqliteErrorCode {
+ Authority,
+ Open,
+ Create,
+ Pragma,
+ Metadata,
+ Migration,
+ Backup,
+ Restore,
+ Integrity,
+ Recovery,
+}
+
+impl ServiceSqliteErrorCode {
+ /// Returns the stable lowercase wire representation.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Authority => "sqlite_authority",
+ Self::Open => "sqlite_open",
+ Self::Create => "sqlite_create",
+ Self::Pragma => "sqlite_pragma",
+ Self::Metadata => "sqlite_metadata",
+ Self::Migration => "sqlite_migration",
+ Self::Backup => "sqlite_backup",
+ Self::Restore => "sqlite_restore",
+ Self::Integrity => "sqlite_integrity",
+ Self::Recovery => "sqlite_recovery",
+ }
+ }
+}
+
+impl fmt::Display for ServiceSqliteErrorCode {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.as_str())
+ }
+}
+
+impl Serialize for ServiceSqliteErrorCode {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+/// Service-neutral failure classes used by the SQLite mechanism boundary.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ServiceSqliteErrorKind {
+ Authority,
+ Open,
+ Create,
+ Pragma,
+ Metadata,
+ Migration,
+ Backup,
+ Restore,
+ Integrity,
+ Recovery,
+}
+
+impl ServiceSqliteErrorKind {
+ /// Returns the stable public code for this failure class.
+ #[must_use]
+ pub const fn code(self) -> ServiceSqliteErrorCode {
+ match self {
+ Self::Authority => ServiceSqliteErrorCode::Authority,
+ Self::Open => ServiceSqliteErrorCode::Open,
+ Self::Create => ServiceSqliteErrorCode::Create,
+ Self::Pragma => ServiceSqliteErrorCode::Pragma,
+ Self::Metadata => ServiceSqliteErrorCode::Metadata,
+ Self::Migration => ServiceSqliteErrorCode::Migration,
+ Self::Backup => ServiceSqliteErrorCode::Backup,
+ Self::Restore => ServiceSqliteErrorCode::Restore,
+ Self::Integrity => ServiceSqliteErrorCode::Integrity,
+ Self::Recovery => ServiceSqliteErrorCode::Recovery,
+ }
+ }
+
+ /// Returns the bounded projection safe for logs, status, and wire envelopes.
+ #[must_use]
+ pub const fn safe_error(self) -> SafeServiceSqliteError {
+ let message = match self {
+ Self::Authority => "SQLite writer authority could not be established",
+ Self::Open => "SQLite state could not be opened",
+ Self::Create => "SQLite state could not be created",
+ Self::Pragma => "SQLite pragma policy could not be applied",
+ Self::Metadata => "SQLite metadata is invalid",
+ Self::Migration => "SQLite migration history is invalid",
+ Self::Backup => "SQLite backup failed",
+ Self::Restore => "SQLite restore failed",
+ Self::Integrity => "SQLite integrity verification failed",
+ Self::Recovery => "SQLite recovery failed",
+ };
+ SafeServiceSqliteError::new(self.code(), message)
+ }
+}
+
+/// Bounded service-neutral SQLite error safe for public observation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+pub struct SafeServiceSqliteError {
+ code: ServiceSqliteErrorCode,
+ message: &'static str,
+}
+
+impl SafeServiceSqliteError {
+ const fn new(code: ServiceSqliteErrorCode, message: &'static str) -> Self {
+ assert!(message.is_ascii());
+ assert!(message.len() <= MAX_SAFE_ERROR_MESSAGE_BYTES);
+ Self { code, message }
+ }
+
+ /// Returns the typed stable error code.
+ #[must_use]
+ pub const fn code(self) -> ServiceSqliteErrorCode {
+ self.code
+ }
+
+ /// Returns the stable serialized code.
+ #[must_use]
+ pub const fn code_str(self) -> &'static str {
+ self.code.as_str()
+ }
+
+ /// Returns the bounded safe message.
+ #[must_use]
+ pub const fn message(self) -> &'static str {
+ self.message
+ }
+}
+
+impl fmt::Display for SafeServiceSqliteError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(formatter, "{}: {}", self.code, self.message)
+ }
+}
+
+/// SQLite mechanism failure with an optional cause for trusted inspection.
+pub struct ServiceSqliteError {
+ kind: ServiceSqliteErrorKind,
+ source: Option<Box<dyn Error + Send + Sync + 'static>>,
+}
+
+impl ServiceSqliteError {
+ /// Creates a failure without an upstream cause.
+ #[must_use]
+ pub const fn new(kind: ServiceSqliteErrorKind) -> Self {
+ Self { kind, source: None }
+ }
+
+ /// Creates a failure while retaining its cause for trusted inspection.
+ pub fn with_source(
+ kind: ServiceSqliteErrorKind,
+ source: impl Error + Send + Sync + 'static,
+ ) -> Self {
+ Self {
+ kind,
+ source: Some(Box::new(source)),
+ }
+ }
+
+ /// Returns the stable service-neutral failure class.
+ #[must_use]
+ pub const fn kind(&self) -> ServiceSqliteErrorKind {
+ self.kind
+ }
+
+ /// Returns the bounded projection safe for public observation.
+ #[must_use]
+ pub const fn safe_error(&self) -> SafeServiceSqliteError {
+ self.kind.safe_error()
+ }
+}
+
+impl fmt::Debug for ServiceSqliteError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("ServiceSqliteError")
+ .field("kind", &self.kind)
+ .field("safe_error", &self.safe_error())
+ .field("source", &self.source.as_ref().map(|_| "[redacted]"))
+ .finish()
+ }
+}
+
+impl fmt::Display for ServiceSqliteError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.safe_error().fmt(formatter)
+ }
+}
+
+impl Error for ServiceSqliteError {
+ fn source(&self) -> Option<&(dyn Error + 'static)> {
+ self.source
+ .as_deref()
+ .map(|source| source as &(dyn Error + 'static))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[derive(Debug)]
+ struct SensitiveCause;
+
+ impl fmt::Display for SensitiveCause {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("secret path=/private/state.sqlite")
+ }
+ }
+
+ impl Error for SensitiveCause {}
+
+ #[test]
+ fn error_inventory_codes_messages_and_serialization_are_exact() {
+ let inventory = [
+ (
+ ServiceSqliteErrorKind::Authority,
+ "sqlite_authority",
+ "SQLite writer authority could not be established",
+ ),
+ (
+ ServiceSqliteErrorKind::Open,
+ "sqlite_open",
+ "SQLite state could not be opened",
+ ),
+ (
+ ServiceSqliteErrorKind::Create,
+ "sqlite_create",
+ "SQLite state could not be created",
+ ),
+ (
+ ServiceSqliteErrorKind::Pragma,
+ "sqlite_pragma",
+ "SQLite pragma policy could not be applied",
+ ),
+ (
+ ServiceSqliteErrorKind::Metadata,
+ "sqlite_metadata",
+ "SQLite metadata is invalid",
+ ),
+ (
+ ServiceSqliteErrorKind::Migration,
+ "sqlite_migration",
+ "SQLite migration history is invalid",
+ ),
+ (
+ ServiceSqliteErrorKind::Backup,
+ "sqlite_backup",
+ "SQLite backup failed",
+ ),
+ (
+ ServiceSqliteErrorKind::Restore,
+ "sqlite_restore",
+ "SQLite restore failed",
+ ),
+ (
+ ServiceSqliteErrorKind::Integrity,
+ "sqlite_integrity",
+ "SQLite integrity verification failed",
+ ),
+ (
+ ServiceSqliteErrorKind::Recovery,
+ "sqlite_recovery",
+ "SQLite recovery failed",
+ ),
+ ];
+
+ for (kind, code, message) in inventory {
+ let safe = kind.safe_error();
+ assert_eq!(kind.code().as_str(), code);
+ assert_eq!(kind.code().to_string(), code);
+ assert_eq!(safe.code(), kind.code());
+ assert_eq!(safe.code_str(), code);
+ assert_eq!(safe.message(), message);
+ assert!(message.is_ascii());
+ assert!(message.len() <= MAX_SAFE_ERROR_MESSAGE_BYTES);
+ assert_eq!(
+ serde_json::to_string(&kind.code()).unwrap(),
+ format!(r#""{code}""#)
+ );
+ assert_eq!(
+ serde_json::to_string(&safe).unwrap(),
+ format!(r#"{{"code":"{code}","message":"{message}"}}"#)
+ );
+ }
+ }
+
+ #[test]
+ fn raw_error_redacts_but_preserves_its_trusted_source() {
+ let error = ServiceSqliteError::with_source(ServiceSqliteErrorKind::Open, SensitiveCause);
+ let display = error.to_string();
+ let debug = format!("{error:?}");
+ let serialized = serde_json::to_string(&error.safe_error()).unwrap();
+
+ for public in [&display, &debug, &serialized] {
+ assert!(!public.contains("secret"));
+ assert!(!public.contains("private"));
+ assert!(!public.contains("state.sqlite"));
+ }
+ assert_eq!(
+ error.source().map(ToString::to_string).as_deref(),
+ Some("secret path=/private/state.sqlite")
+ );
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Open);
+ }
+}
diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs
@@ -1,6 +1,11 @@
#![forbid(unsafe_code)]
//! Reusable, service-neutral SQLite mechanics for Radroots services.
-//!
-//! This crate is intentionally empty while its public modules are introduced
-//! as independently reviewed contract checkpoints.
+
+mod error;
+mod status;
+
+pub use error::{
+ SafeServiceSqliteError, ServiceSqliteError, ServiceSqliteErrorCode, ServiceSqliteErrorKind,
+};
+pub use status::{StorageHealth, StorageIntegrity, StorageStatus};
diff --git a/crates/service_sqlite/src/status.rs b/crates/service_sqlite/src/status.rs
@@ -0,0 +1,136 @@
+//! Passive storage status values for the service-owned status envelope.
+
+use core::num::NonZeroU32;
+
+use serde::Serialize;
+
+/// Service-neutral storage health classification.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum StorageHealth {
+ Ready,
+ ReadOnly,
+ RepairRequired,
+ Unavailable,
+}
+
+/// Service-neutral storage integrity classification.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum StorageIntegrity {
+ Verified,
+ VerificationRequired,
+ Failed,
+}
+
+/// Passive storage facts supplied to a versioned service-status envelope.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+pub struct StorageStatus {
+ health: StorageHealth,
+ schema_version: NonZeroU32,
+ generation: u64,
+ integrity: StorageIntegrity,
+}
+
+impl StorageStatus {
+ /// Constructs a passive status from already-validated storage facts.
+ #[must_use]
+ pub const fn new(
+ health: StorageHealth,
+ schema_version: NonZeroU32,
+ generation: u64,
+ integrity: StorageIntegrity,
+ ) -> Self {
+ Self {
+ health,
+ schema_version,
+ generation,
+ integrity,
+ }
+ }
+
+ #[must_use]
+ pub const fn health(self) -> StorageHealth {
+ self.health
+ }
+
+ #[must_use]
+ pub const fn schema_version(self) -> NonZeroU32 {
+ self.schema_version
+ }
+
+ #[must_use]
+ pub const fn generation(self) -> u64 {
+ self.generation
+ }
+
+ #[must_use]
+ pub const fn integrity(self) -> StorageIntegrity {
+ self.integrity
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn status_projection_and_enum_spellings_are_exact() {
+ let health = [
+ (StorageHealth::Ready, "ready"),
+ (StorageHealth::ReadOnly, "read_only"),
+ (StorageHealth::RepairRequired, "repair_required"),
+ (StorageHealth::Unavailable, "unavailable"),
+ ];
+ for (value, wire) in health {
+ assert_eq!(
+ serde_json::to_string(&value).unwrap(),
+ format!(r#""{wire}""#)
+ );
+ }
+
+ let integrity = [
+ (StorageIntegrity::Verified, "verified"),
+ (
+ StorageIntegrity::VerificationRequired,
+ "verification_required",
+ ),
+ (StorageIntegrity::Failed, "failed"),
+ ];
+ for (value, wire) in integrity {
+ assert_eq!(
+ serde_json::to_string(&value).unwrap(),
+ format!(r#""{wire}""#)
+ );
+ }
+
+ let status = StorageStatus::new(
+ StorageHealth::RepairRequired,
+ NonZeroU32::new(1).unwrap(),
+ 7,
+ StorageIntegrity::VerificationRequired,
+ );
+ assert_eq!(status.health(), StorageHealth::RepairRequired);
+ assert_eq!(status.schema_version().get(), 1);
+ assert_eq!(status.generation(), 7);
+ assert_eq!(status.integrity(), StorageIntegrity::VerificationRequired);
+ assert_eq!(
+ serde_json::to_string(&status).unwrap(),
+ r#"{"health":"repair_required","schema_version":1,"generation":7,"integrity":"verification_required"}"#
+ );
+ }
+
+ #[test]
+ fn zero_schema_version_cannot_cross_the_construction_boundary() {
+ assert!(NonZeroU32::new(0).is_none());
+ let maximum = NonZeroU32::new(u32::MAX).unwrap();
+ let status = StorageStatus::new(
+ StorageHealth::Ready,
+ maximum,
+ u64::MAX,
+ StorageIntegrity::Verified,
+ );
+ assert_eq!(status.schema_version(), maximum);
+ assert_eq!(status.generation(), u64::MAX);
+ }
+}
diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs
@@ -2,9 +2,11 @@ use std::collections::BTreeSet;
const MANIFEST: &str = include_str!("../Cargo.toml");
const ROOT: &str = include_str!("../src/lib.rs");
+const ERROR_SOURCE: &str = include_str!("../src/error.rs");
+const STATUS_SOURCE: &str = include_str!("../src/status.rs");
#[test]
-fn service_sqlite_is_unpublished_lint_governed_and_dependency_free() {
+fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
for required in [
"name = \"radroots_service_sqlite\"",
"publish = false",
@@ -17,17 +19,71 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_free() {
);
}
- assert_eq!(dependency_keys(MANIFEST), BTreeSet::new());
- assert!(!ROOT.contains("pub mod "));
+ assert_eq!(
+ dependency_keys(MANIFEST, "[dependencies]"),
+ BTreeSet::from(["serde"])
+ );
+ assert_eq!(
+ dependency_keys(MANIFEST, "[dev-dependencies]"),
+ BTreeSet::from(["serde_json"])
+ );
+ assert_eq!(private_modules(ROOT), BTreeSet::from(["error", "status"]));
+ assert!(public_modules(ROOT).is_empty());
+
+ for required in [
+ "ServiceSqliteErrorCode",
+ "ServiceSqliteErrorKind",
+ "SafeServiceSqliteError",
+ "ServiceSqliteError",
+ "StorageHealth",
+ "StorageIntegrity",
+ "StorageStatus",
+ ] {
+ assert!(
+ ROOT.contains(required),
+ "crate root is missing `{required}`"
+ );
+ }
+
+ for forbidden in [
+ "radroots_service_host",
+ "radroots_runtime_paths",
+ "sqlx",
+ "rusqlite",
+ "tokio",
+ "std::fs",
+ "OpenOptions",
+ "create_dir",
+ "Connection",
+ "Pool",
+ ] {
+ assert!(
+ !ERROR_SOURCE.contains(forbidden) && !STATUS_SOURCE.contains(forbidden),
+ "Step 052 source contains deferred surface `{forbidden}`"
+ );
+ }
+}
+
+fn public_modules(root: &str) -> BTreeSet<&str> {
+ root.lines()
+ .filter_map(|line| line.strip_prefix("pub mod "))
+ .filter_map(|module| module.strip_suffix(';'))
+ .collect()
+}
+
+fn private_modules(root: &str) -> BTreeSet<&str> {
+ root.lines()
+ .filter_map(|line| line.strip_prefix("mod "))
+ .filter_map(|module| module.strip_suffix(';'))
+ .collect()
}
-fn dependency_keys(manifest: &str) -> BTreeSet<&str> {
+fn dependency_keys<'a>(manifest: &'a str, section: &str) -> BTreeSet<&'a str> {
manifest
- .split_once("[dependencies]")
+ .split_once(section)
.map(|(_, dependencies)| dependencies)
.unwrap_or_default()
.lines()
- .skip(1)
.take_while(|line| !line.starts_with('['))
.filter_map(|line| line.split_once('=').map(|(key, _)| key.trim()))
.filter(|key| !key.is_empty())