From 8f07b30efe941231c65084314e4770101e076ee6 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Fri, 23 Oct 2026 16:56:05 +0530 Subject: [PATCH] fix(storage): declare only, and migrate nothing, on host-managed ports The bus module opened its box store with `open`, which also ensures the v1 source collections. A host that owns the schema refuses those with `schema-is-host-only`, so no box store opens and every command resource fails. Add `open_host_managed` for both stores: it confirms the current declarations and leaves the legacy migration to the host's importer. Co-Authored-By: Claude Opus 5.5 --- crates/tinybox-drivers/src/box_store.rs | 22 ++++++++ crates/tinybox-drivers/src/faulty_tests.rs | 30 ++++++++++ crates/tinybox-drivers/src/index_store.rs | 24 ++++++++ crates/tinybox-drivers/src/migrations.rs | 21 ++++++- .../tinybox-drivers/src/migrations_tests.rs | 56 +++++++++++++++++++ .../src/tinybus_module/storage.rs | 10 +++- .../src/tinybus_module/storage_tests.rs | 14 +++++ 7 files changed, 172 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-drivers/src/box_store.rs b/crates/tinybox-drivers/src/box_store.rs index d4b5bf85..1e35b365 100644 --- a/crates/tinybox-drivers/src/box_store.rs +++ b/crates/tinybox-drivers/src/box_store.rs @@ -44,6 +44,28 @@ impl DocumentBoxStore { migrations::prepare(&storage) .await .map_err(|error| store_error("open", &error))?; + Self::on_prepared(&storage) + } + + /// Open a store on ports a host manages, reached over the storage bus. + /// + /// The host owns the schema and the data migration there: it declared the + /// collections when it bound the module's scope, and its grant does not + /// reach the v1 source collections, so this only confirms the current + /// declarations and migrates nothing. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when the host refuses a declaration or the + /// bridge cannot be opened. + pub async fn open_host_managed(storage: Storage) -> Result { + migrations::declare(&storage) + .await + .map_err(|error| store_error("open", &error))?; + Self::on_prepared(&storage) + } + + fn on_prepared(storage: &Storage) -> Result { let documents: Arc = Arc::clone(storage.documents()); let bridge = Blocking::new().map_err(|error| store_error("open", &error))?; let boxes = Repo::new(&documents, BOX_COLLECTION) diff --git a/crates/tinybox-drivers/src/faulty_tests.rs b/crates/tinybox-drivers/src/faulty_tests.rs index a55dfbc4..3719173b 100644 --- a/crates/tinybox-drivers/src/faulty_tests.rs +++ b/crates/tinybox-drivers/src/faulty_tests.rs @@ -42,6 +42,9 @@ pub(crate) struct Faulty { source_replacement: Mutex>, target_replacement: Mutex>, receipt_race: std::sync::atomic::AtomicBool, + /// Like a host that owns the schema: declaring any collection outside this + /// list is refused. + grant: Option>, } impl Faulty { @@ -69,6 +72,7 @@ impl Faulty { source_replacement: Mutex::new(None), target_replacement: Mutex::new(None), receipt_race: std::sync::atomic::AtomicBool::new(false), + grant: None, }) } @@ -82,6 +86,7 @@ impl Faulty { source_replacement: Mutex::new(None), target_replacement: Mutex::new(None), receipt_race: std::sync::atomic::AtomicBool::new(false), + grant: None, }) } @@ -95,6 +100,7 @@ impl Faulty { source_replacement: Mutex::new(None), target_replacement: Mutex::new(None), receipt_race: std::sync::atomic::AtomicBool::new(false), + grant: None, }) } @@ -112,6 +118,7 @@ impl Faulty { source_replacement: Mutex::new(Some((source_id, replacement))), target_replacement: Mutex::new(None), receipt_race: std::sync::atomic::AtomicBool::new(false), + grant: None, }) } @@ -129,6 +136,23 @@ impl Faulty { source_replacement: Mutex::new(None), target_replacement: Mutex::new(Some((target_id, replacement))), receipt_race: std::sync::atomic::AtomicBool::new(false), + grant: None, + }) + } + + /// Refuses to declare any collection but `granted`, as a host that owns + /// the module's schema does (`schema-is-host-only`). + pub(crate) fn granting(inner: Arc, granted: &[&str]) -> Arc { + Arc::new(Self { + failing: Failing::new(Arc::clone(&inner)), + inner, + conflicts: AtomicUsize::new(0), + fail_target_readback: false, + target_written: std::sync::atomic::AtomicBool::new(false), + source_replacement: Mutex::new(None), + target_replacement: Mutex::new(None), + receipt_race: std::sync::atomic::AtomicBool::new(false), + grant: Some(granted.iter().map(|name| (*name).to_owned()).collect()), }) } @@ -142,6 +166,7 @@ impl Faulty { source_replacement: Mutex::new(None), target_replacement: Mutex::new(None), receipt_race: std::sync::atomic::AtomicBool::new(true), + grant: None, }) } } @@ -153,6 +178,11 @@ impl DocumentStore for Faulty { } async fn ensure_collection(&self, spec: &CollectionSpec) -> Result<()> { + if let Some(grant) = &self.grant + && !grant.contains(&spec.name) + { + return Err(StorageError::invalid_input("schema-is-host-only")); + } self.failing .call("ensure", self.inner.ensure_collection(spec)) .await diff --git a/crates/tinybox-drivers/src/index_store.rs b/crates/tinybox-drivers/src/index_store.rs index a8d9e034..9cd6a0b1 100644 --- a/crates/tinybox-drivers/src/index_store.rs +++ b/crates/tinybox-drivers/src/index_store.rs @@ -49,6 +49,30 @@ impl DocumentIndexStore { migrations::prepare(&storage) .await .map_err(|error| to_io(&error))?; + Self::on_prepared(&storage, registry) + } + + /// Open an index store on ports a host manages, reached over the storage + /// bus: only the current declarations are confirmed, and nothing is + /// migrated (see [`DocumentBoxStore::open_host_managed`]). + /// + /// # Errors + /// + /// Returns an error when the host refuses a declaration, the bridge cannot + /// start, or `registry` cannot be a document id. + /// + /// [`DocumentBoxStore::open_host_managed`]: crate::DocumentBoxStore::open_host_managed + pub async fn open_host_managed( + storage: Storage, + registry: impl Into, + ) -> io::Result { + migrations::declare(&storage) + .await + .map_err(|error| to_io(&error))?; + Self::on_prepared(&storage, registry) + } + + fn on_prepared(storage: &Storage, registry: impl Into) -> io::Result { let documents: Arc = Arc::clone(storage.documents()); let registry = registry.into(); let bridge = Blocking::new().map_err(|e| to_io(&e))?; diff --git a/crates/tinybox-drivers/src/migrations.rs b/crates/tinybox-drivers/src/migrations.rs index e557b2cf..5011a9b3 100644 --- a/crates/tinybox-drivers/src/migrations.rs +++ b/crates/tinybox-drivers/src/migrations.rs @@ -25,16 +25,31 @@ struct MigrationReceipt { complete: bool, } +/// Ensure `TinyBox`'s current declarations, and nothing else. +/// +/// This is all a host-managed port gets ([`DocumentBoxStore::open_host_managed`] +/// and its siblings): the host declared these collections when it bound the +/// module's scope, so the call only confirms host and module agree on them. The +/// host's grant does not include the v1 source collections, and the host's own +/// import owns their migration. +/// +/// [`DocumentBoxStore::open_host_managed`]: crate::DocumentBoxStore::open_host_managed +pub(crate) async fn declare(storage: &Storage) -> Result<()> { + let documents = storage.documents(); + for spec in collections() { + documents.ensure_collection(&spec).await?; + } + Ok(()) +} + /// Ensure `TinyBox`'s declarations and migrate every known legacy row. /// /// Migration receipts record successful scans. Legacy rows are still scanned /// on every open so a delayed old writer cannot leave an unreadable record /// behind a stale completion marker. pub(crate) async fn prepare(storage: &Storage) -> Result<()> { + declare(storage).await?; let documents = Arc::clone(storage.documents()); - for spec in collections() { - documents.ensure_collection(&spec).await?; - } documents .ensure_collection(&CollectionSpec::new(V1_BOX_COLLECTION)) .await?; diff --git a/crates/tinybox-drivers/src/migrations_tests.rs b/crates/tinybox-drivers/src/migrations_tests.rs index f8433c39..f64f53b1 100644 --- a/crates/tinybox-drivers/src/migrations_tests.rs +++ b/crates/tinybox-drivers/src/migrations_tests.rs @@ -712,3 +712,59 @@ async fn malformed_receipts_and_failed_target_writes_preserve_migration_sources( ); Ok(()) } + +/// The current collection names, as a host that owns the module's schema +/// grants them. +fn granted_collections() -> Vec { + collections().into_iter().map(|spec| spec.name).collect() +} + +fn with_host_grant(storage: &Storage) -> Storage { + let granted = granted_collections(); + let granted: Vec<&str> = granted.iter().map(String::as_str).collect(); + Storage::new( + Faulty::granting(Arc::clone(storage.documents()), &granted), + Arc::clone(storage.streams()), + Arc::clone(storage.kv()), + Arc::clone(storage.blobs()), + Arc::clone(storage.queues()), + Arc::clone(storage.secrets()), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_host_owned_schema_refuses_what_open_declares_but_not_a_host_managed_open() -> TestResult +{ + let granted = with_host_grant(&storage("host-grant")?); + + assert!( + DocumentBoxStore::open(granted.clone()).await.is_err(), + "open declares the v1 sources, which the host's grant refuses" + ); + DocumentBoxStore::open_host_managed(granted.clone()).await?; + DocumentIndexStore::open_host_managed(granted, "registry").await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_host_managed_open_migrates_nothing() -> TestResult { + let storage = storage("host-managed")?; + seed_legacy(&storage, "legacy", old_box("legacy"), None).await?; + + DocumentBoxStore::open_host_managed(storage.clone()).await?; + + let documents = storage.documents(); + assert!( + documents + .get(MIGRATIONS_COLLECTION, BOXES_STEP) + .await? + .is_none(), + "no receipt is written: the host's import owns the migration" + ); + assert!(documents.get(BOX_COLLECTION, "legacy").await?.is_none()); + assert!( + documents.get(V1_BOX_COLLECTION, "legacy").await?.is_some(), + "the v1 row is left for the host" + ); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/storage.rs b/crates/tinybox-module/src/tinybus_module/storage.rs index 1b37d061..966982e0 100644 --- a/crates/tinybox-module/src/tinybus_module/storage.rs +++ b/crates/tinybox-module/src/tinybus_module/storage.rs @@ -5,6 +5,12 @@ //! the host binds the scope from the authenticated caller, so nothing here //! names one. If the host does not serve storage, durable box operations fail //! closed; the module never substitutes private in-memory records. +//! +//! These ports are host-managed: the host declared the collections when it +//! bound the scope and its grant is the allowlist, so the stores open with +//! `open_host_managed`, which confirms the current declarations and runs no +//! in-module migration (the v1 collections it reads lie outside the grant; the +//! host's import owns that migration). use std::sync::Arc; @@ -95,7 +101,7 @@ impl ModuleStorage { if let Some(store) = slot.as_ref() { return Ok(Arc::clone(store)); } - let opened = tinybox_drivers::DocumentBoxStore::open(storage) + let opened = tinybox_drivers::DocumentBoxStore::open_host_managed(storage) .await .inspect_err(|error| { log::warn!("[tinybox_module] box store on host storage unavailable: {error}"); @@ -114,7 +120,7 @@ impl ModuleStorage { name: String, ) -> Option> { let storage = self.scoped().await?; - let index = tinybox_drivers::DocumentIndexStore::open(storage, name).await; + let index = tinybox_drivers::DocumentIndexStore::open_host_managed(storage, name).await; let opened = tokio::task::spawn_blocking(move || { let index = index?; tinybox_jail::JailRegistry::open_with_store( diff --git a/crates/tinybox-module/src/tinybus_module/storage_tests.rs b/crates/tinybox-module/src/tinybus_module/storage_tests.rs index 3f45f19e..3c5913fd 100644 --- a/crates/tinybox-module/src/tinybus_module/storage_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/storage_tests.rs @@ -255,3 +255,17 @@ async fn a_recording_view_finds_only_its_own_record() -> TestResult { assert!(first.first_inserted().is_none()); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_bus_backed_store_leaves_the_schema_and_migrations_to_the_host() -> TestResult { + let rig = rig(true).await?; + let storage = ModuleStorage::connected(rig.module.clone()); + storage.box_store().await?; + + let receipts = host_docs(&rig, tinybox_drivers::MIGRATIONS_COLLECTION).await?; + assert!( + receipts.is_empty(), + "the module ran its own migrations on the host's ports: {receipts:?}" + ); + Ok(()) +}