lib

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

commit 58f01db7ffb8f91a3cfa50a022a3ef8bce1ae1a8
parent 6f3a2ef1ca5bcffa174bc353e53e2ff8a2b0ab91
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 05:35:11 +0000

storage-sqlite: hand Studio state to its host

- bind Studio evidence to an immutable handoff descriptor
- require exact nonzero host commitment acknowledgements
- retain only a durable receipt cursor with zero imported rows
- prove retry conflict handling and owned-database isolation

Diffstat:
Acontracts/storage/legacy_studio_handoff_policy_v1.toml | 16++++++++++++++++
Mcrates/storage_sqlite/README.md | 7+++++++
Mcrates/storage_sqlite/src/legacy.rs | 404+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 427 insertions(+), 0 deletions(-)

diff --git a/contracts/storage/legacy_studio_handoff_policy_v1.toml b/contracts/storage/legacy_studio_handoff_policy_v1.toml @@ -0,0 +1,16 @@ +schema_version = 1 +source_kind = "studio" +supported_schema = "studio_v1_host_handoff" +disposition = "host_handoff" +evidence = "immutable_preimport_backup_member" +handoff_identity = "sha256_domain_import_target_manifest_relative_path_source_catalog_length" +receipt = "handoff_sha256_plus_nonzero_opaque_host_commitment_sha256" +receipt_cursor_bytes = 64 +staged_row_count = 0 +exact_retry = "idempotent" +conflicting_retry = "reject" +sdk_runtime_row_import = false +sdk_private_row_import = false +sdk_owned_studio_database = false +source_deletion = false +hidden_clock_or_entropy = false diff --git a/crates/storage_sqlite/README.md b/crates/storage_sqlite/README.md @@ -79,6 +79,13 @@ the private commit therefore replays the exact page from the old runtime cursor without duplicating counts or exposing secret-bearing staging in `runtime.sqlite`. +Classified Studio state is never imported into either owned database. The +backend instead returns an immutable evidence descriptor bound to the import, +target generation, manifest, source digest, schema catalog, and byte length. +Only an exact host receipt carrying that handoff identity and a non-zero opaque +host-store commitment advances the Studio journal member to ready; exact retry +is idempotent and conflicting acknowledgement fails closed. + ```rust,no_run use radroots_storage::event::SourceGeneration; use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage}; diff --git a/crates/storage_sqlite/src/legacy.rs b/crates/storage_sqlite/src/legacy.rs @@ -468,6 +468,83 @@ pub struct LegacyPrivateStagePage { complete: bool, } +/// Immutable host-owned handoff descriptor for one classified Studio snapshot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyStudioHandoff { + import_id: LegacyImportId, + evidence_path: PathBuf, + byte_length: u64, + source_sha256: MemberDigest, + catalog_sha256: MemberDigest, + handoff_sha256: MemberDigest, +} + +impl LegacyStudioHandoff { + /// Returns the import attempt bound to this handoff. + pub const fn import_id(&self) -> LegacyImportId { + self.import_id + } + + /// Returns the immutable backed-up Studio database offered to the host. + pub fn evidence_path(&self) -> &Path { + &self.evidence_path + } + + /// Returns the exact backed-up Studio database length. + pub const fn byte_length(&self) -> u64 { + self.byte_length + } + + /// Returns the exact backed-up Studio database digest. + pub const fn source_sha256(&self) -> MemberDigest { + self.source_sha256 + } + + /// Returns the exact classified Studio schema-catalog digest. + pub const fn catalog_sha256(&self) -> MemberDigest { + self.catalog_sha256 + } + + /// Returns the deterministic identity the host must acknowledge. + pub const fn handoff_sha256(&self) -> MemberDigest { + self.handoff_sha256 + } +} + +/// Host-supplied proof that a specific Studio handoff was durably accepted. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LegacyStudioHandoffReceipt { + handoff_sha256: MemberDigest, + host_commitment_sha256: MemberDigest, +} + +impl LegacyStudioHandoffReceipt { + /// Binds an exact handoff to a non-zero host-owned durable commitment. + pub const fn new( + handoff_sha256: MemberDigest, + host_commitment_sha256: MemberDigest, + ) -> Result<Self, Error> { + if bytes_are_zero(host_commitment_sha256.as_bytes()) { + Err(Error::InvalidLegacyImportStageRequest) + } else { + Ok(Self { + handoff_sha256, + host_commitment_sha256, + }) + } + } + + /// Returns the acknowledged handoff identity. + pub const fn handoff_sha256(&self) -> MemberDigest { + self.handoff_sha256 + } + + /// Returns the opaque host-owned durable commitment. + pub const fn host_commitment_sha256(&self) -> MemberDigest { + self.host_commitment_sha256 + } +} + impl LegacyPrivateStagePage { pub const fn table(&self) -> LegacyPrivateTable { self.table @@ -1785,6 +1862,143 @@ impl SqliteStorage { }) } + /// Revalidates and describes a Studio predecessor snapshot for its host. + pub async fn prepare_legacy_studio_handoff( + &self, + classified: &ClassifiedLegacyImport, + ) -> Result<LegacyStudioHandoff, Error> { + self.require_legacy_import_writer(classified.target_generation())?; + verify_prepared_evidence(&classified.prepared).await?; + let classification_sha256 = classification_digest(classified); + let journal = self + .legacy_import_journal(classified.import_id()) + .await? + .ok_or(Error::InvalidLegacyImportJournal)?; + if !journal_matches_classified(&journal, classified, classification_sha256) { + return Err(Error::LegacyImportConflict); + } + let classification = classified + .sources() + .iter() + .find(|source| source.kind() == LegacySourceKind::Studio) + .filter(|source| { + source.schema() == LegacySchema::StudioV1HostHandoff + && source.schema().disposition() == LegacyImportDisposition::HostHandoff + }) + .ok_or(Error::LegacyImportConflict)?; + let member = journal + .members() + .iter() + .find(|member| member.classification().kind() == LegacySourceKind::Studio) + .ok_or(Error::LegacyImportConflict)?; + if !matches!( + member.state(), + LegacyImportMemberState::Pending + | LegacyImportMemberState::Staging + | LegacyImportMemberState::Ready + ) || member.staged_row_count() != 0 + { + return Err(Error::LegacyImportConflict); + } + let snapshot = classified + .prepared + .snapshots() + .iter() + .find(|snapshot| snapshot.kind() == LegacySourceKind::Studio) + .ok_or(Error::LegacyImportConflict)?; + let evidence_path = classified.bundle_path().join(snapshot.relative_path()); + Ok(LegacyStudioHandoff { + import_id: classified.import_id(), + evidence_path, + byte_length: snapshot.byte_length(), + source_sha256: snapshot.sha256(), + catalog_sha256: classification.catalog_sha256(), + handoff_sha256: studio_handoff_digest(classified, snapshot, classification), + }) + } + + /// Records an exact host-owned Studio handoff acknowledgement without importing it. + pub async fn acknowledge_legacy_studio_handoff( + &self, + classified: &ClassifiedLegacyImport, + receipt: LegacyStudioHandoffReceipt, + updated_at_unix_ms: u64, + ) -> Result<LegacyImportJournal, Error> { + if updated_at_unix_ms == 0 { + return Err(Error::InvalidLegacyImportStageRequest); + } + let handoff = self.prepare_legacy_studio_handoff(classified).await?; + if receipt.handoff_sha256() != handoff.handoff_sha256() { + return Err(Error::LegacyImportConflict); + } + let receipt_cursor = studio_handoff_receipt_cursor(receipt); + let journal = self + .legacy_import_journal(classified.import_id()) + .await? + .ok_or(Error::InvalidLegacyImportJournal)?; + let member = journal + .members() + .iter() + .find(|member| member.classification().kind() == LegacySourceKind::Studio) + .ok_or(Error::LegacyImportConflict)?; + if member.state() == LegacyImportMemberState::Ready { + return if member.resume_cursor() == Some(receipt_cursor.as_slice()) { + Ok(journal) + } else { + Err(Error::LegacyImportConflict) + }; + } + if !matches!( + member.state(), + LegacyImportMemberState::Pending | LegacyImportMemberState::Staging + ) || member.resume_cursor().is_some() + || member.staged_row_count() != 0 + || updated_at_unix_ms < member.updated_at_unix_ms() + || updated_at_unix_ms < journal.updated_at_unix_ms() + { + return Err(Error::LegacyImportConflict); + } + let updated_at = i64::try_from(updated_at_unix_ms) + .map_err(|_| Error::InvalidLegacyImportStageRequest)?; + let mut transaction = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|_| Error::LegacyImportStagingFailed)?; + if journal.state() == LegacyImportState::Classified { + let changed = sqlx::query("UPDATE radroots_runtime_legacy_imports SET state = 'staging', updated_at_ms = ? WHERE import_id = ? AND state = 'classified'") + .bind(updated_at).bind(classified.import_id().as_bytes().as_slice()).execute(&mut *transaction).await.map_err(|_| Error::LegacyImportStagingFailed)?; + if changed.rows_affected() != 1 { + return Err(Error::LegacyImportConflict); + } + } + if member.state() == LegacyImportMemberState::Pending { + let changed = sqlx::query("UPDATE radroots_runtime_legacy_import_members SET state = 'staging', updated_at_ms = ? WHERE import_id = ? AND source_kind = 'studio' AND state = 'pending' AND resume_cursor IS NULL AND staged_row_count = 0") + .bind(updated_at).bind(classified.import_id().as_bytes().as_slice()).execute(&mut *transaction).await.map_err(|_| Error::LegacyImportStagingFailed)?; + if changed.rows_affected() != 1 { + return Err(Error::LegacyImportConflict); + } + } + let changed = sqlx::query("UPDATE radroots_runtime_legacy_import_members SET state = 'ready', resume_cursor = ?, updated_at_ms = ? WHERE import_id = ? AND source_kind = 'studio' AND state = 'staging' AND resume_cursor IS NULL AND staged_row_count = 0") + .bind(receipt_cursor.as_slice()).bind(updated_at).bind(classified.import_id().as_bytes().as_slice()).execute(&mut *transaction).await.map_err(|_| Error::LegacyImportStagingFailed)?; + if changed.rows_affected() != 1 { + return Err(Error::LegacyImportConflict); + } + let pending = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM radroots_runtime_legacy_import_members WHERE import_id = ? AND state != 'ready'") + .bind(classified.import_id().as_bytes().as_slice()).fetch_one(&mut *transaction).await.map_err(|_| Error::LegacyImportStagingFailed)?; + if pending == 0 { + sqlx::query("UPDATE radroots_runtime_legacy_imports SET state = 'ready', updated_at_ms = ? WHERE import_id = ? AND state = 'staging'") + .bind(updated_at).bind(classified.import_id().as_bytes().as_slice()).execute(&mut *transaction).await.map_err(|_| Error::LegacyImportStagingFailed)?; + } + transaction + .commit() + .await + .map_err(|_| Error::LegacyImportStagingFailed)?; + self.legacy_import_journal(classified.import_id()) + .await? + .ok_or(Error::InvalidLegacyImportJournal) + } + fn require_legacy_import_writer( &self, target_generation: SourceGeneration, @@ -2315,6 +2529,35 @@ fn classification_digest(classified: &ClassifiedLegacyImport) -> MemberDigest { MemberDigest::new(digest.finalize().into()) } +fn studio_handoff_digest( + classified: &ClassifiedLegacyImport, + snapshot: &LegacySourceSnapshot, + classification: &LegacySourceClassification, +) -> MemberDigest { + let mut digest = Sha256::new(); + for field in [ + b"radroots.legacy.studio.handoff.v1".as_slice(), + classified.import_id().as_bytes().as_slice(), + classified.target_generation().as_bytes().as_slice(), + classified.prepared.manifest_sha256().as_bytes().as_slice(), + snapshot.relative_path().as_bytes(), + snapshot.sha256().as_bytes().as_slice(), + classification.catalog_sha256().as_bytes().as_slice(), + ] { + digest.update(field); + digest.update([0]); + } + digest.update(snapshot.byte_length().to_be_bytes()); + MemberDigest::new(digest.finalize().into()) +} + +fn studio_handoff_receipt_cursor(receipt: LegacyStudioHandoffReceipt) -> [u8; 64] { + let mut cursor = [0_u8; 64]; + cursor[..32].copy_from_slice(receipt.handoff_sha256().as_bytes()); + cursor[32..].copy_from_slice(receipt.host_commitment_sha256().as_bytes()); + cursor +} + fn outbox_stage_query(table: LegacyOutboxTable) -> &'static str { match table { LegacyOutboxTable::Operations => { @@ -2841,6 +3084,8 @@ mod tests { const OUTBOX_V1_SQL: &str = include_str!("../../outbox/migrations/0001_outbox.up.sql"); const PRIVATE_STAGING_POLICY: &str = include_str!("../../../contracts/storage/legacy_private_staging_policy_v1.toml"); + const STUDIO_HANDOFF_POLICY: &str = + include_str!("../../../contracts/storage/legacy_studio_handoff_policy_v1.toml"); const SDK_PRIVATE_STORE_SOURCE: &str = include_str!("../../../../sdk/crates/sdk/src/private_store.rs"); @@ -2987,6 +3232,26 @@ mod tests { hidden_clock_or_entropy: bool, } + #[derive(Deserialize)] + struct StudioHandoffPolicy { + schema_version: u32, + source_kind: String, + supported_schema: String, + disposition: String, + evidence: String, + handoff_identity: String, + receipt: String, + receipt_cursor_bytes: usize, + staged_row_count: u64, + exact_retry: String, + conflicting_retry: String, + sdk_runtime_row_import: bool, + sdk_private_row_import: bool, + sdk_owned_studio_database: bool, + source_deletion: bool, + hidden_clock_or_entropy: bool, + } + fn generation(byte: u8) -> SourceGeneration { SourceGeneration::new([byte; 32]).expect("source generation") } @@ -3604,6 +3869,34 @@ mod tests { assert!(!policy.hidden_clock_or_entropy); } + #[test] + fn implementation_matches_the_governed_studio_handoff_policy() { + let policy = toml::from_str::<StudioHandoffPolicy>(STUDIO_HANDOFF_POLICY) + .expect("Studio handoff policy"); + assert_eq!(policy.schema_version, 1); + assert_eq!(policy.source_kind, "studio"); + assert_eq!(policy.supported_schema, "studio_v1_host_handoff"); + assert_eq!(policy.disposition, "host_handoff"); + assert_eq!(policy.evidence, "immutable_preimport_backup_member"); + assert_eq!( + policy.handoff_identity, + "sha256_domain_import_target_manifest_relative_path_source_catalog_length" + ); + assert_eq!( + policy.receipt, + "handoff_sha256_plus_nonzero_opaque_host_commitment_sha256" + ); + assert_eq!(policy.receipt_cursor_bytes, 64); + assert_eq!(policy.staged_row_count, 0); + assert_eq!(policy.exact_retry, "idempotent"); + assert_eq!(policy.conflicting_retry, "reject"); + assert!(!policy.sdk_runtime_row_import); + assert!(!policy.sdk_private_row_import); + assert!(!policy.sdk_owned_studio_database); + assert!(!policy.source_deletion); + assert!(!policy.hidden_clock_or_entropy); + } + fn assert_fixed_schema_policy( policy: &FixedSchemaPolicy, user_version: i64, @@ -4352,6 +4645,117 @@ mod tests { reopened.close().await.expect("close private target"); } + #[tokio::test] + async fn studio_handoff_requires_exact_host_receipt_and_imports_no_rows() { + let target_root = tempfile::tempdir().expect("target root"); + let legacy_root = tempfile::tempdir().expect("legacy root"); + let backup_root = tempfile::tempdir().expect("backup root"); + let host_root = tempfile::tempdir().expect("host root"); + let studio_path = legacy_root.path().join("studio.sqlite"); + let studio_connection = supported_studio_database(&studio_path).await; + let plan = LegacyImportPlan::new( + LegacyImportId::new([132; 16]).expect("import id"), + vec![LegacySource::new(LegacySourceKind::Studio, &studio_path).expect("Studio source")], + backup_root.path(), + 13_200, + ) + .expect("Studio import plan"); + let (_, store) = target(target_root.path()).await; + let prepared = store + .prepare_legacy_import(&plan) + .await + .expect("prepared Studio import"); + let classified = store + .classify_legacy_import(&prepared) + .await + .expect("classified Studio import"); + store + .begin_legacy_import(&classified, 13_201) + .await + .expect("begin Studio import"); + + let handoff = store + .prepare_legacy_studio_handoff(&classified) + .await + .expect("Studio handoff"); + assert_eq!(handoff.import_id(), plan.import_id()); + assert!(handoff.evidence_path().starts_with(prepared.bundle_path())); + assert_eq!( + handoff.catalog_sha256(), + classified.sources()[0].catalog_sha256() + ); + let host_path = host_root.path().join("studio.sqlite"); + std::fs::copy(handoff.evidence_path(), &host_path).expect("host accepts evidence"); + let (host_length, host_commitment) = file_digest(&host_path).expect("host commitment"); + assert_eq!(host_length, handoff.byte_length()); + assert_eq!(host_commitment, handoff.source_sha256()); + assert!(matches!( + LegacyStudioHandoffReceipt::new(handoff.handoff_sha256(), MemberDigest::new([0; 32])), + Err(Error::InvalidLegacyImportStageRequest) + )); + let receipt = LegacyStudioHandoffReceipt::new(handoff.handoff_sha256(), host_commitment) + .expect("host receipt"); + let acknowledged = store + .acknowledge_legacy_studio_handoff(&classified, receipt, 13_202) + .await + .expect("acknowledge Studio handoff"); + assert_eq!(acknowledged.state(), LegacyImportState::Ready); + assert_eq!( + acknowledged.members()[0].state(), + LegacyImportMemberState::Ready + ); + assert_eq!(acknowledged.members()[0].staged_row_count(), 0); + assert_eq!( + acknowledged.members()[0].resume_cursor().map(<[u8]>::len), + Some(64) + ); + + let retry = store + .acknowledge_legacy_studio_handoff(&classified, receipt, 13_202) + .await + .expect("exact receipt retry"); + assert_eq!(retry, acknowledged); + let conflict = + LegacyStudioHandoffReceipt::new(handoff.handoff_sha256(), MemberDigest::new([99; 32])) + .expect("conflicting host receipt"); + assert!(matches!( + store + .acknowledge_legacy_studio_handoff(&classified, conflict, 13_203) + .await, + Err(Error::LegacyImportConflict) + )); + for pool in [store.pool(), store.private_pool()] { + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_schema WHERE lower(name) LIKE '%studio%'" + ) + .fetch_one(pool) + .await + .expect("Studio schema isolation"), + 0 + ); + } + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM radroots_runtime_events") + .fetch_one(store.pool()) + .await + .expect("runtime event isolation"), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM radroots_private_artifacts") + .fetch_one(store.private_pool()) + .await + .expect("private artifact isolation"), + 0 + ); + studio_connection + .close() + .await + .expect("close Studio source"); + store.close().await.expect("close target"); + } + #[test] fn pure_catalog_classification_rejects_mixed_unknown_and_newer_schemas() { let studio = vec![CatalogRow {