lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 087c17e1f72d865ce58d5c881fc05a97bf3ec110
parent d3ef9e2e86839534b64a04a832567208a632f7e6
Author: triesap <tyson@radroots.org>
Date:   Tue, 22 Sep 2026 14:35:03 +0000

storage: preserve typed capacity failures

- Classify canonical SQLite and I/O capacity exhaustion
- Retain generic fallbacks without exposing backend details
- Preserve acknowledged drafts and unresolved submissions
- Verify additive API and unchanged coverage thresholds

Diffstat:
Mcontracts/api_baselines/radroots_storage.txt | 1+
Acontracts/architecture/decisions/storage_capacity_errors.v1.json | 16++++++++++++++++
Mcontracts/architecture/deviations.toml | 20++++++++++++++++++++
Mcrates/storage/README.md | 9+++++++++
Mcrates/storage/src/error.rs | 4++++
Mcrates/storage_sqlite/src/atomic.rs | 5+----
Mcrates/storage_sqlite/src/authored.rs | 5+----
Mcrates/storage_sqlite/src/authored_draft.rs | 5+----
Mcrates/storage_sqlite/src/authored_draft_submission_tests.rs | 7+++----
Mcrates/storage_sqlite/src/authored_durability_tests.rs | 2+-
Acrates/storage_sqlite/src/backend.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/src/event/mod.rs | 5+----
Mcrates/storage_sqlite/src/journal/mod.rs | 5+----
Mcrates/storage_sqlite/src/lib.rs | 1+
Mcrates/storage_sqlite/src/outbox/mod.rs | 5+----
Mcrates/storage_sqlite/src/private_artifact/mod.rs | 5+----
Mcrates/storage_sqlite/src/projection/mod.rs | 5+----
17 files changed, 168 insertions(+), 37 deletions(-)

diff --git a/contracts/api_baselines/radroots_storage.txt b/contracts/api_baselines/radroots_storage.txt @@ -1841,6 +1841,7 @@ pub radroots_storage::Error::ReliabilityOperationTerminal pub radroots_storage::Error::ReliabilityRevisionConflict pub radroots_storage::Error::RestoreMemberVerificationFailed pub radroots_storage::Error::SourceGenerationChanged +pub radroots_storage::Error::SpaceInsufficient pub radroots_storage::Error::TooManyEventQueryIds impl core::error::Error for radroots_storage::Error impl core::fmt::Display for radroots_storage::Error diff --git a/contracts/architecture/decisions/storage_capacity_errors.v1.json b/contracts/architecture/decisions/storage_capacity_errors.v1.json @@ -0,0 +1,16 @@ +{ + "schema": "radroots.storage-capacity-errors.v1", + "status": "approved", + "scope": "Backend-neutral capacity diagnosis for canonical SQLite SPI operations", + "error": "SpaceInsufficient", + "classification": "Numeric primary or extended SQLITE_FULL, or SQLx I/O StorageFull/QuotaExceeded; other SQL errors retain BackendUnavailable. Raw database and I/O sources are never exposed.", + "uncertainty": "A capacity error can arise at commit or after earlier effects. It is not a rollback or no-effect receipt. Retain original requests, IDs, drafts and ambiguous receipts; reconcile existing state before retry.", + "ownership": "Only the existing storage owner performs SQL and transactions. No raw handle, SQL escape hatch, global fault control, second owner or application cleanup policy is introduced.", + "compatibility": "The non-exhaustive error enum gains one variant. Schema, persisted bytes, transaction order, durability policy, limits and successful receipts are unchanged. Open, migration, backup and restore capability errors remain their separate contracts.", + "verification": [ + "real bounded SQLITE_FULL draft retention and exact retry", + "real atomic submission capacity retention", + "numeric/extended and typed I/O classification, redaction and generic fallback", + "unchanged coverage and package/release gates" + ] +} diff --git a/contracts/architecture/deviations.toml b/contracts/architecture/deviations.toml @@ -2,6 +2,26 @@ schema_version = 1 architecture_id = "radroots.crates.release.v1" [[deviation]] +id = "RCRV1-DEV-022" +date = "2026-09-22" +status = "closed" +approval = "Explicit user authorization covers necessary owning-repository repairs, verified checkpoints and non-force publication." +affected_steps = ["158", "163"] +spec_anchors = ["contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage", "contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage_sqlite"] +source_evidence = ["The actual bounded SQLite capacity regression retains the acknowledged draft but collapses SQLITE_FULL into BackendUnavailable.", "Separate SQL adapters erase the same classification at authored, atomic, event, journal, outbox, projection and private-artifact boundaries."] +replacement_action = "Preserve bounded capacity classification through the existing storage SPI as governed by storage_capacity_errors.v1.json; keep transactional uncertainty and all original ownership and retention rules." +verification = ["Actual SQLITE_FULL preserves acknowledged drafts and unresolved submission source without a success receipt.", "Primary and extended numeric database codes and typed I/O capacity failures are classified without raw diagnostic leakage; unrelated failures retain existing fallbacks.", "Review additive API and preserve coverage thresholds, full workspace, portable and release checks."] +unresolved_risk = "Typed capacity classification, actual owner regressions, additive API, unchanged coverage gates and complete workspace qualification passed. Consumers still reconcile prior effects; classification grants no eviction or automatic retry authority." +normative_architecture_change = false +adr_required = false +closure_evidence = [ + "Actual bounded SQLITE_FULL retains the acknowledged draft, reports no successful save, and permits exact later retry. Atomic submission failure retains the original source without successful association.", + "Primary and extended database capacity codes and typed I/O capacity/quota failures become one redacted error. Unrelated failures remain generic. No operation ID, transaction, receipt, durability policy, schema or limit changes.", + "The storage non-exhaustive error enum gains one variant; SQLite, SDK and umbrella public API snapshots are unchanged.", + "Both changed packages have fresh coverage and all 45 gates retain their existing thresholds. Unchanged package-source reports retain provenance. Full workspace, portable, contracts, preflight and explicit native/WASM generators pass.", +] + +[[deviation]] id = "RCRV1-DEV-021" date = "2026-09-22" status = "closed" diff --git a/crates/storage/README.md b/crates/storage/README.md @@ -252,6 +252,15 @@ It proves the requested snapshots exist, not current ownership or permission to sign or deliver. Callers retain application policy and must recheck current heads before effects. Losing the result after commit cannot establish rollback. +## Capacity failures + +`Error::SpaceInsufficient` reports exhausted storage capacity without exposing +backend details. It does not prove rollback or absence of earlier effects. +Retain pending requests, original operation identities and ambiguous receipts; +reconcile existing state before retrying. Capacity diagnosis grants no eviction +or automatic retry authority. Backend implementations that cannot distinguish +capacity failures may continue returning `BackendUnavailable`. + ## Intended consumers - `radroots_storage_sqlite` implements the contracts for native durable state. diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs @@ -34,6 +34,8 @@ pub enum Error { EventNotFound, CorruptStoredEvent, BackendUnavailable, + /// Capacity is exhausted; reconcile prior effects before retrying the same operation. + SpaceInsufficient, InvalidOperationInstanceId, InvalidIdempotencyKey, InvalidOperationTimestamp, @@ -165,6 +167,7 @@ impl fmt::Display for Error { Self::EventNotFound => "storage event was not found", Self::CorruptStoredEvent => "storage event data is corrupt", Self::BackendUnavailable => "storage backend is unavailable", + Self::SpaceInsufficient => "storage space is insufficient", Self::InvalidOperationInstanceId => "storage operation instance id is invalid", Self::InvalidIdempotencyKey => "storage idempotency key is invalid", Self::InvalidOperationTimestamp => "storage operation timestamp is invalid", @@ -326,6 +329,7 @@ mod tests { EventNotFound, CorruptStoredEvent, BackendUnavailable, + SpaceInsufficient, InvalidOperationInstanceId, InvalidIdempotencyKey, InvalidOperationTimestamp, diff --git a/crates/storage_sqlite/src/atomic.rs b/crates/storage_sqlite/src/atomic.rs @@ -1,3 +1,4 @@ +use crate::backend::map_backend; use crate::{SqliteStorage, projection}; use radroots_storage::{ Error, @@ -383,10 +384,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::AtomicCommitFailed) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - fn map_corrupt(_: sqlx::Error) -> Error { Error::AtomicCommitFailed } diff --git a/crates/storage_sqlite/src/authored.rs b/crates/storage_sqlite/src/authored.rs @@ -1,4 +1,5 @@ use crate::SqliteStorage; +use crate::backend::map_backend; #[path = "authored_delivery_facts.rs"] mod delivery_facts; #[path = "authored_delivery_reconciliation.rs"] @@ -1322,10 +1323,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::AtomicCommitFailed) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - fn column<T>(row: &SqliteRow, name: &str) -> Result<T, Error> where for<'decode> T: sqlx::Decode<'decode, Sqlite> + sqlx::Type<Sqlite>, diff --git a/crates/storage_sqlite/src/authored_draft.rs b/crates/storage_sqlite/src/authored_draft.rs @@ -1,4 +1,5 @@ use crate::SqliteStorage; +use crate::backend::map_backend; use radroots_storage::{ Error, authored_draft::{ @@ -240,10 +241,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::CorruptAuthoredDraft) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/storage_sqlite/src/authored_draft_submission_tests.rs b/crates/storage_sqlite/src/authored_draft_submission_tests.rs @@ -341,10 +341,9 @@ async fn abandoned_and_full(stage: AuthoredDraftStage) { large["intent"]["payload"] = serde_json::json!(payload); large["intent"]["payload_sha256"] = serde_json::json!(Sha256::digest(&payload).to_vec()); let large: PrepareFromDraft = serde_json::from_value(large).unwrap(); - assert!( - execute_transaction(&mut transaction, &command(&large)) - .await - .is_err() + assert_eq!( + execute_transaction(&mut transaction, &command(&large)).await, + Err(Error::SpaceInsufficient) ); // SQLITE_FULL may already roll back the transaction at the engine boundary. let _ = transaction.rollback().await; diff --git a/crates/storage_sqlite/src/authored_durability_tests.rs b/crates/storage_sqlite/src/authored_durability_tests.rs @@ -165,7 +165,7 @@ async fn authored_durability_sqlite_capacity_failure_preserves_the_acknowledged_ store .append_authored_draft(pending.clone(), Some(baseline.revision())) .await, - Err(Error::BackendUnavailable) + Err(Error::SpaceInsufficient) ); assert_head(&store, &baseline).await; diff --git a/crates/storage_sqlite/src/backend.rs b/crates/storage_sqlite/src/backend.rs @@ -0,0 +1,105 @@ +//! Redacted SQL failure classification shared by the existing storage adapters. + +use radroots_storage::Error; + +pub(crate) fn map_backend(source: sqlx::Error) -> Error { + let full = match &source { + sqlx::Error::Database(error) => error + .code() + .and_then(|code| code.parse::<u32>().ok()) + .is_some_and(|code| code & 0xff == 13), + sqlx::Error::Io(error) => matches!( + error.kind(), + std::io::ErrorKind::StorageFull | std::io::ErrorKind::QuotaExceeded + ), + _ => false, + }; + if full { + Error::SpaceInsufficient + } else { + Error::BackendUnavailable + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::error::{DatabaseError, ErrorKind}; + use std::{borrow::Cow, error::Error as StdError, fmt}; + + #[derive(Debug)] + struct Coded(Option<&'static str>); + impl fmt::Display for Coded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("private path and raw database detail") + } + } + impl StdError for Coded {} + impl DatabaseError for Coded { + fn message(&self) -> &str { + "private path and raw database detail" + } + fn code(&self) -> Option<Cow<'_, str>> { + self.0.map(Cow::Borrowed) + } + fn as_error(&self) -> &(dyn StdError + Send + Sync + 'static) { + self + } + fn as_error_mut(&mut self) -> &mut (dyn StdError + Send + Sync + 'static) { + self + } + fn into_error(self: Box<Self>) -> Box<dyn StdError + Send + Sync + 'static> { + self + } + fn kind(&self) -> ErrorKind { + ErrorKind::Other + } + } + + #[test] + fn numeric_capacity_codes_are_redacted_and_other_database_errors_stay_generic() { + for code in [Some("13"), Some("269"), Some("525")] { + let mapped = map_backend(sqlx::Error::Database(Box::new(Coded(code)))); + assert_eq!(mapped, Error::SpaceInsufficient); + assert_eq!(mapped.to_string(), "storage space is insufficient"); + assert_eq!(format!("{mapped:?}"), "SpaceInsufficient"); + } + for code in [ + None, + Some(""), + Some("full"), + Some("-13"), + Some("4294967296"), + Some("11"), + Some("5"), + Some("10"), + ] { + assert_eq!( + map_backend(sqlx::Error::Database(Box::new(Coded(code)))), + Error::BackendUnavailable + ); + } + } + + #[test] + fn typed_io_capacity_is_distinct_without_inventing_a_no_effect_receipt() { + use std::io::ErrorKind as Io; + for kind in [Io::StorageFull, Io::QuotaExceeded] { + let source = std::io::Error::new(kind, "private path and raw file detail"); + assert_eq!( + map_backend(sqlx::Error::Io(source)), + Error::SpaceInsufficient + ); + } + for kind in [Io::PermissionDenied, Io::Other] { + assert_eq!( + map_backend(sqlx::Error::Io(std::io::Error::from(kind))), + Error::BackendUnavailable + ); + } + assert_eq!( + map_backend(sqlx::Error::PoolClosed), + Error::BackendUnavailable + ); + } +} diff --git a/crates/storage_sqlite/src/event/mod.rs b/crates/storage_sqlite/src/event/mod.rs @@ -1,3 +1,4 @@ +use crate::backend::map_backend; use radroots_event::SignedEvent; use radroots_event_codec::Codec; use radroots_storage::{ @@ -597,10 +598,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::CorruptStoredEvent) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - fn map_corrupt(_: sqlx::Error) -> Error { Error::CorruptStoredEvent } diff --git a/crates/storage_sqlite/src/journal/mod.rs b/crates/storage_sqlite/src/journal/mod.rs @@ -1,4 +1,5 @@ use crate::SqliteStorage; +use crate::backend::map_backend; use radroots_storage::{ Error, Journal, journal::{ @@ -477,10 +478,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::CorruptJournalRecord) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - fn map_corrupt(_: sqlx::Error) -> Error { Error::CorruptJournalRecord } diff --git a/crates/storage_sqlite/src/lib.rs b/crates/storage_sqlite/src/lib.rs @@ -14,6 +14,7 @@ pub mod status; mod atomic; mod authored; mod authored_draft; +mod backend; mod event; mod journal; mod outbox; diff --git a/crates/storage_sqlite/src/outbox/mod.rs b/crates/storage_sqlite/src/outbox/mod.rs @@ -1,4 +1,5 @@ use crate::SqliteStorage; +use crate::backend::map_backend; use radroots_event_codec::Codec; use radroots_storage::{ Error, Outbox, @@ -903,10 +904,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::CorruptOutboxRecord) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - fn map_corrupt(_: sqlx::Error) -> Error { Error::CorruptOutboxRecord } diff --git a/crates/storage_sqlite/src/private_artifact/mod.rs b/crates/storage_sqlite/src/private_artifact/mod.rs @@ -1,4 +1,5 @@ use crate::SqliteStorage; +use crate::backend::map_backend; use radroots_secrets::{ EncryptedEnvelope, context::{EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId}, @@ -780,10 +781,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::CorruptPrivateArtifactMetadata) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - fn map_reseal(_: sqlx::Error) -> Error { Error::PrivateArtifactResealConflict } diff --git a/crates/storage_sqlite/src/projection/mod.rs b/crates/storage_sqlite/src/projection/mod.rs @@ -1,4 +1,5 @@ use crate::SqliteStorage; +use crate::backend::map_backend; use radroots_storage::{ Error, ProjectionStore, event::{EventPosition, SourceGeneration}, @@ -1391,10 +1392,6 @@ fn u64_from_i64(value: i64) -> Result<u64, Error> { u64::try_from(value).map_err(|_| Error::CorruptProjectionRecord) } -fn map_backend(_: sqlx::Error) -> Error { - Error::BackendUnavailable -} - fn map_corrupt(_: sqlx::Error) -> Error { Error::CorruptProjectionRecord }