From dfa04107abd5de1540dc3ffe550829254635317a Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 12:11:35 +0900 Subject: [PATCH] feat: bind FeatureStorage identity and restore to persistent owners --- crates/feature-storage/README.md | 58 +- crates/feature-storage/src/identity_tests.rs | 993 +++++++++++++++ crates/feature-storage/src/legacy_owner.rs | 136 ++ crates/feature-storage/src/lib.rs | 812 ++++++++++-- crates/feature-storage/src/owner.rs | 406 ++++++ crates/subjektiv/src/deletion/tests.rs | 20 +- crates/subjektiv/src/journal/tests.rs | 95 +- crates/subjektiv/src/lib.rs | 8 +- crates/subjektiv/src/storage_format_tests.rs | 581 +++++++++ crates/subjektiv/src/storage_formats.rs | 432 +++++++ crates/workspace-drive/tests/storage.rs | 3 +- .../src/frozen_repository_key_tests.rs | 8 +- .../src/frozen_runtime_binding_tests.rs | 4 +- .../src/implementation_result_tests.rs | 2 +- crates/workspace-server/src/server.rs | 15 + .../server_plugin_authority_storage_tests.rs | 32 +- .../src/server_plugin_host.rs | 5 +- .../src/storage_format_tests.rs | 362 ++++++ .../workspace-server/src/storage_formats.rs | 1109 +++++++++++++++++ .../src/storage_owner_tests.rs | 151 +++ crates/workspace-server/src/store.rs | 118 +- .../src/store_test_support.rs | 4 +- .../src/workspace_drive_host.rs | 8 + tools/state-contract-name-exceptions.json | 11 + 24 files changed, 5165 insertions(+), 208 deletions(-) create mode 100644 crates/feature-storage/src/identity_tests.rs create mode 100644 crates/feature-storage/src/legacy_owner.rs create mode 100644 crates/feature-storage/src/owner.rs create mode 100644 crates/subjektiv/src/storage_format_tests.rs create mode 100644 crates/subjektiv/src/storage_formats.rs create mode 100644 crates/workspace-server/src/storage_format_tests.rs create mode 100644 crates/workspace-server/src/storage_formats.rs create mode 100644 crates/workspace-server/src/storage_owner_tests.rs diff --git a/crates/feature-storage/README.md b/crates/feature-storage/README.md index 1ee4d9680..a54fc73d6 100644 --- a/crates/feature-storage/README.md +++ b/crates/feature-storage/README.md @@ -1,11 +1,55 @@ # feature-storage -Manages the SQLite lifecycle for Features within a trusted Host. -Does not own Feature domain schemas or authentication, or provide SQL access to Workers or models. +Owns physical SQLite identity and lifecycle for trusted Host repositories. +Features own domain schema, authorization and transactions; storage identity is not a grant. +Workers and models do not receive SQL connections. -## Main entry points +[`ScopedFeatureStorage::provision`] is fresh-only; +[`ScopedFeatureStorage::adopt_existing`] requires explicit Host authority and +persisted domain attribution. [`ScopedFeatureStorage::open_existing`] consumes +an expected Workspace/Feature/physical identity and never creates a missing DB. +The lazy [`ScopedFeatureStorage::open`] requests initial Host provisioning only +when no ownership record exists; an unbound existing file requires adoption, +and a missing previously bound file is an error, not a new incarnation. -- [`FeatureStorage`], [`ScopedFeatureStorage`]: registration, scope isolation, storage, and shutdown -- [`FeatureDatabase`]: connections and transactions -- [`FeatureMigration`]: Feature-owned schema changes -- [`configure_connection`]: shared SQLite connection policy +[`FeatureDatabase`] operations retain the verified connection and shared +cross-process lifecycle lease through consumer commit/rollback. +[`DatabaseGuard`] extends the short lease over a writer-release decision without +retaining a SQLite transaction. Long Job waits retain only [`DatabaseIdentity`]; +resumption must reacquire storage and current domain authorization. + +Lock order is scope lifecycle file, manager state, connection, SQLite transaction. +The Host must protect the root, owner catalog and stable lockfiles against native +code bypassing the owner. A successful same-root lock does not prove arbitrary +external writers, a previous Host, or a different filesystem participate. +Same-path replacement is checked by physical file identity as well as DB identity; +old receipt bytes alone cannot authorize a copied file. Restore records a new +physical lifecycle and retains original provenance. A durable erasure fence lives +outside the removed scope and cannot be cleared by restoring an older snapshot. + +`restore` recovers missing scope files using the same owner's retained source +receipts, never by calling `delete` first. `restore_from` requires the Host to +select the source owner explicitly for a cross-root copy. Both check completed +snapshot provenance and the source erasure fence through publication; a fresh +root is not a way to forget that fence. Cross-root locks are acquired in canonical +root order, source shared and target exclusive; same-root recovery takes one +exclusive lock. A restore retires, but does not remove, the previous owner +receipt. Only the new physical identity is current, including after exact retry. +A snapshot without matching source-owner evidence requires explicit, authorized +legacy adoption with domain attribution/shape validation instead of blind restore. +These checks cover owner-managed operations, not deliberate destruction of the +owner catalog/fences or arbitrary Host code inventing authority evidence. + +Adoption identity and domain migrations share a SQLite transaction. A separate +owner receipt binds the stable request, target, authority evidence and inode. +If the DB commits but completion or its response is lost, the same request +reconciles the same physical DB. An unfinished reservation is not an adoption +receipt; a different target or request cannot reuse it. Failure after a fresh file +is created but before its reservation commits leaves an unowned file requiring +explicit Host reconciliation, never discovery-based issuance. + +[`FeatureRegistration`] uses the existing ordered migration owner; a Feature can +validate its finite published receipt/shape families without rewriting original +migration identities or treating physical acceptance as domain-policy adoption. +[`FeatureStorage::open_host_catalog`] connects the Host catalog to the same +identity/lifecycle owner; [`configure_connection`] owns shared SQLite durability policy. diff --git a/crates/feature-storage/src/identity_tests.rs b/crates/feature-storage/src/identity_tests.rs new file mode 100644 index 000000000..3387ab229 --- /dev/null +++ b/crates/feature-storage/src/identity_tests.rs @@ -0,0 +1,993 @@ +//! Component tests of FeatureStorage + owner receipts + real SQLite. The only +//! injected failures are returned validator errors or abandoned real transactions; +//! these do not claim arbitrary crash/power-loss durability. Run with the normal +//! feature-storage lib target. Unix child tests use this same test executable, +//! isolated roots, explicit child-only environment, and observed lock contention. + +use super::*; +use std::cell::Cell; +use std::process::{Child, Command, Stdio}; +use std::time::Instant; + +const SCOPE: &str = "identity-workspace"; +const FEATURE: &str = "identity-feature"; +const REQUEST: &str = "host-adoption-request"; +const EVIDENCE: &str = "Host selected this legacy Workspace and Feature"; +const WAIT: Duration = Duration::from_secs(15); + +fn create_records(tx: &Transaction<'_>) -> Result<()> { + tx.execute_batch("CREATE TABLE IF NOT EXISTS records (value TEXT NOT NULL UNIQUE);")?; + Ok(()) +} + +const PLAN: &[FeatureMigration] = &[FeatureMigration::new(1, "records", create_records)]; + +fn registered(root: &Path) -> (FeatureStorage, RegisteredFeature) { + let manager = FeatureStorage::new(root); + let registration = manager + .register(FeatureRegistration::new(FEATURE, PLAN)) + .unwrap(); + (manager, registration) +} + +fn database_path(root: &Path, scope: &str) -> PathBuf { + root.join(scope) + .join("features") + .join(format!("{FEATURE}.sqlite")) +} + +fn legacy(root: &Path, scope: &str) -> PathBuf { + let path = database_path(root, scope); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch( + "CREATE TABLE records (value TEXT NOT NULL UNIQUE); + INSERT INTO records VALUES ('preserved'); + CREATE TABLE attribution (scope TEXT NOT NULL, feature TEXT NOT NULL);", + ) + .unwrap(); + connection + .execute( + "INSERT INTO attribution VALUES (?1, ?2)", + params![scope, FEATURE], + ) + .unwrap(); + path +} + +fn validate_legacy(tx: &Transaction<'_>) -> Result<()> { + let attribution: (String, String) = + tx.query_row("SELECT scope, feature FROM attribution", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + })?; + if attribution != (SCOPE.into(), FEATURE.into()) { + return Err(FeatureStorageError::operation("legacy attribution differs")); + } + // Exercise the actual supported shape, not just a validator invocation flag. + let _: String = tx.query_row("SELECT value FROM records", [], |r| r.get(0))?; + Ok(()) +} + +fn values(database: &FeatureDatabase) -> Vec { + database + .with_connection(|connection| { + Ok(connection + .prepare("SELECT value FROM records ORDER BY value")? + .query_map([], |r| r.get(0))? + .collect::, _>>()?) + }) + .unwrap() +} + +fn insert(database: &FeatureDatabase, value: &str) { + database + .transaction(|tx| { + tx.execute("INSERT INTO records VALUES (?1)", [value])?; + Ok(()) + }) + .unwrap(); +} + +fn assert_identity_error(result: Result) { + match result { + Err(FeatureStorageError::Identity(_)) => {} + Err(error) => panic!("expected identity refusal, got {error:?}"), + Ok(_) => panic!("expected identity refusal, operation succeeded"), + } +} + +fn has_identity(connection: &Connection) -> bool { + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='__yoi_storage_identity')", + [], + |r| r.get(0), + ) + .unwrap() +} + +#[test] +fn provision_then_reopen_retains_physical_identity_and_committed_data() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let (manager, registration) = registered(&root); + assert!(!root.exists(), "registration must remain lazy"); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.provision(®istration, REQUEST, EVIDENCE).unwrap(); + let identity = database.identity().clone(); + insert(&database, "acknowledged"); + assert_eq!(identity.scope, SCOPE); + assert_eq!(identity.feature, FEATURE); + assert!(!identity.physical.is_empty()); + drop(database); + drop(scoped); + drop(manager); + + let (manager, registration) = registered(&root); + let database = manager + .scope(SCOPE) + .unwrap() + .open_existing(®istration, &identity) + .unwrap(); + assert_eq!(database.identity(), &identity); + assert_eq!(values(&database), ["acknowledged"]); + let scoped = manager.scope(SCOPE).unwrap(); + let replay = scoped.provision(®istration, REQUEST, EVIDENCE).unwrap(); + assert_eq!(replay.identity(), &identity); + assert_eq!(values(&replay), ["acknowledged"]); + assert_identity_error(scoped.provision(®istration, "different-request", EVIDENCE)); + assert_identity_error(manager.scope("another-workspace").unwrap().provision( + ®istration, + REQUEST, + EVIDENCE, + )); + assert!(!database_path(&root, "another-workspace").exists()); + let owner = Connection::open(root.join(".owner.sqlite")).unwrap(); + let receipt: (String, i64) = owner + .query_row( + "SELECT physical, completed FROM storage_requests WHERE scope=?1 AND feature=?2", + params![SCOPE, FEATURE], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(receipt, (identity.physical, 1)); +} + +#[test] +fn unowned_legacy_requires_explicit_evidence_and_real_domain_validation() { + let temp = tempfile::tempdir().unwrap(); + let path = legacy(temp.path(), SCOPE); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + assert!( + scoped.open(®istration).is_err(), + "existence is not adoption permission" + ); + assert!( + scoped + .provision(®istration, "new-provision-request", EVIDENCE) + .is_err() + ); + assert!(!has_identity(&Connection::open(&path).unwrap())); + assert_identity_error(scoped.adopt_existing(®istration, REQUEST, " ", validate_legacy)); + assert!(!has_identity(&Connection::open(&path).unwrap())); + let database = scoped + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .unwrap(); + let identity = database.identity().clone(); + assert_eq!(values(&database), ["preserved"]); + drop(database); + drop(scoped); + drop(manager); + let (manager, registration) = registered(temp.path()); + let reopened = manager + .scope(SCOPE) + .unwrap() + .open_existing(®istration, &identity) + .unwrap(); + assert_eq!(values(&reopened), ["preserved"]); +} + +#[test] +fn rejected_legacy_attribution_or_shape_rolls_back_without_completing_ownership() { + for corrupt in [ + "UPDATE attribution SET scope='another-workspace'", + "ALTER TABLE records RENAME COLUMN value TO unsupported", + ] { + let temp = tempfile::tempdir().unwrap(); + let path = legacy(temp.path(), SCOPE); + let raw = Connection::open(&path).unwrap(); + raw.execute_batch(corrupt).unwrap(); + let (manager, registration) = registered(temp.path()); + assert!( + manager + .scope(SCOPE) + .unwrap() + .adopt_existing(®istration, REQUEST, EVIDENCE, |tx| { + tx.execute_batch("CREATE TABLE must_rollback (value TEXT);")?; + validate_legacy(tx) + },) + .is_err() + ); + assert!(!has_identity(&raw)); + let leaked: bool = raw.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name IN ('must_rollback', '__yoi_feature_schema_migrations'))", + [], |r| r.get(0), + ).unwrap(); + assert!( + !leaked, + "failed validator must roll back its own writes and admission" + ); + let catalog = Connection::open(temp.path().join(".owner.sqlite")).unwrap(); + let completed: i64 = catalog + .query_row( + "SELECT completed FROM storage_requests WHERE request=?1", + [REQUEST], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(completed, 0); + } +} + +#[test] +fn adoption_response_loss_replays_same_identity_but_refuses_changed_request_or_target() { + let temp = tempfile::tempdir().unwrap(); + legacy(temp.path(), SCOPE); + let (manager, registration) = registered(temp.path()); + let database = manager + .scope(SCOPE) + .unwrap() + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .unwrap(); + let expected = database.identity().clone(); + insert(&database, "after-admission"); + // Lose the returned handle/manager, not the committed ownership receipt. + drop(database); + drop(manager); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + let replay = scoped + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .unwrap(); + assert_eq!(replay.identity(), &expected); + assert_eq!(values(&replay), ["after-admission", "preserved"]); + assert_identity_error(scoped.adopt_existing( + ®istration, + "different-request", + EVIDENCE, + validate_legacy, + )); + assert_identity_error(scoped.adopt_existing( + ®istration, + REQUEST, + "changed-authority", + validate_legacy, + )); + legacy(temp.path(), "another-workspace"); + assert_identity_error(manager.scope("another-workspace").unwrap().adopt_existing( + ®istration, + REQUEST, + EVIDENCE, + |_| panic!("changed target must be refused before domain validation"), + )); + let owner = Connection::open(temp.path().join(".owner.sqlite")).unwrap(); + let count: i64 = owner + .query_row("SELECT COUNT(*) FROM storage_requests", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1, "replay must not manufacture another identity"); +} + +#[test] +fn reserved_adoption_reconciles_at_each_real_identity_commit_boundary() { + // 0: before identity writes, 1: identity written but rolled back, + // 2: identity committed but owner completion missing, 3: completion response lost. + for boundary in 0..4 { + let temp = tempfile::tempdir().unwrap(); + let path = legacy(temp.path(), SCOPE); + let lifecycle = owner::Lifecycle { + root: temp.path().into(), + scope: SCOPE.into(), + }; + let expected = { + let _lease = lifecycle.lock(true).unwrap(); + let binding = lifecycle + .reserve(SCOPE, FEATURE, &path, REQUEST, "adopt", EVIDENCE) + .unwrap(); + let mut connection = + Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap(); + configure_connection(&connection).unwrap(); + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + validate_legacy(&tx).unwrap(); + migrate(&tx, FEATURE, FeatureRegistration::new(FEATURE, PLAN)).unwrap(); + tx.execute("INSERT INTO records VALUES ('boundary-write')", []) + .unwrap(); + if boundary >= 1 { + binding.persist(&tx).unwrap(); + } + if boundary >= 2 { + tx.commit().unwrap(); + } else { + tx.rollback().unwrap(); + } + if boundary == 3 { + lifecycle.complete(&binding).unwrap(); + } + binding.identity + }; + let raw = Connection::open(&path).unwrap(); + assert_eq!(has_identity(&raw), boundary >= 2); + let count: i64 = raw + .query_row("SELECT COUNT(*) FROM records", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, if boundary >= 2 { 2 } else { 1 }); + drop(raw); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + if boundary < 3 { + assert_identity_error(scoped.open_existing(®istration, &expected)); + } + let database = scoped + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .unwrap(); + assert_eq!( + database.identity(), + &expected, + "boundary {boundary} changed reserved identity" + ); + assert_eq!(values(&database).len(), count as usize); + drop(database); + drop(scoped); + drop(manager); + let (manager, registration) = registered(temp.path()); + let database = manager + .scope(SCOPE) + .unwrap() + .open_existing(®istration, &expected) + .unwrap(); + assert_eq!(values(&database).len(), count as usize); + let catalog = Connection::open(temp.path().join(".owner.sqlite")).unwrap(); + let receipt: (i64, i64) = catalog + .query_row( + "SELECT COUNT(*), SUM(completed) FROM storage_requests", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(receipt, (1, 1)); + } +} + +#[test] +fn owner_completion_sqlite_failure_reconciles_the_already_committed_identity() { + let temp = tempfile::tempdir().unwrap(); + let path = legacy(temp.path(), SCOPE); + let (manager, registration) = registered(temp.path()); + { + let lifecycle = manager.lifecycle(SCOPE); + let _lease = lifecycle.lock(true).unwrap(); + assert!(lifecycle.lookup(SCOPE, FEATURE).unwrap().is_none()); + lifecycle + .reserve(SCOPE, FEATURE, &path, REQUEST, "adopt", EVIDENCE) + .unwrap(); + let catalog = Connection::open(temp.path().join(".owner.sqlite")).unwrap(); + catalog + .execute_batch( + "CREATE TRIGGER reject_completion BEFORE UPDATE OF completed ON storage_requests + BEGIN SELECT RAISE(ABORT, 'injected owner completion failure'); END;", + ) + .unwrap(); + } + let scoped = manager.scope(SCOPE).unwrap(); + assert!(matches!( + scoped.adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy), + Err(FeatureStorageError::OutcomeUnknown { .. }) + )); + let raw = Connection::open(&path).unwrap(); + let physical: String = raw + .query_row("SELECT physical FROM __yoi_storage_identity", [], |r| { + r.get(0) + }) + .unwrap(); + let expected = DatabaseIdentity { + scope: SCOPE.into(), + feature: FEATURE.into(), + physical, + }; + assert_identity_error(scoped.open_existing(®istration, &expected)); + drop(raw); + drop(scoped); + drop(manager); + { + let catalog = Connection::open(temp.path().join(".owner.sqlite")).unwrap(); + let completed: i64 = catalog + .query_row( + "SELECT completed FROM storage_requests WHERE request=?1", + [REQUEST], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(completed, 0); + catalog + .execute_batch("DROP TRIGGER reject_completion") + .unwrap(); + } + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .unwrap(); + assert_eq!(database.identity(), &expected); + assert_eq!(values(&database), ["preserved"]); + assert_eq!( + scoped + .open_existing(®istration, &expected) + .unwrap() + .identity(), + &expected + ); +} + +#[test] +fn open_existing_never_creates_an_unbound_or_missing_owned_database() { + let temp = tempfile::tempdir().unwrap(); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + let fabricated = DatabaseIdentity { + scope: SCOPE.into(), + feature: FEATURE.into(), + physical: "never-owned".into(), + }; + assert_identity_error(scoped.open_existing(®istration, &fabricated)); + let path = database_path(temp.path(), SCOPE); + assert!(!path.exists()); + let database = scoped.open(®istration).unwrap(); + let expected = database.identity().clone(); + drop(database); + drop(scoped); + drop(manager); + fs::remove_file(&path).unwrap(); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + assert!(scoped.open_existing(®istration, &expected).is_err()); + assert!(scoped.open(®istration).is_err()); + assert!( + !path.exists(), + "missing owned DB must not be silently recreated" + ); +} + +#[test] +fn wrong_expected_workspace_feature_or_physical_identity_never_enters_release_closure() { + let temp = tempfile::tempdir().unwrap(); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.open(®istration).unwrap(); + for field in 0..3 { + let mut wrong = database.identity().clone(); + match field { + 0 => wrong.scope = "another-workspace".into(), + 1 => wrong.feature = "another-feature".into(), + _ => wrong.physical = Uuid::now_v7().to_string(), + } + assert_identity_error(scoped.open_existing(®istration, &wrong)); + assert_identity_error(database.with_identity(&wrong, |_| -> Result<()> { + panic!("release closure must not run for another target") + })); + } +} + +#[test] +fn replaced_path_is_refused_even_when_replacement_copies_the_persisted_identity() { + let temp = tempfile::tempdir().unwrap(); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.open(®istration).unwrap(); + let expected = database.identity().clone(); + insert(&database, "preserved"); + let path = database_path(temp.path(), SCOPE); + let replacement = temp.path().join("replacement.sqlite"); + backup_database(&path, &replacement).unwrap(); + fs::rename(&path, temp.path().join("retired.sqlite")).unwrap(); + fs::rename(&replacement, &path).unwrap(); + assert_identity_error(database.with_identity(&expected, |_| -> Result<()> { + panic!("stale inode must not release a result") + })); + assert_identity_error(scoped.open_existing(®istration, &expected)); + let (other, other_registration) = registered(temp.path()); + assert_identity_error( + other + .scope(SCOPE) + .unwrap() + .open_existing(&other_registration, &expected), + ); +} + +#[test] +fn corrupt_persisted_identity_or_receipt_is_refused_without_repair() { + for corrupt in [ + "UPDATE __yoi_storage_identity SET scope='wrong-workspace'", + "UPDATE __yoi_storage_identity SET feature='wrong-feature'", + "UPDATE __yoi_storage_identity SET physical='wrong-physical'", + "UPDATE __yoi_storage_identity SET request='wrong-request'", + "DELETE FROM __yoi_storage_identity", + ] { + let temp = tempfile::tempdir().unwrap(); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.open(®istration).unwrap(); + let expected = database.identity().clone(); + let raw = Connection::open(database_path(temp.path(), SCOPE)).unwrap(); + raw.execute_batch(corrupt).unwrap(); + assert_identity_error(database.with_connection(|_| -> Result<()> { + panic!("corrupt identity must be checked before a domain query") + })); + assert_identity_error(scoped.open_existing(®istration, &expected)); + drop(database); + drop(scoped); + drop(manager); + let (manager, registration) = registered(temp.path()); + assert_identity_error( + manager + .scope(SCOPE) + .unwrap() + .open_existing(®istration, &expected), + ); + let still_matches: i64 = raw.query_row( + "SELECT COUNT(*) FROM __yoi_storage_identity WHERE scope=?1 AND feature=?2 AND physical=?3", + params![expected.scope, expected.feature, expected.physical], |r| r.get(0), + ).unwrap(); + if !corrupt.contains("SET request=") { + assert_eq!(still_matches, 0, "refusal must not repair the row"); + } else { + let request: String = raw + .query_row("SELECT request FROM __yoi_storage_identity", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(request, "wrong-request"); + } + } +} + +#[test] +fn changed_owner_receipt_invalidates_cached_handle_and_reopen() { + let temp = tempfile::tempdir().unwrap(); + let (manager, registration) = registered(temp.path()); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.open(®istration).unwrap(); + let identity = database.identity().clone(); + let catalog = Connection::open(temp.path().join(".owner.sqlite")).unwrap(); + catalog.execute( + "UPDATE storage_requests SET request='replaced-owner-receipt' WHERE scope=?1 AND feature=?2", + params![SCOPE, FEATURE], + ).unwrap(); + assert_identity_error(database.with_identity(&identity, |_| -> Result<()> { + panic!("cached handle must not release against a replaced owner receipt") + })); + assert_identity_error(scoped.open_existing(®istration, &identity)); + let (fresh, registration) = registered(temp.path()); + assert_identity_error( + fresh + .scope(SCOPE) + .unwrap() + .open_existing(®istration, &identity), + ); +} + +#[test] +fn restore_preserves_data_but_assigns_a_new_physical_identity() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + let snapshot = temp.path().join("snapshot"); + let (manager, registration) = registered(&source); + let source_scope = manager.scope(SCOPE).unwrap(); + let database = source_scope.open(®istration).unwrap(); + insert(&database, "snapshot-data"); + let original = database.identity().clone(); + source_scope.backup(&snapshot).unwrap(); + let (restored, registration) = registered(&destination); + let scoped = restored.scope(SCOPE).unwrap(); + assert!( + scoped.restore(&snapshot).is_err(), + "a fresh root is not source provenance" + ); + scoped.restore_from(&snapshot, &source_scope).unwrap(); + let database = scoped.open(®istration).unwrap(); + let identity = database.identity().clone(); + assert_eq!(identity.scope, original.scope); + assert_eq!(identity.feature, original.feature); + assert_ne!(identity.physical, original.physical); + assert_eq!(values(&database), ["snapshot-data"]); + assert_identity_error(scoped.open_existing(®istration, &original)); + drop(database); + drop(scoped); + drop(restored); + let (restored, registration) = registered(&destination); + let database = restored + .scope(SCOPE) + .unwrap() + .open_existing(®istration, &identity) + .unwrap(); + assert_eq!(values(&database), ["snapshot-data"]); +} + +#[test] +fn same_owner_loss_recovery_retires_old_receipts_and_erasure_blocks_every_restore_target() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let snapshot = temp.path().join("snapshot"); + let (manager, registration) = registered(&root); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.provision(®istration, REQUEST, EVIDENCE).unwrap(); + insert(&database, "saved"); + let old = database.identity().clone(); + scoped.backup(&snapshot).unwrap(); + drop(database); + manager.shutdown().unwrap(); + // Accidental data-file loss is not an authorized erase: the owner is retained. + fs::remove_dir_all(root.join(SCOPE)).unwrap(); + let (manager, registration) = registered(&root); + let scoped = manager.scope(SCOPE).unwrap(); + scoped.restore(&snapshot).unwrap(); + scoped.restore(&snapshot).unwrap(); + let database = scoped.open(®istration).unwrap(); + let current = database.identity().clone(); + assert_ne!(old, current); + assert_eq!(values(&database), ["saved"]); + assert_identity_error(scoped.open_existing(®istration, &old)); + assert_identity_error(scoped.provision(®istration, REQUEST, EVIDENCE)); + let owner = Connection::open(root.join(".owner.sqlite")).unwrap(); + let retained: (String, bool, bool) = owner + .query_row( + "SELECT physical,completed,current FROM storage_requests WHERE request=?1", + [REQUEST], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(retained, (old.physical, true, false)); + drop(database); + scoped.delete().unwrap(); + let (reopened, _) = registered(&root); + let source = reopened.scope(SCOPE).unwrap(); + assert!(matches!( + source.restore(&snapshot), + Err(FeatureStorageError::ScopeMaintenance(_)) + )); + let fresh_root = temp.path().join("fresh-target"); + let (target, _) = registered(&fresh_root); + assert!(matches!( + target + .scope(SCOPE) + .unwrap() + .restore_from(&snapshot, &source), + Err(FeatureStorageError::ScopeMaintenance(_)) + )); + assert!(!fresh_root.join(SCOPE).exists()); +} + +struct ChildGuard(Child); + +impl ChildGuard { + fn start(root: &Path, signals: &Path) -> Self { + Self::start_action(root, signals, None) + } + + fn start_action(root: &Path, signals: &Path, snapshot: Option<&Path>) -> Self { + Self( + Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "identity_tests::lifecycle_delete_child", + "--nocapture", + "--test-threads=1", + ]) + .current_dir(signals) + .env("YOI_IDENTITY_DELETE_CHILD_ROOT", root) + .env("YOI_IDENTITY_DELETE_CHILD_SIGNALS", signals) + .env( + "YOI_IDENTITY_RESTORE_CHILD_SNAPSHOT", + snapshot.unwrap_or_else(|| Path::new("")), + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ) + } + + fn wait_for(&mut self, signal: &Path) { + let deadline = Instant::now() + WAIT; + loop { + if signal.is_file() { + return; + } + assert!( + self.0.try_wait().unwrap().is_none(), + "child exited before {}", + signal.display() + ); + assert!( + Instant::now() < deadline, + "timed out waiting for {}", + signal.display() + ); + std::thread::yield_now(); + } + } + + fn finish(&mut self) { + let deadline = Instant::now() + WAIT; + loop { + if let Some(status) = self.0.try_wait().unwrap() { + assert!(status.success(), "delete child failed: {status}"); + return; + } + assert!( + Instant::now() < deadline, + "delete child did not finish after lease release" + ); + std::thread::yield_now(); + } + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[test] +fn lifecycle_delete_child() { + let Some(root) = std::env::var_os("YOI_IDENTITY_DELETE_CHILD_ROOT") else { + return; + }; + let signals = PathBuf::from(std::env::var_os("YOI_IDENTITY_DELETE_CHILD_SIGNALS").unwrap()); + let manager = FeatureStorage::new(PathBuf::from(root)); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open( + manager + .inner + .root + .join(".lifecycle") + .join(format!("{SCOPE}.lock")), + ) + .unwrap(); + assert!( + matches!(lock.try_lock(), Err(fs::TryLockError::WouldBlock)), + "exclusive lifecycle lock must contend with active operation" + ); + fs::write(signals.join("contended"), b"shared lease observed").unwrap(); + if let Some(snapshot) = + std::env::var_os("YOI_IDENTITY_RESTORE_CHILD_SNAPSHOT").filter(|s| !s.is_empty()) + { + assert!(matches!( + manager + .scope(SCOPE) + .unwrap() + .restore(PathBuf::from(snapshot)), + Err(FeatureStorageError::DestinationExists(_)) + )); + fs::write( + signals.join("restore-rejected"), + b"existing physical lifecycle retained", + ) + .unwrap(); + } else { + manager.scope(SCOPE).unwrap().delete().unwrap(); + fs::write(signals.join("deleted"), b"deletion completed").unwrap(); + } +} + +#[test] +fn cross_process_restore_waits_for_short_guard_and_cannot_replace_the_live_lifecycle() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let snapshot = temp.path().join("snapshot"); + let signals = temp.path().join("signals"); + fs::create_dir(&signals).unwrap(); + let (manager, registration) = registered(&root); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.open(®istration).unwrap(); + insert(&database, "still-current"); + scoped.backup(&snapshot).unwrap(); + let identity = database.identity().clone(); + let guard = database.guard(&identity).unwrap(); + let mut child = ChildGuard::start_action(&root, &signals, Some(&snapshot)); + child.wait_for(&signals.join("contended")); + assert!(!signals.join("restore-rejected").exists()); + assert_eq!(guard.identity(), &identity); + drop(guard); + child.finish(); + assert!(signals.join("restore-rejected").is_file()); + assert_eq!(database.identity(), &identity); + assert_eq!(values(&database), ["still-current"]); +} + +#[test] +fn cross_process_delete_waits_for_transaction_and_release_then_persistently_fences_handles() { + for operation in ["commit", "rollback", "release"] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let signals = temp.path().join("signals"); + fs::create_dir(&signals).unwrap(); + let (manager, registration) = registered(&root); + let scoped = manager.scope(SCOPE).unwrap(); + let database = scoped.open(®istration).unwrap(); + let identity = database.identity().clone(); + insert(&database, "before-delete"); + let mut child = None; + let mut contend = || { + let mut process = ChildGuard::start(&root, &signals); + process.wait_for(&signals.join("contended")); + assert!(!signals.join("deleted").exists()); + assert!(database_path(&root, SCOPE).is_file()); + assert!( + !manager.lifecycle(SCOPE).fenced(SCOPE).unwrap(), + "delete may not fence an operation before lease release" + ); + child = Some(process); + }; + if operation == "release" { + database + .with_identity(&identity, |connection| { + contend(); + let value: String = + connection.query_row("SELECT value FROM records", [], |r| r.get(0))?; + assert_eq!(value, "before-delete"); + Ok(()) + }) + .unwrap(); + } else { + let result = database.transaction(|tx| { + tx.execute("INSERT INTO records VALUES ('transaction-write')", [])?; + contend(); + if operation == "rollback" { + Err(FeatureStorageError::operation("rollback held transaction")) + } else { + Ok(()) + } + }); + if operation == "rollback" { + assert!(matches!(result, Err(FeatureStorageError::Operation(_)))); + } else { + result.unwrap(); + } + } + let mut child = child.unwrap(); + child.finish(); + assert!(signals.join("deleted").is_file()); + assert!(!root.join(SCOPE).exists()); + let entered = Cell::new(false); + assert!( + database + .with_identity(&identity, |_| { + entered.set(true); + Ok(()) + }) + .is_err() + ); + assert!( + !entered.get(), + "stale handles must not enter release closure after delete" + ); + assert!( + database + .transaction(|_| -> Result<()> { panic!("stale transaction entered") }) + .is_err() + ); + assert!(scoped.open_existing(®istration, &identity).is_err()); + let (fresh, registration) = registered(&root); + assert!(fresh.scope(SCOPE).unwrap().open(®istration).is_err()); + fresh.scope(SCOPE).unwrap().delete().unwrap(); + assert!( + !root.join(SCOPE).exists(), + "fresh manager must observe persistent deletion fence" + ); + } +} + +#[test] +fn published_physical_identity_receipt_and_erasure_fences_survive_authorized_adoption() { + let temp = tempfile::tempdir().unwrap(); + let path = legacy(temp.path(), SCOPE); + let physical = Uuid::now_v7().to_string(); + let fingerprint = format!("sha256:{}", "a".repeat(64)); + let connection = Connection::open(&path).unwrap(); + // Exact published physical DDL from both 9edebfa and 7b39a44 identity.rs. + connection.execute_batch("CREATE TABLE __yoi_feature_database_identity ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), format INTEGER NOT NULL CHECK(format=1), + scope_id TEXT NOT NULL,feature_id TEXT NOT NULL,incarnation TEXT NOT NULL); + CREATE TABLE __yoi_feature_provisioning_receipt (singleton INTEGER PRIMARY KEY CHECK(singleton=1),request_fingerprint TEXT NOT NULL);").unwrap(); + connection + .execute( + "INSERT INTO __yoi_feature_database_identity VALUES(1,1,?1,?2,?3)", + params![SCOPE, FEATURE, physical], + ) + .unwrap(); + connection + .execute( + "INSERT INTO __yoi_feature_provisioning_receipt VALUES(1,?1)", + [&fingerprint], + ) + .unwrap(); + let (manager, registration) = registered(temp.path()); + let scope = manager.scope(SCOPE).unwrap(); + let database = scope + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .unwrap(); + assert_eq!(database.identity().physical, physical); + assert_eq!(values(&database), ["preserved"]); + let saved: (String,String) = connection.query_row( + "SELECT incarnation,request_fingerprint FROM __yoi_feature_database_identity CROSS JOIN __yoi_feature_provisioning_receipt",[],|r|Ok((r.get(0)?,r.get(1)?))).unwrap(); + assert_eq!(saved, (physical, fingerprint)); + scope.delete().unwrap(); + let (other, registration) = registered(temp.path()); + assert!(other.scope(SCOPE).unwrap().open(®istration).is_err()); + assert!( + temp.path() + .join(".lifecycle") + .join(format!("{SCOPE}.deleted")) + .exists() + ); +} + +#[test] +fn published_scope_erasure_or_unfinished_lifecycle_is_not_lost_on_owner_upgrade() { + for phase in ["deleted", "deleting", "creating", "restoring"] { + let temp = tempfile::tempdir().unwrap(); + let path = legacy(temp.path(), SCOPE); + fs::create_dir(temp.path().join(".scope-locks")).unwrap(); + fs::write( + temp.path() + .join(".scope-locks") + .join(format!("{SCOPE}.lock")), + format!("v1 {phase} {}\n", Uuid::now_v7()), + ) + .unwrap(); + let (manager, registration) = registered(temp.path()); + assert!( + manager + .scope(SCOPE) + .unwrap() + .adopt_existing(®istration, REQUEST, EVIDENCE, validate_legacy) + .is_err() + ); + assert!(!has_identity(&Connection::open(path).unwrap())); + assert!(!temp.path().join(".owner.sqlite").exists()); + } +} + +#[test] +fn explicit_guard_pins_short_decision_without_holding_connection_or_sqlite_transaction() { + let temp = tempfile::tempdir().unwrap(); + let (manager, registration) = registered(temp.path()); + let scope = manager.scope(SCOPE).unwrap(); + let database = scope.open(®istration).unwrap(); + let guard = database.guard(database.identity()).unwrap(); + assert_eq!(guard.identity(), database.identity()); + // Unlike a connection closure, the short guard permits consumer queries and + // their own transaction while holding the same physical lifecycle lease. + let reopened = scope + .open_existing(®istration, guard.identity()) + .unwrap(); + insert(&reopened, "guarded"); + assert_eq!(values(&database), ["guarded"]); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open(temp.path().join(".lifecycle").join(format!("{SCOPE}.lock"))) + .unwrap(); + assert!(matches!(lock.try_lock(), Err(fs::TryLockError::WouldBlock))); + drop(guard); + lock.try_lock().unwrap(); +} diff --git a/crates/feature-storage/src/legacy_owner.rs b/crates/feature-storage/src/legacy_owner.rs new file mode 100644 index 000000000..c1b2be8b0 --- /dev/null +++ b/crates/feature-storage/src/legacy_owner.rs @@ -0,0 +1,136 @@ +//! Retained physical metadata from published FeatureStorage writers. These are +//! provenance, not authority: adoption still needs a Host grant, attribution and +//! receipt/shape validation. Never rewrite their identity or provisioning receipt. +use super::*; + +pub(super) fn identity(path: &Path, scope: &str, feature: &str) -> Result> { + let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let exists = |name: &str| -> Result { + Ok(connection.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)", + [name], + |r| r.get(0), + )?) + }; + if !exists("__yoi_feature_database_identity")? { + if exists("__yoi_feature_provisioning_receipt")? { + return Err(FeatureStorageError::Identity( + "legacy provisioning receipt has no physical identity".into(), + )); + } + return Ok(None); + } + verify_shape( + &connection, + "__yoi_feature_database_identity", + "CREATE TABLE __yoi_feature_database_identity (singleton INTEGER PRIMARY KEY CHECK(singleton=1), format INTEGER NOT NULL CHECK(format=1), scope_id TEXT NOT NULL,feature_id TEXT NOT NULL,incarnation TEXT NOT NULL)", + )?; + let count: i64 = connection.query_row( + "SELECT COUNT(*) FROM __yoi_feature_database_identity", + [], + |r| r.get(0), + )?; + let (format,saved_scope,saved_feature,physical): (i64,String,String,String) = connection.query_row( + "SELECT format,scope_id,feature_id,incarnation FROM __yoi_feature_database_identity WHERE singleton=1",[], + |r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)))?; + let id = Uuid::parse_str(&physical) + .map_err(|_| FeatureStorageError::Identity("corrupt legacy physical identity".into()))?; + if count != 1 + || format != 1 + || scope != saved_scope + || feature != saved_feature + || id.get_version_num() != 7 + || id.to_string() != physical + { + return Err(FeatureStorageError::Identity( + "legacy physical attribution differs or is corrupt".into(), + )); + } + if exists("__yoi_feature_provisioning_receipt")? { + verify_shape( + &connection, + "__yoi_feature_provisioning_receipt", + "CREATE TABLE __yoi_feature_provisioning_receipt (singleton INTEGER PRIMARY KEY CHECK(singleton=1),request_fingerprint TEXT NOT NULL)", + )?; + let (count,fingerprint): (i64,String) = connection.query_row( + "SELECT (SELECT COUNT(*) FROM __yoi_feature_provisioning_receipt),request_fingerprint FROM __yoi_feature_provisioning_receipt WHERE singleton=1",[],|r|Ok((r.get(0)?,r.get(1)?)))?; + if count != 1 + || fingerprint.len() != 71 + || !fingerprint.starts_with("sha256:") + || !fingerprint.as_bytes()[7..] + .iter() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b)) + { + return Err(FeatureStorageError::Identity( + "corrupt legacy provisioning receipt".into(), + )); + } + } + Ok(Some(physical)) +} + +pub(super) fn fenced(root: &Path, scope: &str) -> Result { + if root + .join(".lifecycle") + .join(format!("{scope}.deleted")) + .try_exists()? + { + return Ok(true); + } + let record_path = root.join(".scope-locks").join(format!("{scope}.lock")); + let record = match fs::read_to_string(record_path) { + Ok(record) if !record.is_empty() => record, + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + let fields: Vec<_> = record.trim_end_matches('\n').split(' ').collect(); + if fields.len() != 3 || fields[0] != "v1" { + return Err(FeatureStorageError::Identity( + "corrupt legacy scope record".into(), + )); + } + let id = Uuid::parse_str(fields[2]) + .map_err(|_| FeatureStorageError::Identity("corrupt legacy scope identity".into()))?; + if record != format!("v1 {} {id}\n", fields[1]) { + return Err(FeatureStorageError::Identity( + "noncanonical legacy scope record".into(), + )); + } + match fields[1] { + "deleted" | "deleting" => Ok(true), + "live" => { + if fs::read_to_string(root.join(scope).join(".incarnation"))? != format!("{id}\n") { + return Err(FeatureStorageError::Identity( + "legacy scope incarnation differs".into(), + )); + } + Ok(false) + } + _ => Err(FeatureStorageError::Identity( + "unfinished legacy scope requires reconciliation by its owner".into(), + )), + } +} + +fn verify_shape(connection: &Connection, table: &str, expected: &str) -> Result<()> { + let sql: String = connection.query_row( + "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1", + [table], + |r| r.get(0), + )?; + // These two frozen DDL definitions contain no SQL string literals. Whitespace + // and token case are immaterial; constraints and column programs are not. + let normalized = |s: &str| { + s.chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase() + }; + if normalized(&sql) != normalized(expected) { + return Err(FeatureStorageError::Identity( + "legacy physical metadata shape differs".into(), + )); + } + Ok(()) +} diff --git a/crates/feature-storage/src/lib.rs b/crates/feature-storage/src/lib.rs index 349c9d2c7..6706b716d 100644 --- a/crates/feature-storage/src/lib.rs +++ b/crates/feature-storage/src/lib.rs @@ -11,6 +11,10 @@ use rusqlite::backup::Backup; use rusqlite::{Connection, ErrorCode, OpenFlags, Transaction, TransactionBehavior, params}; use uuid::Uuid; +mod legacy_owner; +mod owner; +pub use owner::DatabaseIdentity; + const BUSY_TIMEOUT: Duration = Duration::from_secs(5); const MIGRATION_TABLE: &str = "__yoi_feature_schema_migrations"; const SNAPSHOT_FORMAT: &str = "yoi-feature-storage-v1"; @@ -18,10 +22,21 @@ const SNAPSHOT_MANIFEST: &str = "manifest.txt"; const MAX_IDENTIFIER_BYTES: usize = 128; pub type Result = std::result::Result; +pub type FeatureHistoryValidation = + for<'connection> fn(&Transaction<'connection>, &[(u32, String)]) -> Result<()>; pub type FeatureMigrationApply = for<'connection> fn(&Transaction<'connection>) -> Result<()>; #[derive(Debug, thiserror::Error)] pub enum FeatureStorageError { + #[error("Feature storage identity conflict: {0}")] + Identity(String), + #[error( + "Feature storage outcome unknown for {identity:?}: {detail}; reconcile the same request, do not recreate it" + )] + OutcomeUnknown { + identity: DatabaseIdentity, + detail: String, + }, #[error("invalid {kind} storage identifier `{value}`")] InvalidIdentifier { kind: &'static str, value: String }, #[error("Feature `{0}` is not registered with this Host storage manager")] @@ -93,6 +108,11 @@ impl FeatureMigration { } } + /// Executes this registered migration, also useful for immutable schema references. + pub fn apply(&self, transaction: &Transaction<'_>) -> Result<()> { + (self.apply)(transaction) + } + pub fn version(&self) -> u32 { self.version } @@ -106,6 +126,7 @@ impl FeatureMigration { pub struct FeatureRegistration { feature_id: &'static str, migrations: &'static [FeatureMigration], + history_validation: Option, } impl FeatureRegistration { @@ -113,9 +134,18 @@ impl FeatureRegistration { Self { feature_id, migrations, + history_validation: None, } } + /// Installs this Feature's finite published receipt/shape compatibility check. + /// The check must reject unknown histories; it grants neither adoption nor + /// domain authority. Known extension receipts are retained, never rewritten. + pub const fn with_history_validation(mut self, validate: FeatureHistoryValidation) -> Self { + self.history_validation = Some(validate); + self + } + pub fn feature_id(&self) -> &'static str { self.feature_id } @@ -152,7 +182,8 @@ impl RegisteredFeature { /// /// Features own schemas and domain transactions; the Host owns authentication before calls. /// This separation is an in-process API boundary, not a sandbox for arbitrary native code. -/// The constructing Host owns state-root protection, path validation, and execution leases. +/// The owner checks physical identity and holds advisory process-wide lifecycle leases. +/// The constructing Host still protects the root against native code bypassing this owner. /// Registration alone creates no database and does not delete existing data for disabled Features. #[derive(Clone)] pub struct FeatureStorage { @@ -167,12 +198,44 @@ struct FeatureStorageInner { struct FeatureStorageState { running: bool, - registrations: HashMap, + registrations: HashMap, connections: HashMap<(String, String), Weak>, maintenance: HashSet, deletion_fences: HashSet, } +#[derive(Clone, Copy)] +enum Admission<'a> { + Provision { + request: &'a str, + evidence: &'a str, + }, + Adopt { + request: &'a str, + evidence: &'a str, + validate: &'a dyn Fn(&Transaction<'_>) -> Result<()>, + }, +} + +impl Admission<'_> { + fn request(&self) -> &str { + match self { + Self::Provision { request, .. } | Self::Adopt { request, .. } => request, + } + } + fn evidence(&self) -> &str { + match self { + Self::Provision { evidence, .. } | Self::Adopt { evidence, .. } => evidence, + } + } + fn kind(&self) -> &'static str { + match self { + Self::Provision { .. } => "provision", + Self::Adopt { .. } => "adopt", + } + } +} + impl FeatureStorage { /// Lazy manager. Creates no directories or databases until a registered /// Feature is opened, restored, or backed up. @@ -203,6 +266,105 @@ impl FeatureStorage { Ok(Self::new(parent.join("feature-storage"))) } + /// Admits the Host control-plane database under the same physical identity + /// and lifecycle contract as Feature DBs. The Host's explicit configuration + /// selects this path; the domain prepare function must validate existing + /// receipts/shapes before modifying a nonempty database. It may own several + /// schema transactions; admission is complete only after physical identity + /// commits and the owner receipt is reconciled. + pub fn open_host_catalog( + &self, + path: &Path, + request: &str, + authority_evidence: &str, + prepare: impl FnOnce(&mut Connection) -> std::result::Result<(), E>, + ) -> std::result::Result + where + E: From, + { + let scope = "_host_catalog"; + let feature = "catalog"; + let operation = || -> Result<_> { + check_admission(request, authority_evidence)?; + // Outside the public scope identifier grammar: a Workspace cannot + // collide with, delete or restore the Host catalog lifecycle. + let lifecycle = self.lifecycle(scope); + let lease = lifecycle.lock(true)?; + let known = lifecycle.lookup(scope, feature)?; + let established = known.is_some(); + let binding = if let Some(binding) = known { + binding.check_file(path)?; + lifecycle.reserve( + scope, + feature, + path, + request, + lifecycle.request_kind(request)?.as_deref().ok_or_else(|| { + FeatureStorageError::Identity("catalog request differs".into()) + })?, + authority_evidence, + )? + } else { + lifecycle.reserve( + scope, + feature, + path, + request, + lifecycle + .request_kind(request)? + .as_deref() + .unwrap_or(if path.exists() { "adopt" } else { "provision" }), + authority_evidence, + )? + }; + let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + configure_connection(&connection)?; + Ok((lifecycle, lease, binding, connection, established)) + }; + let (lifecycle, _lease, binding, mut connection, established) = + operation().map_err(E::from)?; + // Never silently repair a corrupt stored identity on an established DB. + let identity_exists: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='__yoi_storage_identity')", + [], + |r| r.get(0), + ) + .map_err(FeatureStorageError::from) + .map_err(E::from)?; + if established && !identity_exists { + return Err(E::from(FeatureStorageError::Identity( + "stored DB identity is missing".into(), + ))); + } + if identity_exists { + binding.check_database(&connection).map_err(E::from)?; + } + prepare(&mut connection)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(FeatureStorageError::from) + .map_err(E::from)?; + binding.persist(&tx).map_err(E::from)?; + tx.commit() + .map_err(FeatureStorageError::from) + .map_err(E::from)?; + lifecycle + .complete(&binding) + .map_err(|error| E::from(binding.unknown(error)))?; + Ok(FeatureDatabase { + inner: Arc::new(FeatureDatabaseInner { + scope_id: scope.into(), + feature_id: feature.into(), + accepting_operations: AtomicBool::new(true), + connection: Mutex::new(Some(connection)), + root: self.inner.root.clone(), + path: path.into(), + binding, + }), + }) + } + /// Restricts storage to a Host-issued scope. /// A scope is a physical sharing boundary; it creates no Workspace record or model authorization credentials. /// The same API supports Backend Workspace IDs and persistent local Host scope IDs. @@ -228,9 +390,7 @@ impl FeatureStorage { if state.registrations.contains_key(&feature_id) { return Err(FeatureStorageError::FeatureAlreadyRegistered(feature_id)); } - state - .registrations - .insert(feature_id.clone(), registration.migrations); + state.registrations.insert(feature_id.clone(), registration); Ok(RegisteredFeature { manager_id: self.inner.id, feature_id, @@ -265,6 +425,20 @@ impl FeatureStorage { Ok(()) } + fn check_registration(&self, registration: &RegisteredFeature) -> Result<()> { + if registration.manager_id != self.inner.id { + return Err(FeatureStorageError::ForeignRegistration); + } + let state = self.lock_state()?; + ensure_running(&state)?; + if !state.registrations.contains_key(®istration.feature_id) { + return Err(FeatureStorageError::FeatureNotRegistered( + registration.feature_id.clone(), + )); + } + Ok(()) + } + fn open(&self, scope_id: &str, registration: &RegisteredFeature) -> Result { self.open_with_directory_sync(scope_id, registration, sync_directory) } @@ -274,6 +448,39 @@ impl FeatureStorage { scope_id: &str, registration: &RegisteredFeature, mut sync: impl FnMut(&Path) -> std::io::Result<()>, + ) -> Result { + self.check_registration(registration)?; + // A Host request for initial provisioning is not past ownership evidence. + // Existing unbound files require explicit adoption. Missing bound files + // never become a request to create a replacement database. + let lifecycle = self.lifecycle(scope_id); + let lease = lifecycle.lock(false)?; + let mut binding = lifecycle.lookup(scope_id, ®istration.feature_id)?; + let _lease = if binding.is_none() { + drop(lease); + let exclusive = lifecycle.lock(true)?; + binding = lifecycle.lookup(scope_id, ®istration.feature_id)?; + exclusive + } else { + lease + }; + self.open_locked(scope_id, registration, binding, None, &mut sync) + } + + fn lifecycle(&self, scope_id: &str) -> owner::Lifecycle { + owner::Lifecycle { + root: self.inner.root.clone(), + scope: scope_id.into(), + } + } + + fn open_locked( + &self, + scope_id: &str, + registration: &RegisteredFeature, + binding: Option, + admission: Option>, + sync: &mut impl FnMut(&Path) -> std::io::Result<()>, ) -> Result { if registration.manager_id != self.inner.id { return Err(FeatureStorageError::ForeignRegistration); @@ -291,35 +498,74 @@ impl FeatureStorage { .ok_or_else(|| { FeatureStorageError::FeatureNotRegistered(registration.feature_id.clone()) })?; - if let Some(connection) = state.connections.get(&key).and_then(Weak::upgrade) { - return Ok(FeatureDatabase { inner: connection }); + if admission.is_none() { + if let Some(connection) = state.connections.get(&key).and_then(Weak::upgrade) { + let handle = FeatureDatabase { inner: connection }; + handle.inner.verify()?; + return Ok(handle); + } } - - let feature_dir = self.feature_dir(scope_id); - #[cfg(feature = "test-support")] - let mut counted_sync = |path: &Path| { - registration.observation.sync_attempt(); - let result = sync(path); - if result.is_ok() { - registration.observation.sync_success(); + let path = self + .feature_dir(scope_id) + .join(format!("{}.sqlite", registration.feature_id)); + let lifecycle = self.lifecycle(scope_id); + let newly_bound = binding.is_none(); + let binding = if let Some(binding) = binding { + binding.check_file(&path)?; + binding + } else { + #[cfg(feature = "test-support")] + let mut counted_sync = |path: &Path| { + registration.observation.sync_attempt(); + let result = sync(path); + if result.is_ok() { + registration.observation.sync_success(); + } + result + }; + #[cfg(feature = "test-support")] + durable_create_directory(&self.feature_dir(scope_id), &mut counted_sync)?; + #[cfg(not(feature = "test-support"))] + durable_create_directory(&self.feature_dir(scope_id), sync)?; + if let Some(admission) = admission { + lifecycle.reserve( + scope_id, + ®istration.feature_id, + &path, + admission.request(), + admission.kind(), + admission.evidence(), + )? + } else { + lifecycle.reserve( + scope_id, + ®istration.feature_id, + &path, + &format!("initial:{scope_id}:{}", registration.feature_id), + "provision", + "Host initial provisioning", + )? } - result }; #[cfg(feature = "test-support")] - durable_create_directory(&feature_dir, &mut counted_sync)?; - #[cfg(not(feature = "test-support"))] - durable_create_directory(&feature_dir, &mut sync)?; - let path = feature_dir.join(format!("{}.sqlite", registration.feature_id)); - #[cfg(feature = "test-support")] registration.observation.open_attempt(); - let connection = Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, - )?; + let mut connection = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; #[cfg(feature = "test-support")] registration.observation.open_success(); configure_connection(&connection)?; - migrate(&connection, ®istration.feature_id, migrations)?; + let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(Admission::Adopt { validate, .. }) = admission { + validate(&tx)?; + } + if !newly_bound { + binding.check_database(&tx)?; + } + migrate(&tx, ®istration.feature_id, migrations)?; + binding.persist(&tx)?; + tx.commit()?; + lifecycle + .complete(&binding) + .map_err(|error| binding.unknown(error))?; #[cfg(feature = "test-support")] registration.observation.initialized(); let connection = Arc::new(FeatureDatabaseInner { @@ -327,12 +573,20 @@ impl FeatureStorage { feature_id: registration.feature_id.clone(), accepting_operations: AtomicBool::new(true), connection: Mutex::new(Some(connection)), + root: self.inner.root.clone(), + path, + binding, }); state.connections.insert(key, Arc::downgrade(&connection)); Ok(FeatureDatabase { inner: connection }) } fn backup_scope(&self, scope_id: &str, destination: &Path) -> Result<()> { + let lifecycle = self.lifecycle(scope_id); + let _lease = lifecycle.lock(false)?; + if lifecycle.fenced(scope_id)? { + return Err(FeatureStorageError::ScopeMaintenance(scope_id.into())); + } // Keep manager lifecycle and deletion/restore mutually exclusive for the // duration. Feature transactions use SQLite's online backup snapshot rules. let state = self.lock_state()?; @@ -379,42 +633,120 @@ impl FeatureStorage { result } - fn restore_scope(&self, scope_id: &str, snapshot: &Path) -> Result<()> { + fn restore_scope( + &self, + scope_id: &str, + snapshot: &Path, + source: &ScopedFeatureStorage, + ) -> Result<()> { + if source.scope_id != scope_id { + return Err(FeatureStorageError::Identity( + "restore source scope differs".into(), + )); + } + // Canonical roots define a deterministic order for a cross-owner copy. + // Same-owner recovery needs one exclusive lease, not a shared-to-exclusive upgrade. + let source_root = fs::canonicalize(&source.manager.inner.root)?; + durable_create_directory(&self.inner.root, &mut sync_directory)?; + let target_root = fs::canonicalize(&self.inner.root)?; + let source_lifecycle = source.manager.lifecycle(scope_id); + let lifecycle = self.lifecycle(scope_id); + let (_source_lease, _target_lease) = if source_root == target_root { + (None, lifecycle.lock(true)?) + } else if source_root < target_root { + (Some(source_lifecycle.lock(false)?), lifecycle.lock(true)?) + } else { + let target = lifecycle.lock(true)?; + (Some(source_lifecycle.lock(false)?), target) + }; + if lifecycle.fenced(scope_id)? { + return Err(FeatureStorageError::ScopeMaintenance(scope_id.into())); + } let feature_ids = read_snapshot_manifest(snapshot, scope_id)?; + // Hold the source erasure fence through target publication. A fresh root + // or an old snapshot cannot erase this source-owner dependency. + if source_lifecycle.fenced(scope_id)? { + return Err(FeatureStorageError::ScopeMaintenance(scope_id.into())); + } + for feature in &feature_ids { + source_lifecycle.verify_snapshot( + scope_id, + feature, + &snapshot.join("features").join(format!("{feature}.sqlite")), + )?; + } self.begin_maintenance(scope_id, true)?; let scope_dir = self.scope_dir(scope_id); - let temporary = self - .inner - .root - .join(format!(".restore-{scope_id}-{}", Uuid::now_v7())); + let temporary = self.inner.root.join(format!(".restore-{scope_id}")); + let evidence = fs::canonicalize(snapshot)?.display().to_string(); let result = (|| { if scope_dir.exists() { - return Err(FeatureStorageError::DestinationExists( - scope_dir.display().to_string(), - )); + // Rename/receipt response loss: only this exact saved restore + // may reconcile the published inode. An unrelated existing + // destination never becomes adoption authority. + for feature_id in &feature_ids { + let request = format!("restore:{scope_id}:{feature_id}:{evidence}"); + if lifecycle.request_kind(&request)?.as_deref() != Some("restore") { + return Err(FeatureStorageError::DestinationExists( + scope_dir.display().to_string(), + )); + } + let path = scope_dir + .join("features") + .join(format!("{feature_id}.sqlite")); + let binding = lifecycle + .reserve(scope_id, feature_id, &path, &request, "restore", &evidence)?; + let connection = + Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + binding.check_database(&connection)?; + lifecycle.complete(&binding)?; + } + return Ok(()); } let temporary_features = temporary.join("features"); fs::create_dir_all(&temporary_features)?; + let mut bindings = Vec::new(); for feature_id in &feature_ids { - backup_database( - &snapshot - .join("features") - .join(format!("{feature_id}.sqlite")), - &temporary_features.join(format!("{feature_id}.sqlite")), + let target = temporary_features.join(format!("{feature_id}.sqlite")); + if !target.exists() { + backup_database( + &snapshot + .join("features") + .join(format!("{feature_id}.sqlite")), + &target, + )?; + } + let request = format!("restore:{scope_id}:{feature_id}:{evidence}"); + let binding = lifecycle.reserve( + scope_id, feature_id, &target, &request, "restore", &evidence, )?; + let mut connection = + Connection::open_with_flags(&target, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + configure_connection(&connection)?; + binding.restore_identity(&mut connection)?; + bindings.push(binding); } - fs::create_dir_all(&self.inner.root)?; + sync_directory(&temporary_features)?; + sync_directory(&temporary)?; fs::rename(&temporary, &scope_dir)?; + sync_directory(&self.inner.root)?; + for binding in &bindings { + lifecycle + .complete(binding) + .map_err(|error| binding.unknown(error))?; + } Ok(()) })(); - if result.is_err() { - let _ = fs::remove_dir_all(&temporary); - } + // Failed/pending restore is retained for exact reconciliation, not + // deleted and recreated as if its physical side effects never happened. self.end_maintenance(scope_id)?; result } fn delete_scope(&self, scope_id: &str) -> Result<()> { + let lifecycle = self.lifecycle(scope_id); + let _lease = lifecycle.lock(true)?; + lifecycle.delete(scope_id)?; let connections = { let mut state = self.lock_state()?; ensure_running(&state)?; @@ -545,6 +877,136 @@ impl ScopedFeatureStorage { self.manager.open(&self.scope_id, registration) } + /// Opens only an already-owned DB. Missing files and unfinished adoption + /// never become requests to create a replacement. + pub fn open_existing( + &self, + registration: &RegisteredFeature, + expected: &DatabaseIdentity, + ) -> Result { + self.manager.check_registration(registration)?; + if !self.manager.inner.root.join(".owner.sqlite").is_file() { + return Err(FeatureStorageError::Identity( + "expected ownership record is missing".into(), + )); + } + if expected.scope != self.scope_id || expected.feature != registration.feature_id { + return Err(FeatureStorageError::Identity( + "expected Workspace/Feature differs".into(), + )); + } + let lifecycle = self.manager.lifecycle(&self.scope_id); + let _lease = lifecycle.lock(false)?; + let binding = lifecycle + .lookup(&self.scope_id, ®istration.feature_id)? + .ok_or_else(|| FeatureStorageError::Identity("no completed ownership record".into()))?; + if binding.identity != *expected { + return Err(FeatureStorageError::Identity( + "expected physical DB differs".into(), + )); + } + self.manager.open_locked( + &self.scope_id, + registration, + Some(binding), + None, + &mut sync_directory, + ) + } + + /// Host-authorized adoption, not inferred from existence. Validate persisted + /// domain attribution and supported shape in the same identity transaction. + /// Exact retries reconcile the reserved inode/UUID after response loss. + pub fn adopt_existing( + &self, + registration: &RegisteredFeature, + request: &str, + authority_evidence: &str, + validate: impl Fn(&Transaction<'_>) -> Result<()>, + ) -> Result { + self.manager.check_registration(registration)?; + check_admission(request, authority_evidence)?; + let lifecycle = self.manager.lifecycle(&self.scope_id); + let _lease = lifecycle.lock(true)?; + self.manager.open_locked( + &self.scope_id, + registration, + None, + Some(Admission::Adopt { + request, + evidence: authority_evidence, + validate: &validate, + }), + &mut sync_directory, + ) + } + + /// Explicit new provisioning. An existing unbound file is never adopted. + /// The Host authenticates this operation before supplying its stable request. + pub fn provision( + &self, + registration: &RegisteredFeature, + request: &str, + authority_evidence: &str, + ) -> Result { + self.manager.check_registration(registration)?; + check_admission(request, authority_evidence)?; + let lifecycle = self.manager.lifecycle(&self.scope_id); + let _lease = lifecycle.lock(true)?; + self.manager.open_locked( + &self.scope_id, + registration, + None, + Some(Admission::Provision { + request, + evidence: authority_evidence, + }), + &mut sync_directory, + ) + } + + /// Host startup compatibility for a registered optional Feature. Does not + /// provision missing optional storage. An existing legacy DB still requires + /// explicit adoption authority and persisted attribution validation. + pub fn adopt_legacy_if_present( + &self, + registration: &RegisteredFeature, + request: &str, + authority_evidence: &str, + validate_existing: impl Fn(&Transaction<'_>) -> Result<()>, + ) -> Result<()> { + self.manager.check_registration(registration)?; + check_admission(request, authority_evidence)?; + let path = self + .manager + .feature_dir(&self.scope_id) + .join(format!("{}.sqlite", registration.feature_id)); + if !self.manager.inner.root.exists() { + return Ok(()); + } + let lifecycle = self.manager.lifecycle(&self.scope_id); + let _lease = lifecycle.lock(true)?; + if let Some(binding) = lifecycle.lookup(&self.scope_id, ®istration.feature_id)? { + binding.check_file(&path)?; + return Ok(()); + } + if !path.exists() { + return Ok(()); + } + self.manager.open_locked( + &self.scope_id, + registration, + None, + Some(Admission::Adopt { + request, + evidence: authority_evidence, + validate: &validate_existing, + }), + &mut sync_directory, + )?; + Ok(()) + } + /// Backs up each Feature DB, including disabled/unregistered ones, using SQLite online backup. /// Avoids file copies that omit WAL; checks integrity and foreign keys before publishing by rename. /// Each DB is internally consistent, but no common-point snapshot across DBs or Host authority is guaranteed. @@ -556,14 +1018,27 @@ impl ScopedFeatureStorage { /// Restores a snapshot only into an uncreated scope with no connections. /// Rejects scope mismatches and corrupt DBs; publishes the validated temporary directory by rename. + /// Restores using this same owner's retained source receipts and erasure fence. + /// A missing DB can be recovered; explicit erasure cannot be undone. pub fn restore(&self, snapshot: impl AsRef) -> Result<()> { self.manager - .restore_scope(&self.scope_id, snapshot.as_ref()) + .restore_scope(&self.scope_id, snapshot.as_ref(), self) } - /// Closes connections in the scope and deletes only its storage tree. Retries are idempotent. - /// Keeps the same manager's admission fence after physical deletion, preventing cached tasks - /// from recreating databases before the Host finalizes deletion. Host deletion reservation is a separate responsibility. + /// Copies a snapshot from an explicitly selected source owner. Host authorization + /// remains required; source provenance is not itself a domain grant. The source + /// owner's deletion fence is held and checked even when the target root is new. + pub fn restore_from( + &self, + snapshot: impl AsRef, + source: &ScopedFeatureStorage, + ) -> Result<()> { + self.manager + .restore_scope(&self.scope_id, snapshot.as_ref(), source) + } + + /// Closes connections and deletes only this storage tree. Retries are idempotent. + /// Keeps a durable fence shared by all managers/processes; restore cannot clear it. pub fn delete(&self) -> Result<()> { self.manager.delete_scope(&self.scope_id) } @@ -581,14 +1056,69 @@ pub struct FeatureDatabase { inner: Arc, } +/// Short-lived shared physical lifecycle lease. Cooperating maintenance in any +/// manager/process waits until drop. It pins a verified DB, not current domain +/// permission, and must not be held across a long Job/network wait. +pub struct DatabaseGuard<'a> { + database: &'a FeatureDatabase, + _lease: fs::File, +} + +impl DatabaseGuard<'_> { + pub fn identity(&self) -> &DatabaseIdentity { + self.database.identity() + } +} + struct FeatureDatabaseInner { scope_id: String, feature_id: String, accepting_operations: AtomicBool, connection: Mutex>, + root: PathBuf, + path: PathBuf, + binding: owner::Binding, } impl FeatureDatabase { + pub fn identity(&self) -> &DatabaseIdentity { + &self.inner.binding.identity + } + + /// Pins the checked DB for a short release decision. Retain only the identity + /// across long Job waits and reacquire/recheck current authority on resume. + pub fn with_identity( + &self, + expected: &DatabaseIdentity, + operation: impl FnOnce(&Connection) -> Result, + ) -> Result { + if self.identity() != expected { + return Err(FeatureStorageError::Identity( + "authorization targets another DB".into(), + )); + } + self.with_connection(operation) + } + + pub fn guard(&self, expected: &DatabaseIdentity) -> Result> { + if self.identity() != expected { + return Err(FeatureStorageError::Identity( + "authorization targets another DB".into(), + )); + } + let lease = owner::Lifecycle { + root: self.inner.root.clone(), + scope: self.inner.scope_id.clone(), + } + .lock(false)?; + self.inner.ensure_accepting()?; + self.inner.verify()?; + Ok(DatabaseGuard { + database: self, + _lease: lease, + }) + } + pub fn scope_id(&self) -> &str { &self.inner.scope_id } @@ -620,21 +1150,42 @@ impl FeatureDatabase { where E: From, { + self.try_with_connection_mut(|connection| operation(connection)) + } + + /// Host adapter for repositories that own their transaction boundaries. + /// The shared lifecycle guard covers the entire closure, including commit or + /// rollback. Do not retain it across network waits or recursive storage calls. + pub fn try_with_connection_mut( + &self, + operation: impl FnOnce(&mut Connection) -> std::result::Result, + ) -> std::result::Result + where + E: From, + { + let lifecycle = owner::Lifecycle { + root: self.inner.root.clone(), + scope: self.inner.scope_id.clone(), + }; + let _lease = lifecycle.lock(false).map_err(E::from)?; self.inner.ensure_accepting().map_err(E::from)?; - let connection = self + let mut connection = self .inner .connection .lock() - .map_err(|_| { - FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) - }) - .map_err(E::from)?; + .map_err(|_| E::from(FeatureStorageError::DatabaseClosed))?; self.inner.ensure_accepting().map_err(E::from)?; let connection = connection - .as_ref() - .ok_or(FeatureStorageError::DatabaseClosed) + .as_mut() + .ok_or_else(|| E::from(FeatureStorageError::DatabaseClosed))?; + lifecycle + .verify(&self.inner.binding, &self.inner.path, connection) .map_err(E::from)?; - operation(connection) + let result = operation(connection)?; + lifecycle + .verify(&self.inner.binding, &self.inner.path, connection) + .map_err(|error| E::from(self.inner.binding.unknown(error)))?; + Ok(result) } /// Updates multiple tables within one Feature DB in an IMMEDIATE transaction. @@ -656,30 +1207,17 @@ impl FeatureDatabase { where E: From, { - self.inner.ensure_accepting().map_err(E::from)?; - let mut connection = self - .inner - .connection - .lock() - .map_err(|_| { - FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) - }) - .map_err(E::from)?; - self.inner.ensure_accepting().map_err(E::from)?; - let connection = connection - .as_mut() - .ok_or(FeatureStorageError::DatabaseClosed) - .map_err(E::from)?; - let transaction = connection - .transaction_with_behavior(TransactionBehavior::Immediate) - .map_err(FeatureStorageError::from) - .map_err(E::from)?; - let value = operation(&transaction)?; - transaction - .commit() - .map_err(FeatureStorageError::from) - .map_err(E::from)?; - Ok(value) + self.try_with_connection_mut(|connection| { + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(FeatureStorageError::from) + .map_err(E::from)?; + let value = operation(&tx)?; + tx.commit().map_err(|error| { + E::from(self.inner.binding.unknown(FeatureStorageError::from(error))) + })?; + Ok(value) + }) } pub fn schema_version(&self) -> Result { @@ -696,6 +1234,24 @@ impl FeatureDatabase { } impl FeatureDatabaseInner { + fn verify(&self) -> Result<()> { + let connection = self + .connection + .lock() + .map_err(|_| FeatureStorageError::DatabaseClosed)?; + owner::Lifecycle { + root: self.root.clone(), + scope: self.scope_id.clone(), + } + .verify( + &self.binding, + &self.path, + connection + .as_ref() + .ok_or(FeatureStorageError::DatabaseClosed)?, + ) + } + fn ensure_accepting(&self) -> Result<()> { if self.accepting_operations.load(Ordering::Acquire) { Ok(()) @@ -718,6 +1274,15 @@ impl FeatureDatabaseInner { } } +fn check_admission(request: &str, evidence: &str) -> Result<()> { + if request.trim().is_empty() || evidence.trim().is_empty() { + return Err(FeatureStorageError::Identity( + "request and Host authority evidence are required".into(), + )); + } + Ok(()) +} + fn validate_identifier<'a>(kind: &'static str, value: &'a str) -> Result<&'a str> { let valid = !value.is_empty() && value.len() <= MAX_IDENTIFIER_BYTES @@ -841,11 +1406,11 @@ fn sqlite_is_busy(error: &rusqlite::Error) -> bool { } fn migrate( - connection: &Connection, + transaction: &Transaction<'_>, feature_id: &str, - migrations: &[FeatureMigration], + registration: FeatureRegistration, ) -> Result<()> { - let transaction = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?; + let migrations = registration.migrations; transaction.execute_batch(&format!( r#" CREATE TABLE IF NOT EXISTS {MIGRATION_TABLE} ( @@ -865,32 +1430,36 @@ CREATE TABLE IF NOT EXISTS {MIGRATION_TABLE} ( .collect::, _>>()?; drop(statement); - let supported = migrations.last().map_or(0, |migration| migration.version); - if let Some((actual, _)) = applied.last() - && *actual > supported - { - return Err(FeatureStorageError::UnsupportedSchema { - feature: feature_id.to_string(), - actual: *actual, - supported, - }); - } - for (index, (version, name)) in applied.iter().enumerate() { - let Some(expected) = migrations.get(index) else { + if let Some(validate) = registration.history_validation { + validate(transaction, &applied)?; + } else { + let supported = migrations.last().map_or(0, |migration| migration.version); + if let Some((actual, _)) = applied.last() + && *actual > supported + { return Err(FeatureStorageError::UnsupportedSchema { feature: feature_id.to_string(), - actual: *version, + actual: *actual, supported, }); - }; - if *version != expected.version || name != expected.name { - return Err(FeatureStorageError::MigrationHistory { - feature: feature_id.to_string(), - detail: format!( - "stored migration {version} (`{name}`) does not match expected {} (`{}`)", - expected.version, expected.name - ), - }); + } + for (index, (version, name)) in applied.iter().enumerate() { + let Some(expected) = migrations.get(index) else { + return Err(FeatureStorageError::UnsupportedSchema { + feature: feature_id.to_string(), + actual: *version, + supported, + }); + }; + if *version != expected.version || name != expected.name { + return Err(FeatureStorageError::MigrationHistory { + feature: feature_id.to_string(), + detail: format!( + "stored migration {version} (`{name}`) does not match expected {} (`{}`)", + expected.version, expected.name + ), + }); + } } } @@ -908,7 +1477,6 @@ CREATE TABLE IF NOT EXISTS {MIGRATION_TABLE} ( params![migration.version, migration.name], )?; } - transaction.commit()?; Ok(()) } @@ -1060,6 +1628,9 @@ fn temporary_sibling(destination: &Path, operation: &str) -> Result { Ok(parent.join(format!(".{name}.{operation}-{}", Uuid::now_v7()))) } +#[cfg(test)] +mod identity_tests; + #[cfg(test)] mod tests { use std::sync::{Arc, Barrier}; @@ -1554,17 +2125,17 @@ mod tests { let feature = workspace.register(registration("feature-a")).unwrap(); let database = workspace.open(&feature).unwrap(); insert(&database, "snapshot-value").unwrap(); - workspace.backup(&snapshot).unwrap(); - insert(&database, "after-snapshot").unwrap(); - workspace.delete().unwrap(); - assert!(matches!( - workspace.open(&feature).err().unwrap(), - FeatureStorageError::ScopeMaintenance(_) - )); drop(database); drop(workspace); - drop(manager); - + manager.shutdown().unwrap(); + // An unregistered/disabled Feature still belongs to its source owner. + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + workspace.backup(&snapshot).unwrap(); + drop(workspace); + manager.shutdown().unwrap(); + // Simulate lost data files, not an explicit erasure request. Owner receipts survive. + fs::remove_dir_all(root.join("workspace-a")).unwrap(); let manager = FeatureStorage::new(&root); let workspace = manager.workspace("workspace-a").unwrap(); workspace.restore(&snapshot).unwrap(); @@ -1573,6 +2144,19 @@ mod tests { values(&workspace.open(&feature).unwrap()).unwrap(), ["snapshot-value"] ); + workspace.delete().unwrap(); + assert!(matches!( + workspace.restore(&snapshot), + Err(FeatureStorageError::ScopeMaintenance(_)) + )); + let fresh = FeatureStorage::new(temp.path().join("fresh-target")); + assert!(matches!( + fresh + .scope("workspace-a") + .unwrap() + .restore_from(&snapshot, &workspace), + Err(FeatureStorageError::ScopeMaintenance(_)) + )); } #[test] diff --git a/crates/feature-storage/src/owner.rs b/crates/feature-storage/src/owner.rs new file mode 100644 index 000000000..2edad5d37 --- /dev/null +++ b/crates/feature-storage/src/owner.rs @@ -0,0 +1,406 @@ +//! Physical ownership is independent of domain authorization. The trusted Host +//! supplies adoption evidence and validates domain attribution inside the identity +//! transaction. Merely finding a file never grants adoption authority. +//! +//! Lock order is lifecycle file, manager state, connection, SQLite transaction. +//! The stable scope lock is shared through consumer commit/rollback and exclusive +//! during provisioning, adoption, deletion and restore, across managers/processes. +//! It is advisory: arbitrary native code bypassing this owner can rename files. +//! Such replacement is rejected on the next access using the file identity and +//! persisted physical identity. Hosts must protect the root and its lock/owner DB +//! against uncooperative writers; this is not a filesystem sandbox. +use super::*; +use rusqlite::OptionalExtension; +use std::fs::{File, OpenOptions}; +use std::os::unix::fs::MetadataExt; + +const IDENTITY_TABLE: &str = "__yoi_storage_identity"; + +/// Expected storage target, retained with authorization and rechecked on resume. +/// This is a physical identity, not an authorization credential or a Job lease. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DatabaseIdentity { + pub scope: String, + pub feature: String, + pub physical: String, +} + +#[derive(Clone, Debug)] +pub(super) struct Binding { + pub identity: DatabaseIdentity, + pub device: u64, + pub inode: u64, + pub request: String, +} + +pub(super) struct Lifecycle { + pub root: PathBuf, + pub scope: String, +} + +impl Lifecycle { + pub fn lock(&self, exclusive: bool) -> Result { + durable_create_directory(&self.root.join(".lifecycle"), &mut sync_directory)?; + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open( + self.root + .join(".lifecycle") + .join(format!("{}.lock", self.scope)), + )?; + if exclusive { + lock.lock()?; + } else { + lock.lock_shared()?; + } + Ok(lock) + } + + fn catalog(&self, create: bool) -> Result { + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | if create { + OpenFlags::SQLITE_OPEN_CREATE + } else { + OpenFlags::empty() + }; + let connection = Connection::open_with_flags(self.root.join(".owner.sqlite"), flags)?; + configure_connection(&connection)?; + if create { + connection.execute_batch( + " +CREATE TABLE IF NOT EXISTS storage_requests ( + request TEXT PRIMARY KEY, scope TEXT NOT NULL, feature TEXT NOT NULL, + physical TEXT NOT NULL, device INTEGER NOT NULL, inode INTEGER NOT NULL, + kind TEXT NOT NULL, evidence TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, + current INTEGER NOT NULL DEFAULT 1); +CREATE UNIQUE INDEX IF NOT EXISTS storage_current_target ON storage_requests(scope,feature) WHERE current=1; +CREATE TABLE IF NOT EXISTS storage_deletions (scope TEXT PRIMARY KEY); +", + )?; + } + Ok(connection) + } + + pub fn fenced(&self, scope: &str) -> Result { + if legacy_owner::fenced(&self.root, scope)? { + return Ok(true); + } + if !self.root.join(".owner.sqlite").try_exists()? { + return Ok(false); + } + Ok(self.catalog(false)?.query_row( + "SELECT EXISTS(SELECT 1 FROM storage_deletions WHERE scope=?1)", + [scope], + |r| r.get(0), + )?) + } + + pub fn delete(&self, scope: &str) -> Result<()> { + let directory = self.root.join(".lifecycle"); + durable_create_directory(&directory, &mut sync_directory)?; + let fence = OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(directory.join(format!("{scope}.deleted")))?; + fence.sync_all()?; + sync_directory(&directory)?; + self.catalog(true)?.execute( + "INSERT OR IGNORE INTO storage_deletions(scope) VALUES(?1)", + [scope], + )?; + Ok(()) + } + + pub fn lookup(&self, scope: &str, feature: &str) -> Result> { + if self.fenced(scope)? { + return Err(FeatureStorageError::ScopeMaintenance(scope.into())); + } + if !self.root.join(".owner.sqlite").try_exists()? { + return Ok(None); + } + let catalog = self.catalog(false)?; + Ok(catalog.query_row("SELECT physical,device,inode,request FROM storage_requests WHERE scope=?1 AND feature=?2 AND completed=1 AND current=1", params![scope,feature], |r| Ok(Binding { + identity: DatabaseIdentity { scope: scope.into(), feature: feature.into(), physical: r.get(0)? }, + device: r.get(1)?, inode: r.get(2)?, request: r.get(3)?, + })).optional()?) + } + + /// Called under exclusive lifecycle lock. The reservation precedes identity + /// persistence and survives response loss; exact retries reconcile the same + /// inode and UUID rather than manufacture past provisioning permission. + pub fn reserve( + &self, + scope: &str, + feature: &str, + path: &Path, + request: &str, + kind: &str, + evidence: &str, + ) -> Result { + if request.trim().is_empty() || evidence.trim().is_empty() { + return Err(FeatureStorageError::Identity( + "request and Host authority evidence are required".into(), + )); + } + if self.fenced(scope)? { + return Err(FeatureStorageError::ScopeMaintenance(scope.into())); + } + if kind != "provision" && !path.is_file() { + return Err(FeatureStorageError::Identity( + "expected existing database is missing".into(), + )); + } + let mut catalog = self.catalog(true)?; + let tx = catalog.transaction_with_behavior(TransactionBehavior::Immediate)?; + let saved: Option<(String,String,String,u64,u64,String,String,bool)> = tx.query_row( + "SELECT scope,feature,physical,device,inode,kind,evidence,current FROM storage_requests WHERE request=?1", [request], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).optional()?; + if let Some((s, f, physical, device, inode, k, e, current)) = saved { + if s != scope || f != feature || k != kind || e != evidence || !current { + return Err(FeatureStorageError::Identity( + "request identity was reused for a different target or authority".into(), + )); + } + let binding = Binding { + identity: DatabaseIdentity { + scope: s, + feature: f, + physical, + }, + device, + inode, + request: request.into(), + }; + binding.check_file(path)?; + return Ok(binding); + } + let busy: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM storage_requests WHERE scope=?1 AND feature=?2 AND current=1)", + params![scope, feature], + |r| r.get(0), + )?; + if busy && kind != "restore" { + return Err(FeatureStorageError::Identity( + "target already has an ownership request; reconcile that request".into(), + )); + } + if kind == "provision" { + durable_create_directory( + path.parent() + .ok_or_else(|| FeatureStorageError::Identity("missing parent".into()))?, + &mut sync_directory, + )?; + let file = OpenOptions::new().write(true).create_new(true).open(path)?; + file.sync_all()?; + sync_directory(path.parent().unwrap())?; + } + // A verified restore supersedes the active physical lifecycle, but keeps + // every old request/receipt as provenance. It cannot replay as current authority. + if kind == "restore" { + tx.execute( + "UPDATE storage_requests SET current=0 WHERE scope=?1 AND feature=?2 AND current=1", + params![scope, feature], + )?; + } + let metadata = fs::metadata(path)?; + if !metadata.is_file() { + return Err(FeatureStorageError::Identity( + "database is not a regular file".into(), + )); + } + let binding = Binding { + identity: DatabaseIdentity { + scope: scope.into(), + feature: feature.into(), + physical: if kind == "adopt" { + legacy_owner::identity(path, scope, feature)? + .unwrap_or_else(|| Uuid::now_v7().to_string()) + } else { + Uuid::now_v7().to_string() + }, + }, + device: metadata.dev(), + inode: metadata.ino(), + request: request.into(), + }; + tx.execute("INSERT INTO storage_requests(request,scope,feature,physical,device,inode,kind,evidence) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![request,scope,feature,binding.identity.physical,binding.device,binding.inode,kind,evidence])?; + tx.commit()?; + Ok(binding) + } + + /// Snapshot bytes are not authority. Match their saved identity to this + /// explicitly supplied source owner's completed provenance, including an old + /// lifecycle superseded by a prior restore. The caller holds its lifecycle lock. + pub fn verify_snapshot(&self, scope: &str, feature: &str, path: &Path) -> Result<()> { + if self.fenced(scope)? { + return Err(FeatureStorageError::ScopeMaintenance(scope.into())); + } + if !self.root.join(".owner.sqlite").is_file() { + return Err(FeatureStorageError::Identity( + "snapshot source owner is missing".into(), + )); + } + let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let (s, f, physical, request): (String, String, String, String) = connection.query_row( + "SELECT scope,feature,physical,request FROM __yoi_storage_identity WHERE singleton=1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + )?; + Uuid::parse_str(&physical).map_err(|_| { + FeatureStorageError::Identity("invalid snapshot physical identity".into()) + })?; + let known: bool = self.catalog(false)?.query_row( + "SELECT EXISTS(SELECT 1 FROM storage_requests WHERE scope=?1 AND feature=?2 AND physical=?3 AND request=?4 AND completed=1)", + params![scope, feature, physical, request], |r| r.get(0))?; + if s != scope || f != feature || !known { + return Err(FeatureStorageError::Identity( + "snapshot does not belong to the selected source owner".into(), + )); + } + Ok(()) + } + + pub fn request_kind(&self, request: &str) -> Result> { + if !self.root.join(".owner.sqlite").try_exists()? { + return Ok(None); + } + Ok(self + .catalog(false)? + .query_row( + "SELECT kind FROM storage_requests WHERE request=?1 AND current=1", + [request], + |r| r.get(0), + ) + .optional()?) + } + + pub fn complete(&self, binding: &Binding) -> Result<()> { + let changed = self.catalog(false)?.execute("UPDATE storage_requests SET completed=1 WHERE request=?1 AND scope=?2 AND feature=?3 AND physical=?4 AND device=?5 AND inode=?6 AND current=1",params![binding.request,binding.identity.scope,binding.identity.feature,binding.identity.physical,binding.device,binding.inode])?; + if changed != 1 { + return Err(FeatureStorageError::Identity( + "ownership reservation changed".into(), + )); + } + Ok(()) + } + + pub fn verify(&self, binding: &Binding, path: &Path, connection: &Connection) -> Result<()> { + let current = self + .lookup(&binding.identity.scope, &binding.identity.feature)? + .ok_or_else(|| FeatureStorageError::Identity("no completed ownership record".into()))?; + if current.identity != binding.identity + || current.device != binding.device + || current.inode != binding.inode + || current.request != binding.request + { + return Err(FeatureStorageError::Identity( + "physical ownership changed".into(), + )); + } + binding.check_file(path)?; + binding.check_database(connection) + } +} + +impl Binding { + pub fn unknown(&self, error: FeatureStorageError) -> FeatureStorageError { + FeatureStorageError::OutcomeUnknown { + identity: self.identity.clone(), + detail: error.to_string(), + } + } + pub fn check_file(&self, path: &Path) -> Result<()> { + let metadata = fs::metadata(path)?; + if metadata.dev() != self.device || metadata.ino() != self.inode { + return Err(FeatureStorageError::Identity( + "database path was replaced; explicit authorized adoption is required".into(), + )); + } + Ok(()) + } + + pub fn check_database(&self, connection: &Connection) -> Result<()> { + Uuid::parse_str(&self.identity.physical) + .map_err(|_| FeatureStorageError::Identity("invalid physical identity".into()))?; + let found: Option<(String, String, String, String)> = connection + .query_row( + &format!( + "SELECT scope,feature,physical,request FROM {IDENTITY_TABLE} WHERE singleton=1" + ), + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + if found + != Some(( + self.identity.scope.clone(), + self.identity.feature.clone(), + self.identity.physical.clone(), + self.request.clone(), + )) + { + return Err(FeatureStorageError::Identity( + "database identity/receipt is missing or mismatched".into(), + )); + } + Ok(()) + } + + /// Restore is a new physical lifecycle. Retain old provenance but never + /// return its identity as authority over the restored inode. + pub fn restore_identity(&self, connection: &mut Connection) -> Result<()> { + let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='__yoi_storage_identity')", + [], + |r| r.get(0), + )?; + if exists { + if self.check_database(&tx).is_ok() { + return Ok(()); + } + let (scope, feature): (String, String) = tx.query_row( + "SELECT scope,feature FROM __yoi_storage_identity WHERE singleton=1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + if scope != self.identity.scope || feature != self.identity.feature { + return Err(FeatureStorageError::Identity( + "snapshot belongs to another scope".into(), + )); + } + tx.execute_batch("CREATE TABLE IF NOT EXISTS __yoi_storage_origins (scope TEXT NOT NULL, feature TEXT NOT NULL, physical TEXT NOT NULL, request TEXT NOT NULL, PRIMARY KEY(physical,request)); INSERT OR IGNORE INTO __yoi_storage_origins SELECT scope,feature,physical,request FROM __yoi_storage_identity; DELETE FROM __yoi_storage_identity;")?; + } + self.persist(&tx)?; + tx.commit()?; + Ok(()) + } + + pub fn persist(&self, tx: &Transaction<'_>) -> Result<()> { + tx.execute_batch(&format!( + "CREATE TABLE IF NOT EXISTS {IDENTITY_TABLE} ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), scope TEXT NOT NULL, + feature TEXT NOT NULL, physical TEXT NOT NULL, request TEXT NOT NULL);" + ))?; + let count: i64 = + tx.query_row(&format!("SELECT COUNT(*) FROM {IDENTITY_TABLE}"), [], |r| { + r.get(0) + })?; + if count == 0 { + tx.execute( + &format!("INSERT INTO {IDENTITY_TABLE} VALUES(1,?1,?2,?3,?4)"), + params![ + self.identity.scope, + self.identity.feature, + self.identity.physical, + self.request + ], + )?; + } + self.check_database(tx) + } +} diff --git a/crates/subjektiv/src/deletion/tests.rs b/crates/subjektiv/src/deletion/tests.rs index 1662cd989..bdb62d6a0 100644 --- a/crates/subjektiv/src/deletion/tests.rs +++ b/crates/subjektiv/src/deletion/tests.rs @@ -159,10 +159,21 @@ fn purge_failure_rolls_back_content_but_not_the_committed_fence_and_restart_reco c.execute_batch("CREATE TRIGGER injected_failure BEFORE DELETE ON subjects BEGIN SELECT RAISE(ABORT, 'simulated purge failure'); END;")?; Ok(()) }).unwrap(); - assert!(store.finish_subject_deletion(&id, "delete").is_err()); + let error = store.finish_subject_deletion(&id, "delete").unwrap_err(); + assert!( + error.to_string().contains("simulated purge failure"), + "{error}" + ); assert_eq!(count(&store, "memory_records"), 1); assert_eq!(count(&store, "memory_changes"), 1); assert_eq!(store.subject_deletion(&id).unwrap(), Some(pending.clone())); + store + .database + .try_with_connection::<_, SubjektivError>(|c| { + c.execute_batch("DROP TRIGGER injected_failure")?; + Ok(()) + }) + .unwrap(); drop(store); drop(scope); manager.shutdown().unwrap(); @@ -177,13 +188,6 @@ fn purge_failure_rolls_back_content_but_not_the_committed_fence_and_restart_reco pending ); assert!(store.list_memories(&id).is_err()); - store - .database - .try_with_connection::<_, SubjektivError>(|c| { - c.execute_batch("DROP TRIGGER injected_failure")?; - Ok(()) - }) - .unwrap(); assert!( store .finish_subject_deletion(&id, "delete") diff --git a/crates/subjektiv/src/journal/tests.rs b/crates/subjektiv/src/journal/tests.rs index d246552bc..f631f1881 100644 --- a/crates/subjektiv/src/journal/tests.rs +++ b/crates/subjektiv/src/journal/tests.rs @@ -498,8 +498,8 @@ mod input_delivery { } #[test] - fn input_delivery_migration_failure_rolls_back_table_creation_and_retries() { - // A colliding trigger is created before migration so failure occurs after its new table DDL. + fn input_delivery_migration_collision_is_rejected_before_ddl_and_retries() { + // A colliding trigger must be diagnosed before pending migration DDL. let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("rollback"); let manager = FeatureStorage::new(&root); @@ -529,12 +529,16 @@ mod input_delivery { let manager = FeatureStorage::new(&root); let scope = manager.scope("input-delivery-rollback").unwrap(); let registration = SubjektivStore::register(&scope).unwrap(); - assert!(matches!( - SubjektivStore::open(&scope, ®istration), + match SubjektivStore::open(&scope, ®istration) { Err(crate::SubjektivError::Storage( - feature_storage::FeatureStorageError::MigrationFailed { .. } - )) - )); + feature_storage::FeatureStorageError::MigrationHistory { detail, .. }, + )) => assert!( + detail.contains("journal_generation_input_delivery_no_update"), + "{detail}" + ), + Err(error) => panic!("expected pre-DDL shape refusal, got {error:?}"), + Ok(_) => panic!("colliding schema was accepted"), + } drop(scope); manager.shutdown().unwrap(); drop(manager); @@ -551,6 +555,11 @@ mod input_delivery { assert_eq!(store.subject(&subject.id).unwrap(), Some(subject.clone())); store.database.try_transaction(|tx| { assert!(!tx.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='journal_generation_input_deliveries')", [], |r| r.get::<_, bool>(0))?); + let receipts = tx.prepare("SELECT version,name FROM __yoi_feature_schema_migrations ORDER BY version")? + .query_map([], |r| Ok((r.get::<_, u32>(0)?, r.get::<_, String>(1)?)))? + .collect::, _>>()?; + let expected: Vec<_> = (&crate::MIGRATIONS[..12]).iter().map(|m| (m.version(), m.name().to_string())).collect(); + assert_eq!(receipts, expected); tx.execute_batch("DROP TRIGGER journal_generation_input_delivery_no_update")?; Ok::<_, JournalError>(()) }).unwrap(); @@ -5083,10 +5092,12 @@ fn backup_restore_preserves_generation_records_corrections_sources_position_and_ vec![], ); fixture.close(&generation); - fixture.workspace.as_ref().unwrap().delete().unwrap(); fixture.store.take(); fixture.workspace.take(); fixture.manager.take().unwrap().shutdown().unwrap(); + // Data-file loss is recoverable while the durable source owner remains intact. + // Explicit delete is a separate irreversible fence, covered by storage-owner tests. + std::fs::remove_dir_all(fixture.temp.path().join("storage/journal-tests")).unwrap(); let manager = FeatureStorage::new(fixture.temp.path().join("storage")); let workspace = manager.workspace("journal-tests").unwrap(); workspace.restore(&snapshot).unwrap(); @@ -5222,16 +5233,20 @@ fn failure_before_receipt_rolls_back_record_and_checkpoint_then_exact_retry_succ ); let before = fixture.state(); let sources = fixture.sources(); - assert!(matches!( - fixture.store().execute_journal(&ctx, record.clone()), - Err(JournalError::Storage(_)) - )); + let error = fixture + .store() + .execute_journal(&ctx, record.clone()) + .unwrap_err(); + assert!( + error.to_string().contains("injected before receipt"), + "{error}" + ); + fixture.sql("DROP TRIGGER fail_test_receipt;").unwrap(); fixture.reopen(); assert_eq!(fixture.state(), before); assert_eq!(fixture.sources(), sources); assert!(fixture.entries().is_empty()); assert!(fixture.receipt("fault-record").is_none()); - fixture.sql("DROP TRIGGER fail_test_receipt;").unwrap(); let saved = fixture .store() .execute_journal(&ctx, record.clone()) @@ -5248,16 +5263,20 @@ fn failure_before_receipt_rolls_back_record_and_checkpoint_then_exact_retry_succ ); fixture.sql("CREATE TRIGGER fail_test_receipt BEFORE INSERT ON journal_operations WHEN NEW.request_id='fault-checkpoint' BEGIN SELECT RAISE(ABORT, 'injected before receipt'); END;").unwrap(); let before_checkpoint = fixture.state(); - assert!(matches!( - fixture.store().execute_journal(&ctx, cp.clone()), - Err(JournalError::Storage(_)) - )); + let error = fixture + .store() + .execute_journal(&ctx, cp.clone()) + .unwrap_err(); + assert!( + error.to_string().contains("injected before receipt"), + "{error}" + ); + fixture.sql("DROP TRIGGER fail_test_receipt;").unwrap(); fixture.reopen(); assert_eq!(fixture.state(), before_checkpoint); assert_eq!(fixture.sources(), sources); assert_eq!(fixture.receipt("fault-record"), Some(saved)); assert!(fixture.receipt("fault-checkpoint").is_none()); - fixture.sql("DROP TRIGGER fail_test_receipt;").unwrap(); let reflected = fixture.store().execute_journal(&ctx, cp.clone()).unwrap(); assert_eq!((reflected.process_order, reflected.process_byte), (2, 0)); fixture.reopen(); @@ -5529,7 +5548,8 @@ fn serialized_metadata_and_text_budgets_preserve_continuation_and_availability_r } #[test] -fn journal_migration_failure_rolls_back_ddl_and_retries_without_changing_existing_subject() { +fn journal_migration_collision_is_rejected_before_ddl_and_retries_without_changing_existing_subject() + { for (baseline, collision, rolled_back) in [ (&crate::MIGRATIONS[..7], "journal_entries", "journals"), ( @@ -5564,12 +5584,13 @@ fn journal_migration_failure_rolls_back_ddl_and_retries_without_changing_existin let manager = FeatureStorage::new(&root); let scope = manager.scope("migration-rollback").unwrap(); let registration = SubjektivStore::register(&scope).unwrap(); - assert!(matches!( - SubjektivStore::open(&scope, ®istration), + match SubjektivStore::open(&scope, ®istration) { Err(crate::SubjektivError::Storage( - feature_storage::FeatureStorageError::MigrationFailed { .. } - )) - )); + feature_storage::FeatureStorageError::MigrationHistory { detail, .. }, + )) => assert!(detail.contains(collision), "{detail}"), + Err(error) => panic!("expected pre-DDL shape refusal, got {error:?}"), + Ok(_) => panic!("colliding schema was accepted"), + } drop(scope); manager.shutdown().unwrap(); drop(manager); @@ -5591,6 +5612,17 @@ fn journal_migration_failure_rolls_back_ddl_and_retries_without_changing_existin [rolled_back], |r| r.get::<_, bool>(0) )?); + let receipts = tx + .prepare( + "SELECT version,name FROM __yoi_feature_schema_migrations ORDER BY version", + )? + .query_map([], |r| Ok((r.get::<_, u32>(0)?, r.get::<_, String>(1)?)))? + .collect::, _>>()?; + let expected: Vec<_> = (baseline) + .iter() + .map(|m| (m.version(), m.name().to_string())) + .collect(); + assert_eq!(receipts, expected); tx.execute_batch(&format!("DROP TABLE {collision}"))?; Ok(()) }) @@ -5994,16 +6026,19 @@ fn subject_deletion_fence_denies_all_journal_read_write_binding_and_recovery_pat ), ); // Failed purge and restart must not revive any cached context or exact receipt. - f.sql("CREATE TRIGGER injected_subject_purge_failure BEFORE DELETE ON subjects BEGIN SELECT RAISE(ABORT,'injected'); END;").unwrap(); + f.sql("CREATE TRIGGER injected_subject_purge_failure BEFORE DELETE ON subjects BEGIN SELECT RAISE(ABORT,'injected subject purge'); END;").unwrap(); + let error = f + .store() + .finish_subject_deletion(&f.host.subject_id, "delete") + .unwrap_err(); assert!( - f.store() - .finish_subject_deletion(&f.host.subject_id, "delete") - .is_err() + error.to_string().contains("injected subject purge"), + "{error}" ); - f.reopen(); - subject_deleted(f.store().execute_journal(&ctx, operation)); f.sql("DROP TRIGGER injected_subject_purge_failure;") .unwrap(); + f.reopen(); + subject_deleted(f.store().execute_journal(&ctx, operation)); f.store() .finish_subject_deletion(&f.host.subject_id, "delete") .unwrap(); diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index e7203dc98..631d11c58 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -673,8 +673,11 @@ static MIGRATIONS: &[FeatureMigration] = &[ ), ]; +mod storage_formats; + pub const REGISTRATION: FeatureRegistration = - FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS); + FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS) + .with_history_validation(storage_formats::validate); /// Subject Memory repository restricted to a Host storage scope. /// Owns schemas, validation, queries, and domain transactions; delegates physical layout @@ -5623,10 +5626,11 @@ mod tests { ) .unwrap(); assert!(second.previous_change_id.is_some()); - workspace.delete().unwrap(); drop(reopened); drop(workspace); drop(manager); + // Simulated physical loss retains owner receipts, unlike explicit erasure. + std::fs::remove_dir_all(root.join("workspace-a")).unwrap(); let manager = FeatureStorage::new(&root); let workspace = manager.workspace("workspace-a").unwrap(); diff --git a/crates/subjektiv/src/storage_format_tests.rs b/crates/subjektiv/src/storage_format_tests.rs new file mode 100644 index 000000000..fad671e2f --- /dev/null +++ b/crates/subjektiv/src/storage_format_tests.rs @@ -0,0 +1,581 @@ +//! Storage component fixtures: real SQLite and FeatureStorage registration/open/reopen. +//! Published extension DDL is frozen shape evidence, not a replacement domain implementation. +//! The suite proves physical retention and receipt/shape rejection, not permission adoption, +//! Job recovery, Journal release, or Host authorization. Identity/adoption races belong to the +//! FeatureStorage owner's suite. No production DB, Runtime, or external service is used. + +use super::*; +use crate::{REGISTRATION, SubjectRole, SubjektivStore}; +use feature_storage::{FeatureDatabase, FeatureRegistration, FeatureStorage, ScopedFeatureStorage}; +use rusqlite::types::Value; +use std::path::Path; + +const SCOPE: &str = "published-subjektiv-formats"; +const PUBLISHED: [Format; 7] = [ + Format::Permission18, + Format::Consolidation18, + Format::Fixed18, + Format::Cancellation19, + Format::Consolidation20, + Format::Fixed20, + Format::Cancellation20, +]; + +type Receipt = (u32, String, String); +type TableRows = Vec<(String, Vec>)>; + +fn open(root: &Path, registration: FeatureRegistration) -> (ScopedFeatureStorage, FeatureDatabase) { + let scope = FeatureStorage::new(root).scope(SCOPE).unwrap(); + let registered = scope.register(registration).unwrap(); + let database = scope.open(®istered).unwrap(); + (scope, database) +} + +fn receipts(connection: &Connection) -> Vec { + connection + .prepare(&format!( + "SELECT version,name,applied_at FROM {HISTORY_TABLE} ORDER BY version" + )) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .collect::, _>>() + .unwrap() +} + +fn mark_receipts(connection: &Connection) { + // Distinct saved bytes detect delete/reinsert, even in a single wall-clock second. + connection + .execute( + "UPDATE __yoi_feature_schema_migrations + SET applied_at=printf('original-published-%d',version)", + [], + ) + .unwrap(); +} + +fn all_rows(connection: &Connection) -> TableRows { + let names: Vec = connection + .prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + names + .into_iter() + .map(|name| { + let quoted = name.replace('"', "\"\""); + let columns = connection + .prepare(&format!("SELECT * FROM \"{quoted}\"")) + .unwrap() + .column_count(); + let order = (1..=columns) + .map(|column| column.to_string()) + .collect::>() + .join(","); + let mut statement = connection + .prepare(&format!("SELECT * FROM \"{quoted}\" ORDER BY {order}")) + .unwrap(); + let rows = statement + .query_map([], |row| { + (0..columns) + .map(|column| row.get(column)) + .collect::, _>>() + }) + .unwrap() + .collect::, _>>() + .unwrap(); + (name, rows) + }) + .collect() +} + +fn history(connection: &Connection) -> Vec<(u32, String)> { + receipts(connection) + .into_iter() + .map(|(version, name, _)| (version, name)) + .collect() +} + +fn assert_valid(connection: &mut Connection) { + let tx = connection.transaction().unwrap(); + validate(&tx, &history(&tx)).unwrap(); + tx.commit().unwrap(); +} + +fn assert_foreign_keys(connection: &Connection) { + assert!( + connection + .prepare("PRAGMA foreign_key_check") + .unwrap() + .query([]) + .unwrap() + .next() + .unwrap() + .is_none() + ); +} + +// These fixture functions execute the historical SQL, including rebuilds and triggers. They +// never invent alternative tables with similar names or stamp a canonical receipt on old data. +fn permission_fixture(connection: &Connection) { + connection.execute_batch(PERMISSION_SEAL_SQL).unwrap(); + for table in PERMISSION_FENCE_TABLES { + for action in ["INSERT", "UPDATE"] { + connection + .execute_batch(&format!( + "CREATE TRIGGER permission_sealed_{table}_{action} BEFORE {action} ON {table} + WHEN EXISTS(SELECT 1 FROM permission_authority_seal) + BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;" + )) + .unwrap(); + } + connection + .execute_batch(&format!( + "CREATE TRIGGER permission_sealed_{table}_DELETE BEFORE DELETE ON {table} + WHEN EXISTS(SELECT 1 FROM permission_authority_seal) + AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id) + BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;" + )) + .unwrap(); + } + connection.execute_batch( + "INSERT INTO subject_creation_receipts VALUES('creation','historical-subject','fingerprint',' {\"execution\":[]} '); + INSERT INTO permission_authority_seal VALUES(1,'transfer',' {\"opaque_saved_export\":true} ');" + ).unwrap(); +} + +fn scope_fixture(connection: &Connection) { + connection.execute_batch(FIXED_SCOPE_SQL).unwrap(); + // Opaque saved payloads prove byte retention, not valid domain release authority. + connection.execute_batch( + "INSERT INTO journal_final_scopes VALUES('fixed','remove','binding','owner-json','members',1,1,'session',NULL); + INSERT INTO journal_final_scope_sessions VALUES('fixed','session','metadata-json'); + INSERT INTO journal_final_scope_pages VALUES('fixed','','session','page-digest'); + UPDATE journal_final_scopes SET scope_digest='frozen' WHERE request_id='fixed'; + INSERT INTO journal_scope_release_receipts VALUES('fixed','release-json');" + ).unwrap(); +} + +fn cancellation_fixture(connection: &Connection) { + let before: Vec<_> = all_rows(connection) + .into_iter() + .filter(|(name, _)| { + name.starts_with("journal_final_scope") || name == "journal_scope_release_receipts" + }) + .collect(); + connection.execute_batch(CANCELLATION_SQL).unwrap(); + for (name, rows) in before { + let after = all_rows(connection); + assert_eq!( + after.iter().find(|(table, _)| table == &name).unwrap().1, + rows + ); + } + connection + .execute_batch( + r#"INSERT INTO journal_scope_cancellations VALUES('subject-release:cancel', + '{"request_id":"subject-release:cancel","removal_operation_id":"cancel"}');"#, + ) + .unwrap(); +} + +fn consolidation_fixture(connection: &Connection) { + connection.execute_batch(CONSOLIDATION_SQL).unwrap(); + connection.execute_batch( + "INSERT INTO consolidation_write_bindings VALUES('published-subjektiv-formats','job','attempt','subject','runtime','worker','input','grant','deadline','revoked');" + ).unwrap(); +} + +fn record(connection: &Connection, version: u32, name: &str) { + connection + .execute( + &format!("INSERT INTO {HISTORY_TABLE}(version,name) VALUES(?1,?2)"), + params![version, name], + ) + .unwrap(); +} + +fn install_published_fixture(connection: &Connection, format: Format) { + match format { + Format::Permission18 => { + permission_fixture(connection); + record(connection, 18, PERMISSION_18); + } + Format::Consolidation18 | Format::Consolidation20 => { + consolidation_fixture(connection); + record(connection, 18, CONSOLIDATION_18); + if format == Format::Consolidation20 { + scope_fixture(connection); + cancellation_fixture(connection); + record(connection, 20, CONVERGENCE_20); + } + } + Format::Fixed18 | Format::Cancellation19 | Format::Fixed20 | Format::Cancellation20 => { + scope_fixture(connection); + record(connection, 18, FIXED_18); + if format != Format::Fixed18 { + cancellation_fixture(connection); + if matches!(format, Format::Cancellation19 | Format::Cancellation20) { + record(connection, 19, CANCELLATION_19); + } + } + if matches!(format, Format::Fixed20 | Format::Cancellation20) { + consolidation_fixture(connection); + record(connection, 20, CONVERGENCE_20); + } + } + Format::Common(_) => unreachable!("common fixtures use their original registration"), + } +} + +fn published_fixture(root: &Path, format: Format) { + let (scope, database) = open( + root, + FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, &MIGRATIONS[..COMMON_COUNT]), + ); + let store = SubjektivStore::from_database(database.clone()).unwrap(); + store + .create_subject_with_behavior( + SubjectRole::new("retained").unwrap(), + "原文: retained\n".into(), + ) + .unwrap(); + database.transaction(|tx| { + // Permanent erasure evidence is deliberately not tied to a live Subject FK. + tx.execute_batch( + "INSERT INTO journal_erased_sessions VALUES('erased-session','erased-subject','erasure','erased-at'); + INSERT INTO subject_deletions VALUES('erased-subject','delete-request',1,'started-at'); + INSERT INTO subject_deleted_workers VALUES('erased-subject','old-runtime','old-worker'); + INSERT INTO subject_deleted_sessions VALUES('erased-subject','erased-session');" + )?; + install_published_fixture(tx, format); + mark_receipts(tx); + Ok(()) + }).unwrap(); + drop(store); + drop(database); + scope.shutdown().unwrap(); +} + +fn database_path(root: &Path) -> std::path::PathBuf { + root.join(SCOPE).join("features/subjektiv.sqlite") +} + +#[test] +fn published_families_reopen_without_changing_shapes_rows_or_original_receipts() { + for format in PUBLISHED { + let temp = tempfile::tempdir().unwrap(); + published_fixture(temp.path(), format); + let mut original = Connection::open(database_path(temp.path())).unwrap(); + assert_valid(&mut original); + assert_eq!(recognize(&history(&original)).unwrap(), format); + let saved_shape = domain_shape(&original).unwrap(); + let saved_rows = all_rows(&original); + let saved_receipts = receipts(&original); + drop(original); + + // This goes through the real registered migration engine, not a test-only planner. + // There is intentionally no new storage/domain migration or convergence receipt. + for _ in 0..2 { + let (scope, database) = open(temp.path(), REGISTRATION); + database + .with_connection(|connection| { + assert_eq!(domain_shape(connection)?, saved_shape, "{format:?}"); + assert_eq!(all_rows(connection), saved_rows, "{format:?}"); + assert_eq!(receipts(connection), saved_receipts, "{format:?}"); + assert_foreign_keys(connection); + Ok(()) + }) + .unwrap(); + drop(database); + scope.shutdown().unwrap(); + } + } +} + +#[test] +fn common_prefixes_migrate_to_fresh_shape_and_keep_saved_receipts_and_oldest_data() { + let fresh = tempfile::tempdir().unwrap(); + let (fresh_scope, fresh_db) = open(fresh.path(), REGISTRATION); + let fresh_shape = fresh_db.with_connection(domain_shape).unwrap(); + drop(fresh_db); + fresh_scope.shutdown().unwrap(); + + for count in 0..=COMMON_COUNT { + let temp = tempfile::tempdir().unwrap(); + let (old_scope, old_db) = open( + temp.path(), + FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, &MIGRATIONS[..count]), + ); + let original_json = serde_json::json!({ + "schema_version": 1, "id": "oldest", "role": "preserved", + "state": "active", "store_revision": 0, + "created_at": "created", "updated_at": "updated" + }) + .to_string(); + old_db.with_connection(|connection| { + if count > 0 { + connection.execute("INSERT INTO store_scope VALUES(1,?1)", [SCOPE])?; + } + if count == 1 { + connection.execute( + "INSERT INTO subjects VALUES('oldest','preserved','active',0,?1,'created','updated')", + [&original_json], + )?; + } + mark_receipts(connection); + Ok(()) + }).unwrap(); + let saved = old_db.with_connection(|c| Ok(receipts(c))).unwrap(); + drop(old_db); + old_scope.shutdown().unwrap(); + + let (scope, db) = open(temp.path(), REGISTRATION); + db.with_connection(|connection| { + assert_eq!(domain_shape(connection)?, fresh_shape, "prefix {count}"); + assert_eq!(&receipts(connection)[..count], saved.as_slice()); + assert_foreign_keys(connection); + if count > 0 { + assert_eq!( + connection + .query_row("SELECT workspace_id FROM store_scope", [], |row| row + .get::<_, String>(0))?, + SCOPE + ); + } + if count == 1 { + let archive: String = connection.query_row( + "SELECT row_json FROM legacy_record_archive WHERE source_table='subjects'", + [], + |row| row.get(0), + )?; + let archived: serde_json::Value = serde_json::from_str(&archive).unwrap(); + assert_eq!(archived["record_json"], original_json); + } + Ok(()) + }) + .unwrap(); + if count == 1 { + let store = SubjektivStore::from_database(db.clone()).unwrap(); + let subject = store.subject("oldest").unwrap().unwrap(); + assert_eq!(subject.role.as_str(), "preserved"); + } + drop(db); + scope.shutdown().unwrap(); + } +} + +#[test] +fn unknown_mixed_missing_receipts_and_shape_mismatches_refuse_without_mutation() { + let cases = [ + ( + Format::Permission18, + "UPDATE __yoi_feature_schema_migrations SET name='unknown' WHERE version=18", + ), + ( + Format::Permission18, + "DELETE FROM __yoi_feature_schema_migrations WHERE version=18", + ), + ( + Format::Permission18, + "DROP TRIGGER permission_sealed_subject_permissions_INSERT", + ), + ( + Format::Permission18, + "ALTER TABLE permission_authority_seal ADD COLUMN unexpected TEXT", + ), + ( + Format::Permission18, + "INSERT INTO __yoi_feature_schema_migrations(version,name) VALUES(20,'converge published Journal scope and consolidation write histories')", + ), + ( + Format::Consolidation18, + "UPDATE __yoi_feature_schema_migrations SET name='fixed Journal removal scopes' WHERE version=18", + ), + ( + Format::Consolidation18, + "INSERT INTO __yoi_feature_schema_migrations(version,name) VALUES(19,'retain exact Subject release scope cancellations')", + ), + ( + Format::Consolidation18, + "INSERT INTO __yoi_feature_schema_migrations(version,name) VALUES(20,'converge published Journal scope and consolidation write histories')", + ), + ( + Format::Consolidation18, + "DROP TRIGGER consolidation_write_binding_no_delete", + ), + (Format::Fixed18, "DROP TABLE journal_scope_release_receipts"), + ( + Format::Cancellation19, + "DELETE FROM __yoi_feature_schema_migrations WHERE version=19", + ), + ( + Format::Consolidation20, + "DELETE FROM __yoi_feature_schema_migrations WHERE version=18", + ), + ( + Format::Fixed20, + "UPDATE __yoi_feature_schema_migrations SET name='unknown provenance' WHERE version=20", + ), + ( + Format::Cancellation20, + "DELETE FROM __yoi_feature_schema_migrations WHERE version=3", + ), + ( + Format::Cancellation20, + "INSERT INTO __yoi_feature_schema_migrations(version,name) VALUES(21,'future')", + ), + ( + Format::Cancellation20, + "CREATE TABLE unknown_payload(secret BLOB)", + ), + ]; + for (format, corrupt) in cases { + let temp = tempfile::tempdir().unwrap(); + published_fixture(temp.path(), format); + let mut raw = Connection::open(database_path(temp.path())).unwrap(); + raw.execute_batch(corrupt).unwrap(); + // Snapshot AFTER deliberate corruption. Rejection must not repair or adopt it. + let saved_shape = domain_shape(&raw).unwrap(); + let saved_rows = all_rows(&raw); + let tx = raw.transaction().unwrap(); + let error = validate(&tx, &history(&tx)).unwrap_err(); + assert!( + matches!(error, FeatureStorageError::MigrationHistory { .. }), + "{format:?}: {corrupt}: {error}" + ); + drop(tx); + drop(raw); + + let scope = FeatureStorage::new(temp.path()).scope(SCOPE).unwrap(); + let registered = scope.register(REGISTRATION).unwrap(); + let result = scope.open(®istered); + assert!( + matches!(result, Err(FeatureStorageError::MigrationHistory { .. })), + "{format:?}: {corrupt}" + ); + scope.shutdown().unwrap(); + let raw = Connection::open(database_path(temp.path())).unwrap(); + assert_eq!(domain_shape(&raw).unwrap(), saved_shape); + assert_eq!(all_rows(&raw), saved_rows); + } +} + +#[test] +fn mixed_known_families_are_not_inferred_from_column_presence() { + let temp = tempfile::tempdir().unwrap(); + published_fixture(temp.path(), Format::Permission18); + let mut raw = Connection::open(database_path(temp.path())).unwrap(); + consolidation_fixture(&raw); + let before = all_rows(&raw); + let shape = domain_shape(&raw).unwrap(); + let tx = raw.transaction().unwrap(); + let error = validate(&tx, &history(&tx)).unwrap_err(); + assert!(error.to_string().contains("SQLite shape")); + drop(tx); + assert_eq!(all_rows(&raw), before); + assert_eq!(domain_shape(&raw).unwrap(), shape); + drop(raw); + let scope = FeatureStorage::new(temp.path()).scope(SCOPE).unwrap(); + let registered = scope.register(REGISTRATION).unwrap(); + assert!(matches!( + scope.open(®istered), + Err(FeatureStorageError::MigrationHistory { .. }) + )); + scope.shutdown().unwrap(); + let raw = Connection::open(database_path(temp.path())).unwrap(); + assert_eq!(all_rows(&raw), before); + assert_eq!(domain_shape(&raw).unwrap(), shape); +} + +#[test] +fn nonempty_schema_without_receipt_table_is_not_treated_as_new() { + let temp = tempfile::tempdir().unwrap(); + published_fixture(temp.path(), Format::Permission18); + let mut raw = Connection::open(database_path(temp.path())).unwrap(); + raw.execute_batch("DROP TABLE __yoi_feature_schema_migrations;") + .unwrap(); + let before = all_rows(&raw); + let shape = domain_shape(&raw).unwrap(); + let tx = raw.transaction().unwrap(); + assert!(matches!( + validate(&tx, &[]), + Err(FeatureStorageError::MigrationHistory { .. }) + )); + drop(tx); + drop(raw); + let scope = FeatureStorage::new(temp.path()).scope(SCOPE).unwrap(); + let registered = scope.register(REGISTRATION).unwrap(); + assert!(matches!( + scope.open(®istered), + Err(FeatureStorageError::MigrationHistory { .. }) + )); + scope.shutdown().unwrap(); + let raw = Connection::open(database_path(temp.path())).unwrap(); + // Even the engine's CREATE TABLE IF NOT EXISTS must roll back after rejection. + assert_eq!(all_rows(&raw), before); + assert_eq!(domain_shape(&raw).unwrap(), shape); +} + +#[test] +fn common_receipt_write_failure_rolls_back_in_sqlite_then_engine_retry_succeeds() { + let temp = tempfile::tempdir().unwrap(); + let (old_scope, old_db) = open( + temp.path(), + FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, &MIGRATIONS[..16]), + ); + old_db + .with_connection(|connection| { + connection.execute("INSERT INTO store_scope VALUES(1,?1)", [SCOPE])?; + mark_receipts(connection); + Ok(()) + }) + .unwrap(); + drop(old_db); + old_scope.shutdown().unwrap(); + let raw = Connection::open(database_path(temp.path())).unwrap(); + let before_shape = domain_shape(&raw).unwrap(); + let before_rows = all_rows(&raw); + let saved_receipts = receipts(&raw); + drop(raw); + + // Direct SQLite transaction coverage, not engine fault injection: public opens cannot + // install a TEMP trigger between validation and receipt insertion. Exercise the frozen + // migration plus receipt-write failure here, then retry through the actual engine. + let mut raw = Connection::open(database_path(temp.path())).unwrap(); + raw.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + raw.execute_batch( + "CREATE TEMP TRIGGER fail_receipt BEFORE INSERT ON main.__yoi_feature_schema_migrations + WHEN NEW.version=17 BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;", + ) + .unwrap(); + { + let tx = raw.transaction().unwrap(); + validate(&tx, &history(&tx)).unwrap(); + MIGRATIONS[16].apply(&tx).unwrap(); + assert!( + tx.execute( + &format!("INSERT INTO {HISTORY_TABLE}(version,name) VALUES(?1,?2)"), + params![MIGRATIONS[16].version(), MIGRATIONS[16].name()], + ) + .is_err() + ); + } + assert_eq!(domain_shape(&raw).unwrap(), before_shape); + assert_eq!(all_rows(&raw), before_rows); + drop(raw); + let (scope, db) = open(temp.path(), REGISTRATION); + db.with_connection(|connection| { + assert_eq!( + domain_shape(connection)?, + reference_shape(Format::Common(17))? + ); + assert_eq!(&receipts(connection)[..16], saved_receipts.as_slice()); + Ok(()) + }) + .unwrap(); + drop(db); + scope.shutdown().unwrap(); +} diff --git a/crates/subjektiv/src/storage_formats.rs b/crates/subjektiv/src/storage_formats.rs new file mode 100644 index 000000000..4e2d29dbd --- /dev/null +++ b/crates/subjektiv/src/storage_formats.rs @@ -0,0 +1,432 @@ +//! Physical compatibility with published Subjektiv stores, not adoption of domain authority. +//! +//! FeatureStorage calls `validate` in its migration transaction, before applying pending common +//! migrations. Published receipts and complete SQLite shapes must agree. Recognized extensions +//! stay physically intact: this module never installs a permission policy, transforms Job states, +//! or advances Journal scopes on the target. Those transitions belong to their domain owners. + +use crate::{MIGRATIONS, SUBJEKTIV_FEATURE_ID}; +use feature_storage::FeatureStorageError; +use rusqlite::{Connection, Transaction, params}; +use std::sync::OnceLock; + +const HISTORY_TABLE: &str = "__yoi_feature_schema_migrations"; +const COMMON_COUNT: usize = 17; +const PERMISSION_18: &str = + "seal legacy permission authority and retain creation enrollment defaults"; +const CONSOLIDATION_18: &str = "retain irreversible consolidation write bindings"; +const FIXED_18: &str = "fixed Journal removal scopes"; +const CANCELLATION_19: &str = "retain exact Subject release scope cancellations"; +const CONVERGENCE_20: &str = "converge published Journal scope and consolidation write histories"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Format { + Common(usize), + Permission18, + Consolidation18, + Fixed18, + Cancellation19, + // Equal final shapes do not make their receipt histories interchangeable. + Consolidation20, + Fixed20, + Cancellation20, +} + +fn incompatible(detail: impl Into) -> FeatureStorageError { + FeatureStorageError::MigrationHistory { + feature: SUBJEKTIV_FEATURE_ID.into(), + detail: detail.into(), + } +} + +fn recognize(applied: &[(u32, String)]) -> feature_storage::Result { + let count = applied.len().min(COMMON_COUNT); + if !applied[..count] + .iter() + .zip(&MIGRATIONS[..count]) + .all(|((version, name), expected)| { + *version == expected.version() && name == expected.name() + }) + { + return Err(incompatible( + "receipt prefix differs from published Subjektiv 1..17", + )); + } + if applied.len() <= COMMON_COUNT { + return Ok(Format::Common(count)); + } + let tail: Vec<_> = applied[COMMON_COUNT..] + .iter() + .map(|(version, name)| (*version, name.as_str())) + .collect(); + match tail.as_slice() { + [(18, PERMISSION_18)] => Ok(Format::Permission18), + [(18, CONSOLIDATION_18)] => Ok(Format::Consolidation18), + [(18, FIXED_18)] => Ok(Format::Fixed18), + [(18, FIXED_18), (19, CANCELLATION_19)] => Ok(Format::Cancellation19), + [(18, CONSOLIDATION_18), (20, CONVERGENCE_20)] => Ok(Format::Consolidation20), + [(18, FIXED_18), (20, CONVERGENCE_20)] => Ok(Format::Fixed20), + [(18, FIXED_18), (19, CANCELLATION_19), (20, CONVERGENCE_20)] => Ok(Format::Cancellation20), + _ => Err(incompatible( + "unknown, missing or mixed published Subjektiv receipt tail", + )), + } +} + +/// Validates physical compatibility only. The caller owns identity/adoption and the transaction. +/// Never relabel/delete original receipts, fill historical gaps, or append a domain receipt here. +/// After success the engine may apply only the pending registered common migrations (1..17). +pub(super) fn validate( + tx: &Transaction<'_>, + applied: &[(u32, String)], +) -> feature_storage::Result<()> { + let format = recognize(applied)?; + let expected = reference_shape(format)?; + let actual = domain_shape(tx)?; + if actual != expected { + let difference = actual + .iter() + .find(|object| !expected.contains(object)) + .or_else(|| expected.iter().find(|object| !actual.contains(object))); + return Err(incompatible(format!( + "published receipts disagree with SQLite shape at {}", + difference.map_or("unknown object", |object| object.1.as_str()) + ))); + } + Ok(()) +} + +// Compare tables, indexes, views, triggers and their full SQL (including constraints), not +// merely column presence. Exact physical-owner metadata is excluded, never a wildcard prefix. +type SchemaObject = (String, String, String, Option); + +fn domain_shape(connection: &Connection) -> feature_storage::Result> { + let mut statement = connection.prepare( + "SELECT type,name,tbl_name,sql FROM sqlite_schema + WHERE name NOT GLOB 'sqlite_*' + AND name NOT IN ('__yoi_feature_schema_migrations', '__yoi_storage_identity', '__yoi_storage_origins', '__yoi_feature_database_identity', '__yoi_feature_provisioning_receipt') + ORDER BY type,name", + )?; + let shape = statement + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::, _>>()?; + Ok(shape) +} + +fn reference_shape(format: Format) -> feature_storage::Result> { + // Cache reference shapes only; every target is reread under its caller's transaction. + static SHAPES: [OnceLock>; 25] = [const { OnceLock::new() }; 25]; + let index = match format { + Format::Common(count) => count, + Format::Permission18 => 18, + Format::Consolidation18 => 19, + Format::Fixed18 => 20, + Format::Cancellation19 => 21, + Format::Consolidation20 => 22, + Format::Fixed20 => 23, + Format::Cancellation20 => 24, + }; + if let Some(shape) = SHAPES[index].get() { + return Ok(shape.clone()); + } + let mut reference = Connection::open_in_memory()?; + reference.execute_batch("PRAGMA foreign_keys=ON;")?; + let tx = reference.transaction()?; + tx.execute_batch( + "CREATE TABLE __yoi_feature_schema_migrations ( + version INTEGER PRIMARY KEY CHECK(version>0), name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);", + )?; + let common = match format { + Format::Common(count) => count, + _ => COMMON_COUNT, + }; + for migration in &MIGRATIONS[..common] { + migration.apply(&tx)?; + tx.execute( + &format!("INSERT INTO {HISTORY_TABLE}(version,name) VALUES(?1,?2)"), + params![migration.version(), migration.name()], + )?; + } + // These frozen DDL fragments run ONLY on the disposable reference, never on a target. + // Preserve ALTER/rebuild provenance: sqlite_schema text can differ for equal columns. + match format { + Format::Common(_) => {} + Format::Permission18 => { + tx.execute_batch(PERMISSION_SEAL_SQL)?; + for table in PERMISSION_FENCE_TABLES { + for action in ["INSERT", "UPDATE"] { + tx.execute_batch(&format!( + "CREATE TRIGGER permission_sealed_{table}_{action} BEFORE {action} ON {table} + WHEN EXISTS(SELECT 1 FROM permission_authority_seal) + BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;" + ))?; + } + tx.execute_batch(&format!( + "CREATE TRIGGER permission_sealed_{table}_DELETE BEFORE DELETE ON {table} + WHEN EXISTS(SELECT 1 FROM permission_authority_seal) + AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id) + BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;" + ))?; + } + } + Format::Consolidation18 => tx.execute_batch(CONSOLIDATION_SQL)?, + Format::Fixed18 => tx.execute_batch(FIXED_SCOPE_SQL)?, + Format::Cancellation19 => { + tx.execute_batch(FIXED_SCOPE_SQL)?; + tx.execute_batch(CANCELLATION_SQL)?; + } + Format::Consolidation20 | Format::Fixed20 | Format::Cancellation20 => { + tx.execute_batch(FIXED_SCOPE_SQL)?; + tx.execute_batch(CANCELLATION_SQL)?; + tx.execute_batch(CONSOLIDATION_SQL)?; + } + } + let shape = domain_shape(&tx)?; + let _ = SHAPES[index].set(shape.clone()); + Ok(shape) +} + +const PERMISSION_FENCE_TABLES: [&str; 4] = [ + "subject_permissions", + "subject_permission_changes", + "subject_permission_receipts", + "worker_execution_grants", +]; + +// Frozen physical shape evidence, not target migrations. Permission SQL is from +// 9edebfa5936f2411adb6affffc793d3e93cc21bd:crates/subjektiv/src/permissions/authority.rs. +// Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03: +// crates/subjektiv/src/{consolidation_write_bindings.sql,journal/final_scope.sql, +// journal/final_scope/cancellation.sql}. Receipt provenance follows that ref's recognizer; +// no permission-seal -> 20 history is published there. + +const PERMISSION_SEAL_SQL: &str = r#"CREATE TABLE permission_authority_seal ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), + transfer_id TEXT NOT NULL CHECK(length(transfer_id)>0), + export_json TEXT NOT NULL + ); + CREATE TRIGGER permission_authority_seal_immutable BEFORE UPDATE ON permission_authority_seal + BEGIN SELECT RAISE(ABORT, 'permission authority seal is immutable'); END; + CREATE TRIGGER permission_authority_seal_retained BEFORE DELETE ON permission_authority_seal + BEGIN SELECT RAISE(ABORT, 'permission authority seal is permanent'); END; + ALTER TABLE subject_creation_receipts ADD COLUMN permission_enrollment_json TEXT; + CREATE TRIGGER subject_creation_receipts_immutable BEFORE UPDATE ON subject_creation_receipts + BEGIN SELECT RAISE(ABORT, 'Subject creation receipts are immutable'); END; + CREATE TRIGGER subject_creation_receipts_retained BEFORE DELETE ON subject_creation_receipts + BEGIN SELECT RAISE(ABORT, 'Subject creation receipts are retained'); END;"#; + +const CONSOLIDATION_SQL: &str = r#"-- Domain write fence only: no Job runner state and no Subject content. +-- Deliberately no Subject foreign key: erasure must retain this identity fence. +CREATE TABLE consolidation_write_bindings ( + scope_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + input_digest TEXT NOT NULL, + grant_digest TEXT NOT NULL, + deadline_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (scope_id, job_id, attempt_id), + CHECK (revoked_at IS NULL OR length(revoked_at) > 0) +); + +-- BEFORE INSERT also catches INSERT OR REPLACE, even with recursive_triggers off. +CREATE TRIGGER consolidation_write_binding_no_replace +BEFORE INSERT ON consolidation_write_bindings +WHEN EXISTS (SELECT 1 FROM consolidation_write_bindings + WHERE scope_id = NEW.scope_id AND job_id = NEW.job_id AND attempt_id = NEW.attempt_id) +BEGIN + SELECT RAISE(ABORT, 'consolidation write binding already exists'); +END; + +CREATE TRIGGER consolidation_write_binding_immutable +BEFORE UPDATE ON consolidation_write_bindings +WHEN NEW.scope_id IS NOT OLD.scope_id + OR NEW.job_id IS NOT OLD.job_id OR NEW.attempt_id IS NOT OLD.attempt_id + OR NEW.subject_id IS NOT OLD.subject_id OR NEW.runtime_id IS NOT OLD.runtime_id + OR NEW.worker_id IS NOT OLD.worker_id OR NEW.input_digest IS NOT OLD.input_digest + OR NEW.grant_digest IS NOT OLD.grant_digest OR NEW.deadline_at IS NOT OLD.deadline_at + OR (OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NOT OLD.revoked_at) +BEGIN + SELECT RAISE(ABORT, 'consolidation write binding is immutable or revoked'); +END; + +CREATE TRIGGER consolidation_write_binding_no_delete +BEFORE DELETE ON consolidation_write_bindings +BEGIN + SELECT RAISE(ABORT, 'consolidation write binding must be retained'); +END; +"#; + +const FIXED_SCOPE_SQL: &str = r#"-- Fixed removal scope metadata only; no source bodies. +-- No FK to live Subject/writer: retained identity evidence cannot block explicit deletion. +-- Every API rechecks the live Subject, exact binding and permanent erase fences. +CREATE TABLE journal_final_scopes ( + request_id TEXT PRIMARY KEY, + removal_operation_id TEXT NOT NULL UNIQUE, + binding_id TEXT NOT NULL UNIQUE, + owner_json TEXT NOT NULL, + member_digest TEXT NOT NULL, + member_count INTEGER NOT NULL CHECK(member_count >= 0), + collected_count INTEGER NOT NULL DEFAULT 0 CHECK(collected_count >= 0 AND collected_count <= member_count), + after_session_id TEXT, + scope_digest TEXT +); +CREATE TRIGGER journal_final_scopes_guard BEFORE UPDATE ON journal_final_scopes +WHEN OLD.scope_digest IS NOT NULL OR NEW.request_id != OLD.request_id + OR NEW.removal_operation_id != OLD.removal_operation_id OR NEW.binding_id != OLD.binding_id + OR NEW.owner_json != OLD.owner_json OR NEW.member_digest != OLD.member_digest + OR NEW.member_count != OLD.member_count OR NEW.collected_count < OLD.collected_count + OR (NEW.scope_digest IS NOT NULL AND NEW.collected_count != NEW.member_count) +BEGIN SELECT RAISE(ABORT, 'fixed Journal scope owner and frozen header are immutable'); END; +CREATE TRIGGER journal_final_scopes_no_delete BEFORE DELETE ON journal_final_scopes +BEGIN SELECT RAISE(ABORT, 'fixed Journal scopes are retained'); END; +-- One normalized permanent metadata row per collected Session. No parallel full-scope JSON. +CREATE TABLE journal_final_scope_sessions ( + request_id TEXT NOT NULL REFERENCES journal_final_scopes(request_id) ON DELETE RESTRICT, + session_id TEXT NOT NULL, + metadata_json TEXT NOT NULL, + PRIMARY KEY(request_id,session_id) +); +CREATE TRIGGER journal_final_scope_sessions_frozen_insert BEFORE INSERT ON journal_final_scope_sessions +WHEN (SELECT scope_digest FROM journal_final_scopes WHERE request_id=NEW.request_id) IS NOT NULL +BEGIN SELECT RAISE(ABORT, 'frozen Session set is immutable'); END; +CREATE TRIGGER journal_final_scope_sessions_no_update BEFORE UPDATE ON journal_final_scope_sessions +BEGIN SELECT RAISE(ABORT, 'fixed Session metadata is immutable'); END; +CREATE TRIGGER journal_final_scope_sessions_no_delete BEFORE DELETE ON journal_final_scope_sessions +BEGIN SELECT RAISE(ABORT, 'fixed Session metadata is retained'); END; +-- Page receipts contain identities only; replay metadata is read from normalized rows. +CREATE TABLE journal_final_scope_pages ( + request_id TEXT NOT NULL REFERENCES journal_final_scopes(request_id) ON DELETE RESTRICT, + after_session_id TEXT NOT NULL, + through_session_id TEXT NOT NULL, + page_digest TEXT NOT NULL, + PRIMARY KEY(request_id,after_session_id) +); +CREATE TRIGGER journal_final_scope_pages_no_update BEFORE UPDATE ON journal_final_scope_pages +BEGIN SELECT RAISE(ABORT, 'scope page receipts are immutable'); END; +CREATE TRIGGER journal_final_scope_pages_no_delete BEFORE DELETE ON journal_final_scope_pages +BEGIN SELECT RAISE(ABORT, 'scope page receipts are retained'); END; +CREATE TABLE journal_scope_release_receipts ( + request_id TEXT PRIMARY KEY REFERENCES journal_final_scopes(request_id) ON DELETE RESTRICT, + receipt_json TEXT NOT NULL +); +CREATE TRIGGER journal_scope_release_no_update BEFORE UPDATE ON journal_scope_release_receipts +BEGIN SELECT RAISE(ABORT, 'Journal release receipts are immutable'); END; +CREATE TRIGGER journal_scope_release_no_delete BEFORE DELETE ON journal_scope_release_receipts +BEGIN SELECT RAISE(ABORT, 'Journal release receipts are retained'); END; +"#; + +const CANCELLATION_SQL: &str = r#"-- Migration 19 replaces only migration-18's unconditional binding UNIQUE with a live-owner +-- constraint. Transaction-local copies preserve every column/row. Published 18 is unchanged. +-- No Journal history, body input or checkpoint is removed or rewritten. +CREATE TEMP TABLE scope_cancel_copy_journal_final_scopes AS SELECT * FROM journal_final_scopes; +CREATE TEMP TABLE scope_cancel_copy_journal_final_scope_sessions AS SELECT * FROM journal_final_scope_sessions; +CREATE TEMP TABLE scope_cancel_copy_journal_final_scope_pages AS SELECT * FROM journal_final_scope_pages; +CREATE TEMP TABLE scope_cancel_copy_journal_scope_release_receipts AS SELECT * FROM journal_scope_release_receipts; +DROP TABLE journal_scope_release_receipts; +DROP TABLE journal_final_scope_pages; +DROP TABLE journal_final_scope_sessions; +DROP TABLE journal_final_scopes; +CREATE TABLE journal_final_scopes ( + request_id TEXT PRIMARY KEY, + removal_operation_id TEXT NOT NULL UNIQUE, + binding_id TEXT NOT NULL, + owner_json TEXT NOT NULL, + member_digest TEXT NOT NULL, + member_count INTEGER NOT NULL CHECK(member_count >= 0), + collected_count INTEGER NOT NULL DEFAULT 0 CHECK(collected_count >= 0 AND collected_count <= member_count), + after_session_id TEXT, + scope_digest TEXT +); +CREATE TABLE journal_final_scope_sessions ( + request_id TEXT NOT NULL REFERENCES journal_final_scopes(request_id) ON DELETE RESTRICT, + session_id TEXT NOT NULL, + metadata_json TEXT NOT NULL, + PRIMARY KEY(request_id,session_id) +); +CREATE TABLE journal_final_scope_pages ( + request_id TEXT NOT NULL REFERENCES journal_final_scopes(request_id) ON DELETE RESTRICT, + after_session_id TEXT NOT NULL, + through_session_id TEXT NOT NULL, + page_digest TEXT NOT NULL, + PRIMARY KEY(request_id,after_session_id) +); +CREATE TABLE journal_scope_release_receipts ( + request_id TEXT PRIMARY KEY REFERENCES journal_final_scopes(request_id) ON DELETE RESTRICT, + receipt_json TEXT NOT NULL +); +INSERT INTO journal_final_scopes SELECT * FROM scope_cancel_copy_journal_final_scopes; +DROP TABLE scope_cancel_copy_journal_final_scopes; +INSERT INTO journal_final_scope_sessions SELECT * FROM scope_cancel_copy_journal_final_scope_sessions; +DROP TABLE scope_cancel_copy_journal_final_scope_sessions; +INSERT INTO journal_final_scope_pages SELECT * FROM scope_cancel_copy_journal_final_scope_pages; +DROP TABLE scope_cancel_copy_journal_final_scope_pages; +INSERT INTO journal_scope_release_receipts SELECT * FROM scope_cancel_copy_journal_scope_release_receipts; +DROP TABLE scope_cancel_copy_journal_scope_release_receipts; +CREATE TRIGGER journal_final_scopes_guard BEFORE UPDATE ON journal_final_scopes +WHEN OLD.scope_digest IS NOT NULL OR NEW.request_id != OLD.request_id + OR NEW.removal_operation_id != OLD.removal_operation_id OR NEW.binding_id != OLD.binding_id + OR NEW.owner_json != OLD.owner_json OR NEW.member_digest != OLD.member_digest + OR NEW.member_count != OLD.member_count OR NEW.collected_count < OLD.collected_count + OR (NEW.scope_digest IS NOT NULL AND NEW.collected_count != NEW.member_count) +BEGIN SELECT RAISE(ABORT, 'fixed Journal scope owner and frozen header are immutable'); END; +CREATE TRIGGER journal_final_scopes_no_delete BEFORE DELETE ON journal_final_scopes +BEGIN SELECT RAISE(ABORT, 'fixed Journal scopes are retained'); END; +CREATE TRIGGER journal_final_scope_sessions_frozen_insert BEFORE INSERT ON journal_final_scope_sessions +WHEN (SELECT scope_digest FROM journal_final_scopes WHERE request_id=NEW.request_id) IS NOT NULL +BEGIN SELECT RAISE(ABORT, 'frozen Session set is immutable'); END; +CREATE TRIGGER journal_final_scope_sessions_no_update BEFORE UPDATE ON journal_final_scope_sessions +BEGIN SELECT RAISE(ABORT, 'fixed Session metadata is immutable'); END; +CREATE TRIGGER journal_final_scope_sessions_no_delete BEFORE DELETE ON journal_final_scope_sessions +BEGIN SELECT RAISE(ABORT, 'fixed Session metadata is retained'); END; +CREATE TRIGGER journal_final_scope_pages_no_update BEFORE UPDATE ON journal_final_scope_pages +BEGIN SELECT RAISE(ABORT, 'scope page receipts are immutable'); END; +CREATE TRIGGER journal_final_scope_pages_no_delete BEFORE DELETE ON journal_final_scope_pages +BEGIN SELECT RAISE(ABORT, 'scope page receipts are retained'); END; +CREATE TRIGGER journal_scope_release_no_update BEFORE UPDATE ON journal_scope_release_receipts +BEGIN SELECT RAISE(ABORT, 'Journal release receipts are immutable'); END; +CREATE TRIGGER journal_scope_release_no_delete BEFORE DELETE ON journal_scope_release_receipts +BEGIN SELECT RAISE(ABORT, 'Journal release receipts are retained'); END; +-- One immutable cancellation fact with its parent identity, including before collection. +CREATE TABLE journal_scope_cancellations ( + request_id TEXT PRIMARY KEY, + owner_json TEXT NOT NULL +); +CREATE TRIGGER journal_scope_cancellations_no_update BEFORE UPDATE ON journal_scope_cancellations +BEGIN SELECT RAISE(ABORT, 'scope cancellation is immutable'); END; +CREATE TRIGGER journal_scope_cancellations_no_delete BEFORE DELETE ON journal_scope_cancellations +BEGIN SELECT RAISE(ABORT, 'scope cancellation is retained'); END; +CREATE TRIGGER journal_scope_cancellation_guard BEFORE INSERT ON journal_scope_cancellations +WHEN NEW.request_id != 'subject-release:' || json_extract(NEW.owner_json,'$.removal_operation_id') + OR NEW.request_id != json_extract(NEW.owner_json,'$.request_id') + OR EXISTS(SELECT 1 FROM journal_scope_release_receipts WHERE request_id=NEW.request_id) + OR EXISTS(SELECT 1 FROM journal_final_scopes s WHERE s.request_id=NEW.request_id AND s.owner_json!=NEW.owner_json) +BEGIN SELECT RAISE(ABORT, 'only exact unreleased Subject release scope can be cancelled'); END; +CREATE TRIGGER journal_final_scopes_live_owner BEFORE INSERT ON journal_final_scopes +WHEN EXISTS(SELECT 1 FROM journal_scope_cancellations c WHERE c.request_id=NEW.request_id) + OR EXISTS(SELECT 1 FROM journal_final_scopes s WHERE s.binding_id=NEW.binding_id + AND NOT EXISTS(SELECT 1 FROM journal_scope_cancellations c WHERE c.request_id=s.request_id)) +BEGIN SELECT RAISE(ABORT, 'fixed scope writer is occupied or request cancelled'); END; +CREATE TRIGGER journal_scope_release_not_cancelled BEFORE INSERT ON journal_scope_release_receipts +WHEN EXISTS(SELECT 1 FROM journal_scope_cancellations c WHERE c.request_id=NEW.request_id) +BEGIN SELECT RAISE(ABORT, 'cancelled scope cannot release'); END; +CREATE TRIGGER journal_final_scope_cancelled_update BEFORE UPDATE ON journal_final_scopes +WHEN EXISTS(SELECT 1 FROM journal_scope_cancellations c WHERE c.request_id=OLD.request_id) +BEGIN SELECT RAISE(ABORT, 'cancelled scope cannot continue'); END; +CREATE TRIGGER journal_final_scope_cancelled_session BEFORE INSERT ON journal_final_scope_sessions +WHEN EXISTS(SELECT 1 FROM journal_scope_cancellations c WHERE c.request_id=NEW.request_id) +BEGIN SELECT RAISE(ABORT, 'cancelled scope cannot continue'); END; +CREATE TRIGGER journal_final_scope_cancelled_page BEFORE INSERT ON journal_final_scope_pages +WHEN EXISTS(SELECT 1 FROM journal_scope_cancellations c WHERE c.request_id=NEW.request_id) +BEGIN SELECT RAISE(ABORT, 'cancelled scope cannot continue'); END; +"#; + +#[cfg(test)] +#[path = "storage_format_tests.rs"] +mod tests; diff --git a/crates/workspace-drive/tests/storage.rs b/crates/workspace-drive/tests/storage.rs index 2bf55d28b..7e83c7064 100644 --- a/crates/workspace-drive/tests/storage.rs +++ b/crates/workspace-drive/tests/storage.rs @@ -473,8 +473,9 @@ fn stopped_writer_backup_restores_ids_last_mutation_ids_receipts_and_sequence_wi std::fs::copy(file.path(), restored_blobs.join(file.file_name())).unwrap(); } let restored = FeatureStorage::new(t.path().join("restored/metadata")); + let source_scope = scope; let scope = restored.workspace("ws").unwrap(); - scope.restore(&snapshot).unwrap(); + scope.restore_from(&snapshot, &source_scope).unwrap(); let registered = Drive::register(&scope).unwrap(); let d = Drive::open(&scope, ®istered, &restored_blobs).unwrap(); assert_eq!(d.metadata(latest.id).unwrap(), latest); diff --git a/crates/workspace-server/src/frozen_repository_key_tests.rs b/crates/workspace-server/src/frozen_repository_key_tests.rs index 798f1575d..a730da617 100644 --- a/crates/workspace-server/src/frozen_repository_key_tests.rs +++ b/crates/workspace-server/src/frozen_repository_key_tests.rs @@ -470,7 +470,7 @@ fn migrated_workdir_request_replays_and_retries_without_accepting_changed_displa rename_saved_secret_receipt_counters(&conn).unwrap(); let store = SqliteWorkspaceStore { db_executor: crate::db_executor::DbExecutor::shared(), - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), }; let migrated = store .load_workdir_create_operation("space", "create-workdir") @@ -654,7 +654,7 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr rename_saved_secret_receipt_counters(&conn).unwrap(); let store = Arc::new(SqliteWorkspaceStore { db_executor: crate::db_executor::DbExecutor::shared(), - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), }); let archived = store .load_workdir_create_legacy_ssh_archive("space", "create-workdir") @@ -1030,7 +1030,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei rename_saved_secret_receipt_counters(&conn).unwrap(); let store = Arc::new(SqliteWorkspaceStore { db_executor: crate::db_executor::DbExecutor::shared(), - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), }); let service = crate::repository_access::RepositorySecretService::open(store.clone(), &database).unwrap(); @@ -1183,7 +1183,7 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() { rename_saved_secret_receipt_counters(&conn).unwrap(); let store = Arc::new(SqliteWorkspaceStore { db_executor: crate::db_executor::DbExecutor::shared(), - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), }); let service = crate::repository_access::RepositorySecretService::open(store.clone(), &database).unwrap(); diff --git a/crates/workspace-server/src/frozen_runtime_binding_tests.rs b/crates/workspace-server/src/frozen_runtime_binding_tests.rs index 9c8bef71f..02e436a50 100644 --- a/crates/workspace-server/src/frozen_runtime_binding_tests.rs +++ b/crates/workspace-server/src/frozen_runtime_binding_tests.rs @@ -534,7 +534,7 @@ mod existing_workspace_provisioning { // unrelated full Server schema. Exercise the real service/store methods. Arc::new(SqliteWorkspaceStore { db_executor: crate::db_executor::DbExecutor::shared(), - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), }) } @@ -646,7 +646,7 @@ mod existing_workspace_provisioning { drop(store); let store = Arc::new(SqliteWorkspaceStore { db_executor: crate::db_executor::DbExecutor::shared(), - conn: Arc::new(Mutex::new(Connection::open(&path).unwrap())), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(Connection::open(&path).unwrap()))), }); let service = WorkspaceSigningIdentityService::new( store.clone(), diff --git a/crates/workspace-server/src/implementation_result_tests.rs b/crates/workspace-server/src/implementation_result_tests.rs index 6d25647ef..1b8630c5a 100644 --- a/crates/workspace-server/src/implementation_result_tests.rs +++ b/crates/workspace-server/src/implementation_result_tests.rs @@ -530,7 +530,7 @@ fn implementation_legacy96_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w','workspace-orchestrator','r','worker',1,'1','1'); INSERT INTO repositories(workspace_id,repository_id,repository_key,kind,provider,uri,created_at,updated_at,source_kind,source_uri,source_fingerprint) VALUES('w','main','main','git','git','file:///main','1','1','file','file:///main','sha256:fixture');").unwrap(); let store = SqliteWorkspaceStore { - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), db_executor: crate::db_executor::DbExecutor::new( crate::db_executor::DbExecutorLimits::default(), ) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5750a0cda..502a02eec 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4661,6 +4661,7 @@ impl WorkspaceApi { } let database_path = config.database_path.clone(); let workspace_id = config.workspace_id.clone(); + let storage_catalog = config_store.clone(); let (feature_storage, subjektiv_registration, authority) = store .db_executor() .run(move || { @@ -4673,12 +4674,26 @@ impl WorkspaceApi { .map_err(|error| { Error::Store(format!("failed to register subjektiv storage: {error}")) })?; + storage_catalog.adopt_workspace_feature_storage( + &feature_storage, + ®istration, + "store_scope", + )?; let authority = database_preparation.open_authority(database_path, workspace_id)?; Ok::<_, Error>((feature_storage, registration, authority)) }) .await?; let drive_host = Arc::new(crate::workspace_drive_host::WorkspaceDriveHost::new(&feature_storage).await?); + { + let catalog = config_store.clone(); + let scope = feature_storage.clone(); + let host = drive_host.clone(); + store + .db_executor() + .run(move || host.adopt_legacy_storage(&scope, &catalog)) + .await?; + } let mut api = Self { host_executors, drive_host, diff --git a/crates/workspace-server/src/server_plugin_authority_storage_tests.rs b/crates/workspace-server/src/server_plugin_authority_storage_tests.rs index 072533214..e5508ad20 100644 --- a/crates/workspace-server/src/server_plugin_authority_storage_tests.rs +++ b/crates/workspace-server/src/server_plugin_authority_storage_tests.rs @@ -104,7 +104,7 @@ async fn scoped_subject_database_rereads_policy_changed_on_independent_connectio } #[tokio::test] -async fn scoped_subject_database_does_not_survive_deletion_or_refresh_itself_after_restore() { +async fn scoped_subject_database_observes_irreversible_erasure_after_check_finishes() { let f = Fixture::new(None).await; let snapshot_dir = tempfile::tempdir().unwrap(); let snapshot = snapshot_dir.path().join("snapshot"); @@ -112,25 +112,19 @@ async fn scoped_subject_database_does_not_survive_deletion_or_refresh_itself_aft let request = permission(&f); plugin_host::with_authority_database(&f.api, || { subject_authorization::authorize_scope(&f.api, &f.source, &request).unwrap(); - // The guard retains no connection lock: deletion completes synchronously. - f.api.feature_storage.delete().unwrap(); - assert!(subject_authorization::authorize_scope(&f.api, &f.source, &request).is_err()); - assert!(f.api.feature_storage.restore(&snapshot).is_err()); - let fresh = - crate::FeatureStorage::for_server_database(&f.api.config.database_path).unwrap(); - let scope = fresh.workspace(TEST_WORKSPACE_ID).unwrap(); - scope.restore(&snapshot).unwrap(); - let registration = crate::subjektiv::SubjektivStore::register(&scope).unwrap(); - let mut restored = f.api.clone(); - restored.feature_storage = scope; - restored.subjektiv_registration = registration; - // A fresh Host binding can read the restored DB; the old binding stays fenced. - subject_authorization::authorize_scope(&restored, &f.source, &request).unwrap(); - assert!(subject_authorization::authorize_scope(&f.api, &f.source, &request).is_err()); - restored.feature_storage.shutdown().unwrap(); Ok(()) - }) - .unwrap(); + }).unwrap(); + // The short check's lifecycle guard is released before destructive maintenance. + f.api.feature_storage.delete().unwrap(); + assert!(subject_authorization::authorize_scope(&f.api, &f.source, &request).is_err()); + assert!(f.api.feature_storage.restore(&snapshot).is_err()); + let fresh = crate::FeatureStorage::for_server_database(&f.api.config.database_path).unwrap(); + let scope = fresh.workspace(TEST_WORKSPACE_ID).unwrap(); + assert!(scope.restore(&snapshot).is_err()); + let another = crate::FeatureStorage::new(snapshot_dir.path().join("another-owner")); + assert!(another.workspace(TEST_WORKSPACE_ID).unwrap() + .restore_from(&snapshot, &scope).is_err()); + assert!(subject_authorization::authorize_scope(&f.api, &f.source, &request).is_err()); f.shutdown().await; } diff --git a/crates/workspace-server/src/server_plugin_host.rs b/crates/workspace-server/src/server_plugin_host.rs index c8b918b05..f00ca4740 100644 --- a/crates/workspace-server/src/server_plugin_host.rs +++ b/crates/workspace-server/src/server_plugin_host.rs @@ -141,10 +141,13 @@ pub(super) fn with_authority_database( api: &WorkspaceApi, check: impl FnOnce() -> crate::Result, ) -> crate::Result { - let _database = api + let database = api .feature_storage .open(&api.subjektiv_registration) .map_err(|error| Error::Store(error.to_string()))?; + let _guard = database + .guard(database.identity()) + .map_err(|error| Error::Store(error.to_string()))?; check() } diff --git a/crates/workspace-server/src/storage_format_tests.rs b/crates/workspace-server/src/storage_format_tests.rs new file mode 100644 index 000000000..762839881 --- /dev/null +++ b/crates/workspace-server/src/storage_format_tests.rs @@ -0,0 +1,362 @@ +//! Storage component tests: real retained migration functions and published SQL +//! on isolated SQLite databases; no authority, Runtime or domain recovery fakes. +//! The normal workspace-server lib test target runs this module. These fixtures +//! attest physical acceptance/data retention, not policy transfer or Job recovery. +use super::*; +use rusqlite::{backup::Backup, params}; + +fn save_history( + conn: &Connection, + stage: Stage, + baseline: i64, + predecessor: Option<(Family, usize)>, +) { + conn.execute("DELETE FROM __yoi_schema_migrations", []) + .unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", + params![baseline, super::super::SCHEMA_BASELINE_NAME], + ) + .unwrap(); + let published = match stage { + Stage::Published(family, count) => Some((family, count)), + _ => predecessor, + }; + if let Some((family, count)) = published { + for (index, name) in family.names().iter().take(count).enumerate() { + let version = 114 + index as i64; + if version > baseline { + conn.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", + params![version, name], + ) + .unwrap(); + } + } + } + if let Stage::Converged(current) = stage { + for (version, name) in [ + (119, CONVERGED), + (120, PRIOR_RELEASE), + (121, CLEANUP_COVERAGE), + ] { + if version > baseline && version <= current { + conn.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", + params![version, name], + ) + .unwrap(); + } + } + } +} + +fn fixture(stage: Stage, baseline: i64, predecessor: Option<(Family, usize)>) -> Connection { + let conn = reference_base().unwrap(); + install_reference(&conn, stage).unwrap(); + save_history(&conn, stage, baseline, predecessor); + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) + VALUES('owner','user','owner','Owner','1','1'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) + VALUES('workspace-a','owner','Saved workspace','active','1','1'); + INSERT INTO backend_jobs(workspace_id,job_id,purpose,input_digest,input_ref,request_json,intent_fingerprint,state,current_attempt,created_at,updated_at,input_disposition) + VALUES('workspace-a','saved-job','journal','digest','input','{}','fingerprint','pending',1,'1','1','erased'); + INSERT INTO backend_job_attempts(workspace_id,job_id,attempt_id,attempt,input_digest,state,deadline_at,created_at,updated_at) + VALUES('workspace-a','saved-job','saved-attempt',1,'digest','reserved','2','1','1'); + INSERT INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_updated_at,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,diagnostics_disposition,blockers_json,state,reason,created_at,updated_at) + VALUES('saved-removal','saved-plan','saved-fingerprint','workspace-a','runtime','worker','1','policy','digest','erase','erase','none','erase','[]','succeeded','saved','1','1');").unwrap(); + if super::super::table_exists(&conn, "permission_policy_receipts").unwrap() { + conn.execute_batch("INSERT INTO permission_policy_receipts(workspace_id,operation_id,fingerprint,result_json) + VALUES('workspace-a','saved-permission','saved-fingerprint','{\"saved\":true}');").unwrap(); + } + if super::super::table_exists(&conn, "server_feature_root").unwrap() { + conn.execute_batch("INSERT INTO server_feature_root(root_slot,reservation_id,request_fingerprint,state,accepted_receipt) + VALUES(1,'saved-reservation','saved-fingerprint','accepted','saved-root-receipt'); + INSERT INTO content_history_targets(workspace,target,head,erased) + VALUES('workspace-a','workspace-config',NULL,1);").unwrap(); + } + conn +} + +fn saved_rows(conn: &Connection) -> Vec<(String, Vec)> { + let names = conn.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").unwrap() + .query_map([], |row| row.get::<_, String>(0)).unwrap() + .collect::>>().unwrap(); + names + .into_iter() + .map(|name| { + let mut statement = conn + .prepare(&format!("SELECT * FROM \"{}\"", name.replace('"', "\"\""))) + .unwrap(); + let count = statement.column_count(); + let mut rows = statement + .query_map([], |row| { + (0..count) + .map(|index| row.get::<_, rusqlite::types::Value>(index)) + .collect::>>() + }) + .unwrap() + .map(|row| format!("{:?}", row.unwrap())) + .collect::>(); + rows.sort(); + (name, rows) + }) + .collect() +} + +fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) { + let rows = saved_rows(conn); + let shape = snapshot(conn).unwrap(); + verify_history(conn, current).unwrap(); + if current >= 114 { + let plan = published_plan(conn, current).unwrap().unwrap(); + assert!(plan.migrations.is_empty()); + assert_eq!(plan.target_schema_version, current); + } + assert_eq!(rows, saved_rows(conn)); + assert_eq!(shape, snapshot(conn).unwrap()); +} + +#[test] +fn each_published_receipt_prefix_and_fresh_branch_baseline_requires_its_real_shape() { + for family in FAMILIES { + for count in 1..=family.names().len() { + let stage = Stage::Published(family, count); + let current = 113 + count as i64; + // A baseline can start at any retained branch stage, not just 113. + for baseline in 113..=current { + let conn = fixture(stage, baseline, None); + assert_accepted_without_domain_changes(&conn, current); + } + } + } +} + +#[test] +fn converged_shapes_keep_all_real_predecessor_receipts_and_erasure_fences() { + let origins = std::iter::once(None).chain( + FAMILIES + .into_iter() + .flat_map(|family| (1..=family.names().len()).map(move |count| Some((family, count)))), + ); + for origin in origins { + for current in 119..=121 { + let conn = fixture(Stage::Converged(current), 113, origin); + assert_accepted_without_domain_changes(&conn, current); + let erased: i64 = conn + .query_row("SELECT erased FROM content_history_targets", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(erased, 1); + // A physical check must not act as the Job owner's new Failed migration. + let state: String = conn + .query_row("SELECT state FROM backend_jobs", [], |row| row.get(0)) + .unwrap(); + assert_eq!(state, "pending"); + } + } + for current in 119..=121 { + for baseline in 119..=current { + assert_accepted_without_domain_changes( + &fixture(Stage::Converged(current), baseline, None), + current, + ); + } + } +} + +#[test] +fn published_database_runs_normal_store_preparation_and_reopens_without_downgrade() { + for stage in [ + Stage::Published(Family::Permissions, 2), + Stage::Published(Family::Removal, 4), + Stage::Published(Family::Jobs, 5), + Stage::Converged(119), + Stage::Converged(120), + Stage::Converged(121), + ] { + let current = match stage { + Stage::Published(_, count) => 113 + count as i64, + Stage::Converged(version) => version, + Stage::Base => 113, + }; + let source = fixture(stage, 113, None); + let expected = saved_rows(&source); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("catalog.db"); + let mut destination = Connection::open(&path).unwrap(); + Backup::new(&source, &mut destination) + .unwrap() + .run_to_completion(128, std::time::Duration::ZERO, None) + .unwrap(); + drop(destination); + for _ in 0..2 { + let conn = Connection::open(&path).unwrap(); + // Production store owner, not a separate test migration implementation. + super::super::prepare_connection(&conn).unwrap(); + assert_eq!( + super::super::current_schema_version(&conn).unwrap(), + current + ); + assert_eq!(saved_rows(&conn), expected); + } + } +} + +#[test] +fn unknown_mixed_missing_receipts_and_shapes_are_diagnosed_without_writes() { + for (sql, current) in [ + ( + "UPDATE __yoi_schema_migrations SET name='unknown receipt' WHERE version=115", + 118, + ), + ("DELETE FROM __yoi_schema_migrations WHERE version=116", 118), + ( + "UPDATE __yoi_schema_migrations SET name='Worker removal expected Feature database identity' WHERE version=115", + 118, + ), + ("DROP TRIGGER backend_job_cleanup_owner_immutable", 118), + ( + "CREATE INDEX unreceipted_job_index ON backend_jobs(job_id)", + 118, + ), + ( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,'unknown future')", + 122, + ), + ] { + let conn = fixture(Stage::Published(Family::Jobs, 5), 113, None); + conn.execute_batch(sql).unwrap(); + let rows = saved_rows(&conn); + let shape = snapshot(&conn).unwrap(); + assert!(verify_history(&conn, current).is_err(), "{sql}"); + assert_eq!(saved_rows(&conn), rows); + assert_eq!(snapshot(&conn).unwrap(), shape); + } + let conn = fixture(Stage::Published(Family::Permissions, 2), 113, None); + conn.execute_batch(COLLECTION_SQL).unwrap(); + assert!( + verify_history(&conn, 115) + .unwrap_err() + .to_string() + .contains("mismatch") + ); + let conn = fixture(Stage::Converged(121), 113, None); + conn.execute_batch("DROP TRIGGER backend_job_cleanup_origin_immutable") + .unwrap(); + assert!(verify_history(&conn, 121).is_err()); +} + +#[test] +fn missing_or_weakened_constraints_and_literal_changes_are_not_shape_equivalent() { + let conn = fixture(Stage::Published(Family::Permissions, 1), 113, None); + conn.execute_batch("DROP TABLE permission_policy_receipts; + CREATE TABLE permission_policy_receipts(workspace_id TEXT NOT NULL,operation_id TEXT NOT NULL,fingerprint TEXT NOT NULL,result_json TEXT NOT NULL,PRIMARY KEY(workspace_id,operation_id));").unwrap(); + assert!(verify_history(&conn, 114).is_err()); + let conn = fixture(Stage::Published(Family::Jobs, 5), 113, None); + conn.execute_batch("DROP TRIGGER backend_job_cleanup_owner_immutable; + CREATE TRIGGER backend_job_cleanup_owner_immutable BEFORE UPDATE ON backend_job_cleanup_invocation_owners + BEGIN SELECT RAISE(ABORT,'Cleanup invocation ownership is immutable'); END;").unwrap(); + assert!(verify_history(&conn, 118).is_err()); +} + +fn retained_to(target: i64, alternate_103: bool) -> (tempfile::TempDir, Connection) { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("retained.db"); + super::super::tests::prepare_schema_v50(&path, Some("workspace-a")); + let conn = Connection::open(&path).unwrap(); + super::super::configure_sqlite(&conn).unwrap(); + for migration in super::super::MIGRATIONS + .iter() + .filter(|migration| migration.version <= target) + { + let current = super::super::current_schema_version(&conn).unwrap(); + verify_history(&conn, current).unwrap(); + let migration = if migration.version == 103 && alternate_103 { + &super::super::JOB_INPUT_RETENTION_SCHEMA_103 + } else { + migration + }; + (migration.apply)(&conn).unwrap(); + } + verify_history(&conn, target).unwrap(); + (temp, conn) +} + +#[test] +fn oldest_data_bearing_schema_and_both_real_103_histories_migrate_without_relabeling() { + for alternate in [false, true] { + let (_temp, conn) = retained_to(103, alternate); + let original = super::super::workspace_schema_migration_history(&conn).unwrap(); + super::super::prepare_connection(&conn).unwrap(); + verify_history(&conn, 113).unwrap(); + let updated = super::super::workspace_schema_migration_history(&conn).unwrap(); + assert_eq!(&updated[..original.len()], &original); + let name: String = conn + .query_row( + "SELECT display_name FROM workspaces WHERE workspace_id='workspace-a'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(name, "Workspace A"); + } +} + +#[test] +fn fresh_103_baselines_use_shape_and_never_repair_missing_receipts_or_guards() { + for alternate in [false, true] { + let (_temp, conn) = retained_to(103, alternate); + conn.execute_batch("DELETE FROM __yoi_schema_migrations; + INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,'workspace schema baseline');").unwrap(); + assert_accepted_without_domain_changes(&conn, 103); + if alternate { + conn.execute_batch("DROP TRIGGER backend_job_input_retirement") + .unwrap(); + } else { + conn.execute_batch("DROP TABLE passkey_addition_receipts") + .unwrap(); + } + let rows = saved_rows(&conn); + assert!(verify_history(&conn, 103).is_err()); + assert_eq!(saved_rows(&conn), rows); + } + let (_temp, conn) = retained_to(102, false); + conn.execute_batch( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,'workspace schema baseline')", + ) + .unwrap(); + assert!(verify_history(&conn, 103).is_err()); +} + +#[test] +fn later_family_objects_without_receipts_and_half_converged_103_are_rejected() { + let conn = fixture(Stage::Base, 113, None); + conn.execute_batch(CLAIMS_SQL).unwrap(); + assert!(verify_history(&conn, 113).is_err()); + let (_temp, conn) = retained_to(103, false); + conn.execute_batch("ALTER TABLE backend_jobs ADD COLUMN input_disposition TEXT NOT NULL DEFAULT 'available' CHECK(input_disposition IN ('available','reclaimed','erased','invalidated'))").unwrap(); + assert!(verify_history(&conn, 103).is_err()); +} + +#[test] +fn schema_comparison_preserves_literals_programs_and_composite_key_order() { + let left = declarations( + tokens("CREATE TABLE item(a TEXT, b TEXT, PRIMARY KEY(a,b), CHECK(a='Sensitive'))") + .unwrap(), + ) + .unwrap(); + let reordered = declarations( + tokens("CREATE TABLE \"item\"(b TEXT, CHECK(a='Sensitive'), a TEXT, PRIMARY KEY(a,b))") + .unwrap(), + ) + .unwrap(); + assert_eq!(left, reordered); + for sql in [ + "CREATE TABLE item(a TEXT, b TEXT, PRIMARY KEY(b,a), CHECK(a='Sensitive'))", + "CREATE TABLE item(a TEXT, b TEXT, PRIMARY KEY(a,b), CHECK(a='sensitive'))", + ] { + assert_ne!(left, declarations(tokens(sql).unwrap()).unwrap()); + } +} diff --git a/crates/workspace-server/src/storage_formats.rs b/crates/workspace-server/src/storage_formats.rs new file mode 100644 index 000000000..29a057301 --- /dev/null +++ b/crates/workspace-server/src/storage_formats.rs @@ -0,0 +1,1109 @@ +//! Physical acceptance of retained ServerDB histories, not domain adoption. +//! +//! The store's ordered migration owner calls `verify_history` before writing. +//! Versions 50..113 still use its retained migrations (including both real 103 +//! receipts). Published 114..121 databases retain their version, receipt names, +//! policy, Job states, root records and erasure fences. They need no domain DDL +//! merely to acquire the common storage identity/lifetime owned by the caller. +//! This does not attest policy transfer, execution termination or Journal release. +//! +//! Frozen SQL below is a physical reference: it runs ONLY on empty, private +//! in-memory databases. Never execute it on an adopted database. Later domain +//! migrations belong to their domain owners, not this recognizer. A successful +//! check is not a lock/reservation: the caller must hold the common storage +//! lifecycle guard through migration/adoption and consumer commit/rollback. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::OnceLock; + +use super::{Connection, Error, Result}; + +const CONVERGED: &str = "Published schema tail convergence and canonical config source history"; +const PRIOR_RELEASE: &str = "Worker removal original Subject release validation checkpoints"; +const CLEANUP_COVERAGE: &str = "Backend cleanup complete invocation history origins"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Family { + Permissions, + Removal, + Jobs, +} + +const FAMILIES: [Family; 3] = [Family::Permissions, Family::Removal, Family::Jobs]; + +impl Family { + fn names(self) -> &'static [&'static str] { + match self { + Self::Permissions => &[ + "Canonical config permission authority and sealed legacy transfer", + "Durable installed Plugin permission registration lineage", + ], + Self::Removal => &[ + "Worker removal fixed Journal collection reference", + "Worker removal expected Feature database identity", + "Workspace creation Feature provisioning intents", + "Immutable erased Worker removal supersession links", + ], + Self::Jobs => &[ + "Backend Job cleanup ownership claims", + "Exact Journal cleanup child identities", + "Backend Job live states and closed effect admission", + "Backend Job actual cleanup invocation termination", + "Backend cleanup kernel ownership", + ], + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Stage { + Base, + Published(Family, usize), + Converged(i64), +} + +fn invalid(message: impl std::fmt::Display) -> Error { + Error::Store(format!("Server storage format: {message}")) +} + +/// Verify exact saved receipts AND physical tail shape without modifying `conn`. +/// Fresh baselines at a branch-local version are disambiguated by their shape. +/// Every other row, gap, mixed family, unknown suffix and missing object fails. +pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> { + let rows = super::workspace_schema_migration_history(conn)?; + let Some((baseline, name)) = rows.first() else { + return Err(invalid("missing migration receipts")); + }; + if !(50..=121).contains(baseline) + || !(50..=121).contains(¤t) + || *baseline > current + || name != super::SCHEMA_BASELINE_NAME + || rows.last().map(|row| row.0) != Some(current) + { + return Err(invalid(format!( + "unknown baseline or current version: {rows:?}" + ))); + } + let mut prefix = vec![(*baseline, super::SCHEMA_BASELINE_NAME.to_owned())]; + for migration in super::MIGRATIONS + .iter() + .filter(|migration| migration.version > *baseline && migration.version <= current.min(113)) + { + let name = if migration.version == 103 + && rows.iter().any(|(version, name)| { + *version == 103 && name == super::JOB_INPUT_RETENTION_SCHEMA_103.name + }) { + super::JOB_INPUT_RETENTION_SCHEMA_103.name + } else { + migration.name + }; + prefix.push((migration.version, name.to_owned())); + } + if current <= 113 { + if rows != prefix { + return Err(invalid(format!( + "unknown or missing retained receipt: {rows:?}" + ))); + } + reject_early_tail(conn, current)?; + verify_103_features(conn, current, &rows)?; + if current == 113 { + require_shape(conn, Stage::Base)?; + } + return Ok(()); + } + + verify_103_features(conn, current, &rows)?; + // A converged history may have jumped straight from 113, or retained any + // exact published prefix. It must not acquire fabricated missing receipts. + let prior = if current < 119 { + current + } else { + rows.iter() + .filter(|(version, _)| (114..=118).contains(version)) + .map(|row| row.0) + .max() + .unwrap_or((*baseline).max(113)) + }; + let stages: Vec<_> = if *baseline >= 119 { + vec![Stage::Converged(current)] + } else if prior == 113 { + vec![Stage::Base] + } else { + FAMILIES + .into_iter() + .filter(|family| prior >= 114 && prior <= 113 + family.names().len() as i64) + .map(|family| Stage::Published(family, (prior - 113) as usize)) + .collect() + }; + let mut diagnostics = Vec::new(); + for stage in stages { + let mut expected = prefix.clone(); + if let Stage::Published(family, count) = stage { + expected.extend( + family + .names() + .iter() + .take(count) + .enumerate() + .filter(|(index, _)| 114 + *index as i64 > *baseline) + .map(|(index, name)| (114 + index as i64, (*name).to_owned())), + ); + } + for (version, name) in [ + (119, CONVERGED), + (120, PRIOR_RELEASE), + (121, CLEANUP_COVERAGE), + ] { + if version > *baseline && version <= current { + expected.push((version, name.to_owned())); + } + } + if expected != rows { + continue; + } + let installed = if current >= 119 { + Stage::Converged(current) + } else { + stage + }; + match require_shape(conn, installed) { + Ok(()) => return Ok(()), + Err(error) => diagnostics.push(error.to_string()), + } + } + Err(invalid(format!( + "unknown/mixed receipts or receipt-shape mismatch at {current}: {}", + diagnostics.join("; ") + ))) +} + +/// Plan physical acceptance of a published tail without downgrading it to 113 +/// or claiming its policy/Job migrations have been performed by this binary. +/// The caller retains the existing migration path for all earlier versions. +pub(super) fn published_plan( + conn: &Connection, + current: i64, +) -> Result> { + if current <= 113 { + return Ok(None); + } + verify_history(conn, current)?; + Ok(Some(super::WorkspaceSchemaMigrationPlan { + current_schema_version: current, + target_schema_version: current, + migrations: vec![], + })) +} + +// sqlite_schema programs, not table/column presence alone. SQL literal bytes, +// CHECK/FK clauses, indexes and trigger programs remain part of the contract. +type Shape = BTreeMap)>; +struct References { + stages: BTreeMap, + tables: BTreeSet, + new_columns: BTreeMap>, +} + +fn snapshot(conn: &Connection) -> Result { + let mut statement = conn.prepare("SELECT name,type,tbl_name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name!='__yoi_schema_migrations' ORDER BY name")?; + let mut shape = BTreeMap::new(); + for row in statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + })? { + let (name, kind, table, sql) = row?; + let mut tokens = tokens(&sql)?; + if kind == "table" { + tokens = declarations(tokens)?; + } + shape.insert(name, (kind, table, tokens)); + } + Ok(shape) +} + +fn reference_base() -> Result { + let conn = Connection::open_in_memory()?; + super::configure_sqlite(&conn)?; + ticket::migrate_sqlite_ticket_schema(&conn).map_err(invalid)?; + merge_request::migrate(&conn).map_err(invalid)?; + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; + let tx = conn.unchecked_transaction()?; + super::create_latest_workspace_schema(&tx)?; + tx.commit()?; + Ok(conn) +} + +fn references() -> Result<&'static References> { + static REFERENCES: OnceLock> = OnceLock::new(); + REFERENCES + .get_or_init(|| build_references().map_err(|error| error.to_string())) + .as_ref() + .map_err(invalid) +} + +fn build_references() -> Result { + let mut stages = BTreeMap::new(); + let mut columns = BTreeMap::new(); + let all = std::iter::once(Stage::Base) + .chain(FAMILIES.into_iter().flat_map(|family| { + (1..=family.names().len()).map(move |count| Stage::Published(family, count)) + })) + .chain((119..=121).map(Stage::Converged)); + for stage in all { + let conn = reference_base()?; + install_reference(&conn, stage)?; + let shape = snapshot(&conn)?; + let mut fields = BTreeMap::new(); + for (name, (kind, _, _)) in &shape { + if kind == "table" { + fields.insert( + name.clone(), + super::table_columns(&conn, name)? + .into_iter() + .collect::>(), + ); + } + } + columns.insert(stage, fields); + stages.insert(stage, shape); + } + let base = &stages[&Stage::Base]; + let mut tables = BTreeSet::new(); + let mut new_columns: BTreeMap> = BTreeMap::new(); + for (stage, shape) in &stages { + for (name, object) in shape { + if base.get(name) != Some(object) { + tables.insert(object.1.clone()); + } + } + for (table, fields) in &columns[stage] { + let old = columns[&Stage::Base] + .get(table) + .cloned() + .unwrap_or_default(); + new_columns + .entry(table.clone()) + .or_default() + .extend(fields.difference(&old).cloned()); + } + } + Ok(References { + stages, + tables, + new_columns, + }) +} + +fn require_shape(conn: &Connection, stage: Stage) -> Result<()> { + let references = references()?; + let selected = |shape: Shape| -> Shape { + shape + .into_iter() + .filter(|(_, object)| references.tables.contains(&object.1)) + .collect() + }; + let expected = selected(references.stages[&stage].clone()); + let actual = selected(snapshot(conn)?); + if actual != expected { + let changed: BTreeSet<_> = actual + .keys() + .chain(expected.keys()) + .filter(|key| actual.get(*key) != expected.get(*key)) + .collect(); + return Err(invalid(format!("physical {stage:?} mismatch: {changed:?}"))); + } + Ok(()) +} + +fn reject_early_tail(conn: &Connection, current: i64) -> Result<()> { + if current == 113 { + return Ok(()); + } + let references = references()?; + let actual = snapshot(conn)?; + let base = &references.stages[&Stage::Base]; + for (name, object) in &actual { + if references.tables.contains(&object.1) && !base.contains_key(name) { + return Err(invalid(format!( + "published object before its receipt: {name}" + ))); + } + } + for (table, fields) in &references.new_columns { + if super::table_exists(conn, table)? + && super::table_columns(conn, table)? + .iter() + .any(|field| fields.contains(field)) + { + return Err(invalid(format!( + "published column before its receipt: {table}" + ))); + } + } + Ok(()) +} + +fn verify_103_features(conn: &Connection, current: i64, rows: &[(i64, String)]) -> Result<()> { + let passkeys = super::table_exists(conn, "passkey_metadata")?; + let retention = super::column_exists(conn, "backend_jobs", "input_disposition")?; + if current < 103 { + if passkeys || retention { + return Err(invalid("schema-103 objects before their receipt")); + } + return Ok(()); + } + if current >= 104 { + if !passkeys || !retention { + return Err(invalid("missing converged schema-103 objects")); + } + } else { + let receipt = rows + .iter() + .find(|row| row.0 == 103) + .map(|row| row.1.as_str()); + let matched = match receipt { + Some(super::SCHEMA_BASELINE_NAME) => passkeys != retention, + Some("Passkey metadata and atomic addition results") => passkeys && !retention, + Some("Job input retirement independent of immutable receipts") => { + retention && !passkeys + } + _ => false, + }; + if !matched { + return Err(invalid("schema-103 receipt-shape mismatch")); + } + } + // Both migration branches have externally visible immutable receipts and + // fences. Missing/modified triggers are not repaired opportunistically. + let expected = &references()?.stages[&Stage::Base]; + let actual = snapshot(conn)?; + for name in [ + "passkey_metadata", + "passkey_addition_receipts", + "passkey_metadata_insert", + "backend_job_identity_immutable", + "backend_job_input_retirement", + ] { + let required = if name.starts_with("passkey_") { + passkeys + } else { + retention + }; + if required && actual.get(name) != expected.get(name) { + return Err(invalid(format!( + "schema-103 object missing or drifted: {name}" + ))); + } + } + Ok(()) +} + +// Only private empty reference DBs reach this function. In particular, the Job +// rebuild changes empty reference DDL; it never transforms any saved Job state. +fn install_reference(conn: &Connection, stage: Stage) -> Result<()> { + match stage { + Stage::Base => {} + Stage::Published(family, count) => { + for index in 0..count { + reference_stage(conn, family, index)?; + } + } + Stage::Converged(version) => { + for family in FAMILIES { + for index in 0..family.names().len() { + reference_stage(conn, family, index)?; + } + } + for sql in [ROOT_SQL, SET_IDENTITIES_SQL, HISTORY_SQL, SYNC_SQL] { + conn.execute_batch(sql)?; + } + if version >= 120 { + conn.execute_batch(PRIOR_RELEASE_SQL)?; + } + if version >= 121 { + conn.execute_batch(CLEANUP_COVERAGE_SQL)?; + } + } + } + Ok(()) +} + +fn reference_stage(conn: &Connection, family: Family, index: usize) -> Result<()> { + let sql = match (family, index) { + (Family::Permissions, 0) => PERMISSION_SQL, + (Family::Permissions, 1) => REGISTRATIONS_SQL, + (Family::Removal, 0) => COLLECTION_SQL, + (Family::Removal, 1) => IDENTITY_SQL, + (Family::Removal, 2) => PROVISIONING_SQL, + (Family::Removal, 3) => SUPERSESSION_SQL, + (Family::Jobs, 0) => CLAIMS_SQL, + (Family::Jobs, 1) => CLEANUP_REMOVALS_SQL, + (Family::Jobs, 2) => { + reference_job_shape(conn)?; + return Ok(()); + } + (Family::Jobs, 3) => INVOCATION_ENDS_SQL, + (Family::Jobs, 4) => INVOCATION_OWNERS_SQL, + _ => return Err(invalid("invalid reference stage")), + }; + conn.execute_batch(sql)?; + Ok(()) +} + +fn reference_job_shape(conn: &Connection) -> Result<()> { + for table in ["backend_jobs", "backend_job_attempts"] { + let count: i64 = conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| { + row.get(0) + })?; + if count != 0 { + return Err(invalid("reference rebuild requires EMPTY Job tables")); + } + } + let programs = conn.prepare("SELECT type,name,sql FROM sqlite_schema WHERE type='trigger' OR (type='index' AND tbl_name IN ('backend_jobs','backend_job_attempts') AND sql IS NOT NULL) ORDER BY name")? + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))? + .collect::>>()?; + for (kind, name, _) in &programs { + conn.execute_batch(&format!("DROP {kind} \"{}\";", name.replace('"', "\"\"")))?; + } + for (table, old, new) in [ + ( + "backend_jobs", + "CHECK (state IN ('pending', 'completed', 'failed', 'unknown'))", + "CHECK (state IN ('pending', 'completed', 'failed'))", + ), + ( + "backend_job_attempts", + "CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown'))", + "CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed'))", + ), + ] { + let ddl: String = conn.query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [table], + |row| row.get(0), + )?; + if ddl.matches(old).count() != 1 { + return Err(invalid("unrecognized reference Job DDL")); + } + conn.execute_batch(&format!("DROP TABLE {table};"))?; + conn.execute_batch(&ddl.replacen(old, new, 1))?; + } + conn.execute_batch("ALTER TABLE backend_job_attempts ADD COLUMN effect_admission_closed INTEGER NOT NULL DEFAULT 0 CHECK (effect_admission_closed IN (0, 1));")?; + conn.execute_batch(STATE_HISTORY_SQL)?; + for (_, _, sql) in programs { + conn.execute_batch(&sql)?; + } + Ok(()) +} + +// Normalize only SQL syntax, never string literals. Independent column/constraint +// declarations may move under ALTER TABLE, but composite keys retain their order. +fn tokens(sql: &str) -> Result> { + let chars: Vec<_> = sql.chars().collect(); + let mut result = Vec::new(); + let mut i = 0; + while i < chars.len() { + let ch = chars[i]; + if ch.is_whitespace() { + i += 1; + continue; + } + if chars[i..].starts_with(&['-', '-']) { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } + if chars[i..].starts_with(&['/', '*']) { + i += 2; + while i + 1 < chars.len() && !chars[i..].starts_with(&['*', '/']) { + i += 1; + } + if i + 1 >= chars.len() { + return Err(invalid("unterminated SQL comment")); + } + i += 2; + continue; + } + if ['\'', '"', '`', '['].contains(&ch) { + let end = if ch == '[' { ']' } else { ch }; + i += 1; + let mut word = String::new(); + loop { + let next = *chars + .get(i) + .ok_or_else(|| invalid("unterminated SQL quote"))?; + i += 1; + if next == end { + if end != ']' && chars.get(i) == Some(&end) { + word.push(end); + i += 1; + } else { + break; + } + } else { + word.push(next); + } + } + result.push(if ch == '\'' { + format!("literal:{word}") + } else { + format!("word:{}", word.to_ascii_lowercase()) + }); + } else if ch.is_alphanumeric() || ch == '_' { + let begin = i; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + result.push(format!( + "word:{}", + chars[begin..i] + .iter() + .collect::() + .to_ascii_lowercase() + )); + } else { + result.push(ch.to_string()); + i += 1; + } + } + Ok(result) +} + +fn declarations(tokens: Vec) -> Result> { + let start = tokens + .iter() + .position(|token| token == "(") + .ok_or_else(|| invalid("missing table declarations"))?; + let mut depth = 1; + let mut begin = start + 1; + let mut fields = Vec::new(); + for i in begin..tokens.len() { + match tokens[i].as_str() { + "(" => depth += 1, + ")" => depth -= 1, + _ => {} + } + if depth == 0 || (depth == 1 && tokens[i] == ",") { + fields.push(tokens[begin..i].to_vec()); + begin = i + 1; + } + if depth == 0 { + fields.sort(); + let mut result = tokens[..=start].to_vec(); + for field in fields { + result.extend(field); + result.push(",".into()); + } + result.extend_from_slice(&tokens[i..]); + return Ok(result); + } + } + Err(invalid("unbalanced table declarations")) +} + +#[cfg(test)] +#[path = "storage_format_tests.rs"] +mod tests; + +// Exact, frozen physical references from public Git 7b39a44ca698c474287e5511f674f2e8baae4c03. +// Per-constant source paths below permit byte-for-byte provenance checks. These +// are NOT registered migrations and must never be applied to a source database. + +// crates/workspace-server/src/schema_convergence/sql/t818_permission_metadata.sql +const PERMISSION_SQL: &str = r####"-- Canonical permission metadata. Policy values live only in the evaluated Decodal +-- source; history/receipts are immutable evidence, never a writable policy overlay. +CREATE TABLE permission_authority_transfers ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + transfer_id TEXT NOT NULL, + export_digest TEXT NOT NULL, + export_json TEXT NOT NULL, + config_identity_json TEXT NOT NULL, + UNIQUE(workspace_id,transfer_id) +); +CREATE TABLE permission_policy_changes ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + subject_id TEXT NOT NULL, + change_id TEXT NOT NULL, + record_json TEXT NOT NULL, + present INTEGER NOT NULL CHECK(present IN (0,1)), + PRIMARY KEY(workspace_id,subject_id,change_id) +); +CREATE TABLE permission_policy_heads ( + workspace_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + change_id TEXT NOT NULL, + PRIMARY KEY(workspace_id,subject_id), + FOREIGN KEY(workspace_id,subject_id,change_id) + REFERENCES permission_policy_changes(workspace_id,subject_id,change_id) ON DELETE RESTRICT +); +CREATE TABLE permission_policy_receipts ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + operation_id TEXT NOT NULL, + fingerprint TEXT NOT NULL, + result_json TEXT NOT NULL, + PRIMARY KEY(workspace_id,operation_id) +); +CREATE TABLE permission_child_grants ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + source_change_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY(workspace_id,runtime_id,worker_id), + FOREIGN KEY(workspace_id,subject_id,source_change_id) + REFERENCES permission_policy_changes(workspace_id,subject_id,change_id) ON DELETE RESTRICT +); +-- These are permission-side fences, not Subject lifecycle state. The Host must +-- commit its canonical deletion fence first and pass the same deletion identity. +CREATE TABLE permission_legacy_workers ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + source_change_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY(workspace_id,runtime_id,worker_id) +); +CREATE TABLE permission_subject_fences ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + subject_id TEXT NOT NULL, + deletion_operation_id TEXT NOT NULL, + PRIMARY KEY(workspace_id,subject_id) +); +CREATE TABLE permission_worker_fences ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + deletion_operation_id TEXT NOT NULL, + PRIMARY KEY(workspace_id,runtime_id,worker_id), + FOREIGN KEY(workspace_id,subject_id) REFERENCES permission_subject_fences(workspace_id,subject_id) ON DELETE RESTRICT +); +CREATE TRIGGER permission_changes_immutable BEFORE UPDATE ON permission_policy_changes +BEGIN SELECT RAISE(ABORT,'permission history is immutable'); END; +CREATE TRIGGER permission_changes_retained BEFORE DELETE ON permission_policy_changes +BEGIN SELECT RAISE(ABORT,'permission history is retained'); END; +CREATE TRIGGER permission_receipts_immutable BEFORE UPDATE ON permission_policy_receipts +BEGIN SELECT RAISE(ABORT,'permission receipt is immutable'); END; +CREATE TRIGGER permission_receipts_retained BEFORE DELETE ON permission_policy_receipts +BEGIN SELECT RAISE(ABORT,'permission receipt is retained'); END; +CREATE TRIGGER permission_transfer_immutable BEFORE UPDATE ON permission_authority_transfers +BEGIN SELECT RAISE(ABORT,'permission transfer is immutable'); END; +CREATE TRIGGER permission_transfer_retained BEFORE DELETE ON permission_authority_transfers +BEGIN SELECT RAISE(ABORT,'permission transfer is retained'); END; +CREATE TRIGGER permission_grants_immutable BEFORE UPDATE ON permission_child_grants +BEGIN SELECT RAISE(ABORT,'permission child binding is immutable'); END; +CREATE TRIGGER permission_grants_retained BEFORE DELETE ON permission_child_grants +BEGIN SELECT RAISE(ABORT,'permission child binding is retained'); END; +CREATE TRIGGER permission_legacy_workers_immutable BEFORE UPDATE ON permission_legacy_workers +BEGIN SELECT RAISE(ABORT,'legacy permission binding is immutable'); END; +CREATE TRIGGER permission_legacy_workers_retained BEFORE DELETE ON permission_legacy_workers +BEGIN SELECT RAISE(ABORT,'legacy permission binding is retained'); END; +CREATE TRIGGER permission_subject_fence_immutable BEFORE UPDATE ON permission_subject_fences +BEGIN SELECT RAISE(ABORT,'permission deletion fence is immutable'); END; +CREATE TRIGGER permission_subject_fence_retained BEFORE DELETE ON permission_subject_fences +BEGIN SELECT RAISE(ABORT,'permission deletion fence is permanent'); END; +CREATE TRIGGER permission_worker_fence_immutable BEFORE UPDATE ON permission_worker_fences +BEGIN SELECT RAISE(ABORT,'permission Worker fence is immutable'); END; +CREATE TRIGGER permission_worker_fence_retained BEFORE DELETE ON permission_worker_fences +BEGIN SELECT RAISE(ABORT,'permission Worker fence is permanent'); END; +"####; + +// crates/workspace-server/src/schema_convergence/sql/t818_plugin_permission_registrations.sql +const REGISTRATIONS_SQL: &str = r####"-- Applied by the ordered Workspace schema migration, never by a runtime reader. +CREATE TABLE plugin_permission_registrations ( + workspace_id TEXT NOT NULL, + installation_id TEXT NOT NULL, + artifact_digest TEXT NOT NULL, + generation TEXT NOT NULL CHECK(length(generation) = 64 AND generation NOT GLOB '*[^0-9a-f]*'), + enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), + PRIMARY KEY(workspace_id, installation_id, artifact_digest), + FOREIGN KEY(workspace_id, installation_id, artifact_digest) + REFERENCES plugin_artifacts(workspace_id, installation_id, artifact_digest) ON DELETE CASCADE +); + +-- Old installations start a new lineage rather than inheriting historical grants. +INSERT INTO plugin_permission_registrations + (workspace_id, installation_id, artifact_digest, generation, enabled) +SELECT workspace_id, installation_id, artifact_digest, lower(hex(randomblob(32))), 1 +FROM plugin_references WHERE kind = 'config' AND owner = 'workspace-config'; + +-- Config synchronization must preserve unchanged references: delete/reinsert is +-- a new activation and intentionally invalidates that reference's frozen grants. +CREATE TRIGGER plugin_permission_enable AFTER INSERT ON plugin_references +WHEN NEW.kind = 'config' AND NEW.owner = 'workspace-config' +BEGIN + INSERT INTO plugin_permission_registrations + (workspace_id, installation_id, artifact_digest, generation, enabled) + VALUES(NEW.workspace_id, NEW.installation_id, NEW.artifact_digest, lower(hex(randomblob(32))), 1) + ON CONFLICT(workspace_id, installation_id, artifact_digest) + DO UPDATE SET generation = excluded.generation, enabled = 1; +END; + +CREATE TRIGGER plugin_permission_disable AFTER DELETE ON plugin_references +WHEN OLD.kind = 'config' AND OLD.owner = 'workspace-config' +BEGIN + UPDATE plugin_permission_registrations SET enabled = 0 + WHERE workspace_id = OLD.workspace_id AND installation_id = OLD.installation_id + AND artifact_digest = OLD.artifact_digest; +END; +"####; + +// crates/workspace-server/src/schema_convergence/sql/t821_removal_collection.sql +const COLLECTION_SQL: &str = + r####"ALTER TABLE worker_removal_operations ADD COLUMN journal_collection_json TEXT;"####; + +// crates/workspace-server/src/schema_convergence/sql/t821_removal_identity.sql +const IDENTITY_SQL: &str = + r####"ALTER TABLE worker_removal_operations ADD COLUMN journal_storage_identity_json TEXT;"####; + +// crates/workspace-server/src/schema_convergence/sql/t821_workspace_feature_provisioning.sql +const PROVISIONING_SQL: &str = r####" +CREATE TABLE workspace_feature_provisioning ( + creation_operation_key TEXT NOT NULL, + identity_operation_key TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, + feature_id TEXT NOT NULL, + incarnation TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'issued', 'confirmed')), + PRIMARY KEY (creation_operation_key, feature_id), + UNIQUE (workspace_id, feature_id), + FOREIGN KEY (identity_operation_key) + REFERENCES workspace_signing_identity_provisioning_operations(operation_key) +); +"####; + +// crates/workspace-server/src/schema_convergence/sql/t821_erasure_supersession.sql +const SUPERSESSION_SQL: &str = r####"-- Immutable authority/reference facts only; execution state stays on existing parents. +CREATE UNIQUE INDEX worker_removal_erasure_parent_identity + ON worker_removal_operations(operation_id,input_fingerprint,workspace_id,runtime_id,worker_id); +CREATE TABLE worker_removal_erasure_supersessions ( + previous_operation_id TEXT PRIMARY KEY, + replacement_operation_id TEXT NOT NULL UNIQUE, + previous_fingerprint TEXT NOT NULL, + replacement_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + session_id TEXT NOT NULL CHECK(length(session_id)>0), + previous_request_json TEXT NOT NULL CHECK(json_valid(previous_request_json)), + replacement_request_json TEXT NOT NULL CHECK(json_valid(replacement_request_json)), + created_at TEXT NOT NULL, + CHECK(previous_operation_id<>replacement_operation_id), + CHECK(previous_fingerprint<>replacement_fingerprint), + FOREIGN KEY(previous_operation_id,previous_fingerprint,workspace_id,runtime_id,worker_id) + REFERENCES worker_removal_operations(operation_id,input_fingerprint,workspace_id,runtime_id,worker_id), + FOREIGN KEY(replacement_operation_id,replacement_fingerprint,workspace_id,runtime_id,worker_id) + REFERENCES worker_removal_operations(operation_id,input_fingerprint,workspace_id,runtime_id,worker_id) +); +CREATE TRIGGER worker_removal_erasure_supersessions_no_update +BEFORE UPDATE ON worker_removal_erasure_supersessions +BEGIN SELECT RAISE(ABORT,'Worker removal erasure supersession is immutable'); END; +CREATE TRIGGER worker_removal_erasure_supersessions_no_delete +BEFORE DELETE ON worker_removal_erasure_supersessions +BEGIN SELECT RAISE(ABORT,'Worker removal erasure supersession is immutable'); END; +"####; + +// crates/workspace-server/src/schema_convergence/sql/t820_backend_job_cleanup_claims.sql +const CLAIMS_SQL: &str = r####"ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_claim_id TEXT + CHECK (worker_cleanup_claim_id IS NULL OR length(worker_cleanup_claim_id) = 36); + CREATE TRIGGER backend_job_cleanup_claim_insert + BEFORE INSERT ON backend_job_attempts + WHEN NEW.worker_cleanup_state = 'executing' AND NEW.worker_cleanup_claim_id IS NULL + BEGIN SELECT RAISE(ABORT, 'executing cleanup requires an invocation claim'); END; + CREATE TRIGGER backend_job_cleanup_claim_update + BEFORE UPDATE OF worker_cleanup_state, worker_cleanup_claim_id ON backend_job_attempts + WHEN NEW.worker_cleanup_state = 'executing' AND NEW.worker_cleanup_claim_id IS NULL + AND NOT (OLD.worker_cleanup_state IS 'executing' AND OLD.worker_cleanup_claim_id IS NULL) + BEGIN SELECT RAISE(ABORT, 'executing cleanup requires an invocation claim'); END;"####; + +// crates/workspace-server/src/schema_convergence/sql/t820_backend_journal_cleanup_removals.sql +const CLEANUP_REMOVALS_SQL: &str = r####"CREATE TABLE backend_journal_cleanup_removals ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + claim_id TEXT NOT NULL CHECK (length(claim_id) = 36), + input_digest TEXT NOT NULL, + operation_id TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + generation_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, job_id, attempt_id, claim_id), + UNIQUE (operation_id), + FOREIGN KEY (workspace_id, job_id, attempt_id) + REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE, + FOREIGN KEY (operation_id) REFERENCES worker_removal_operations(operation_id) +); +"####; + +// crates/workspace-server/src/schema_convergence/sql/t820_backend_job_state_history.sql +const STATE_HISTORY_SQL: &str = r####" +CREATE TABLE backend_job_legacy_state_history ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state = 'unknown'), + completed_at TEXT, + failure_category TEXT, + failure_detail TEXT, + PRIMARY KEY (workspace_id, job_id), + FOREIGN KEY (workspace_id, job_id) + REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE +); +CREATE TABLE backend_job_attempt_legacy_state_history ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state = 'unknown'), + completed_at TEXT, + failure_category TEXT, + failure_detail TEXT, + PRIMARY KEY (workspace_id, job_id, attempt_id), + FOREIGN KEY (workspace_id, job_id, attempt_id) + REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE +); +CREATE TRIGGER backend_job_effect_admission_monotone +BEFORE UPDATE OF effect_admission_closed ON backend_job_attempts +WHEN NEW.effect_admission_closed < OLD.effect_admission_closed +BEGIN SELECT RAISE(ABORT, 'Backend Job effect admission cannot reopen'); END; +CREATE TRIGGER backend_job_legacy_state_no_update +BEFORE UPDATE ON backend_job_legacy_state_history +BEGIN SELECT RAISE(ABORT, 'Backend Job migration history is immutable'); END; +CREATE TRIGGER backend_job_attempt_legacy_state_no_update +BEFORE UPDATE ON backend_job_attempt_legacy_state_history +BEGIN SELECT RAISE(ABORT, 'Backend Job migration history is immutable'); END; +"####; + +// crates/workspace-server/src/schema_convergence/sql/t820_backend_job_cleanup_invocation_ends.sql +const INVOCATION_ENDS_SQL: &str = r####"CREATE TABLE backend_job_cleanup_invocation_ends ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + claim_id TEXT NOT NULL CHECK(length(claim_id)=36), + ended_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,job_id,attempt_id,claim_id), + FOREIGN KEY(workspace_id,job_id,attempt_id) + REFERENCES backend_job_attempts(workspace_id,job_id,attempt_id) ON DELETE CASCADE +); +CREATE TRIGGER backend_job_cleanup_invocation_end_immutable +BEFORE UPDATE ON backend_job_cleanup_invocation_ends +BEGIN SELECT RAISE(ABORT,'cleanup invocation termination evidence is immutable'); END; +"####; + +// crates/workspace-server/src/schema_convergence/sql/t820_backend_job_cleanup_invocation_owners.sql +const INVOCATION_OWNERS_SQL: &str = r####"CREATE TABLE backend_job_cleanup_invocation_owners ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + claim_id TEXT NOT NULL, + lease_scope TEXT NOT NULL, + lease_device TEXT NOT NULL, + lease_inode TEXT NOT NULL, + acquisition_id TEXT NOT NULL, + PRIMARY KEY(workspace_id,job_id,attempt_id,claim_id), + FOREIGN KEY(workspace_id,job_id,attempt_id) + REFERENCES backend_job_attempts(workspace_id,job_id,attempt_id) ON DELETE CASCADE +); +CREATE TRIGGER backend_job_cleanup_owner_immutable +BEFORE UPDATE ON backend_job_cleanup_invocation_owners +BEGIN SELECT RAISE(ABORT,'cleanup invocation ownership is immutable'); END; +"####; + +// crates/workspace-server/src/feature_storage_owner/root_schema.sql +const ROOT_SQL: &str = r####"-- Predecessor: composed ServerDB prefix including T821 workspace_feature_provisioning, +-- workspace_signing_identity_provisioning_operations and workspace_create_operations. +-- T816 owns ordered/prefix-aware registration; do not execute this from an opener, +-- rewrite any published 114..118 SQL, or assign a competing migration number. +CREATE TABLE server_feature_root ( + root_slot INTEGER NOT NULL PRIMARY KEY CHECK (root_slot = 1), + reservation_id TEXT NOT NULL UNIQUE, + request_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'issued', 'accepted')), + accepted_receipt TEXT, + CHECK ((state = 'accepted' AND accepted_receipt IS NOT NULL) + OR (state != 'accepted' AND accepted_receipt IS NULL)) +); +CREATE TRIGGER server_feature_root_identity_immutable +BEFORE UPDATE ON server_feature_root +WHEN NEW.root_slot != OLD.root_slot + OR NEW.reservation_id != OLD.reservation_id + OR NEW.request_fingerprint != OLD.request_fingerprint + OR NOT ((OLD.state = 'reserved' AND NEW.state = 'issued') + OR (OLD.state = 'issued' AND NEW.state = 'accepted') + OR (OLD.state = NEW.state AND NEW.accepted_receipt IS OLD.accepted_receipt)) +BEGIN SELECT RAISE(ABORT, 'Feature root identity or transition changed'); END; +CREATE TRIGGER server_feature_root_no_delete BEFORE DELETE ON server_feature_root +BEGIN SELECT RAISE(ABORT, 'Feature root acceptance cannot be deleted implicitly'); END; +"####; + +// crates/workspace-server/src/plugin_permissions/set_identities_schema.sql +const SET_IDENTITIES_SQL: &str = r####"-- Separate module DDL, composed only by a new ordered Workspace migration. +-- Requires the historical Plugin permission registration schema and workspaces. +-- Extension-point identity is Workspace-owned, not owned by a live contributor. +-- No operation, namespace, registration or grant survives through this table. +CREATE TABLE workspace_permission_set_identities ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + set_id TEXT NOT NULL CHECK(length(set_id) BETWEEN 1 AND 128), + PRIMARY KEY(workspace_id, set_id) +); +"####; + +// crates/workspace-server/src/config_source/sync_schema.sql +const SYNC_SQL: &str = r####"CREATE TABLE workspace_config_sync_receipts ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + operation_id TEXT NOT NULL CHECK(length(operation_id) BETWEEN 1 AND 128), + account_id TEXT NOT NULL, + receipt_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, operation_id) +) STRICT; +"####; + +// crates/workspace-server/src/retention/prior_release_schema.sql +const PRIOR_RELEASE_SQL: &str = r####"-- Validation progress only: removal must reread the original release, its receipt +-- and current attempt proof. This checkpoint never grants release authority. +CREATE TABLE worker_removal_prior_releases ( + operation_id TEXT PRIMARY KEY REFERENCES worker_removal_operations(operation_id) ON DELETE CASCADE, + original_release_operation_id TEXT NOT NULL, + lease_json TEXT NOT NULL, + request_json TEXT NOT NULL, + storage_identity_json TEXT NOT NULL, + admission_sha256 TEXT NOT NULL, + after_session_id TEXT, + validation_complete INTEGER NOT NULL DEFAULT 0 CHECK (validation_complete IN (0, 1)) +); + +CREATE TRIGGER worker_removal_prior_releases_no_update +BEFORE UPDATE ON worker_removal_prior_releases +WHEN OLD.operation_id IS NOT NEW.operation_id + OR OLD.original_release_operation_id IS NOT NEW.original_release_operation_id + OR OLD.lease_json IS NOT NEW.lease_json + OR OLD.request_json IS NOT NEW.request_json + OR OLD.storage_identity_json IS NOT NEW.storage_identity_json + OR OLD.admission_sha256 IS NOT NEW.admission_sha256 + OR NEW.validation_complete < OLD.validation_complete +BEGIN + SELECT RAISE(ABORT, 'prior release validation reference is immutable'); +END; + +CREATE TRIGGER worker_removal_prior_releases_no_delete +BEFORE DELETE ON worker_removal_prior_releases +BEGIN + SELECT RAISE(ABORT, 'prior release validation reference is retained'); +END; +"####; + +// crates/workspace-server/src/backend_job_cleanup_coverage.sql +const CLEANUP_COVERAGE_SQL: &str = r####"CREATE TABLE backend_job_cleanup_history_origins ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt INTEGER NOT NULL, + input_digest TEXT NOT NULL, + PRIMARY KEY(workspace_id,job_id,attempt_id), + FOREIGN KEY(workspace_id,job_id,attempt_id) + REFERENCES backend_job_attempts(workspace_id,job_id,attempt_id) ON DELETE CASCADE +); +CREATE TRIGGER backend_job_cleanup_origin_immutable +BEFORE UPDATE ON backend_job_cleanup_history_origins +BEGIN SELECT RAISE(ABORT,'cleanup history origin is immutable'); END; +CREATE TRIGGER backend_job_cleanup_origin_requires_unallocated_birth +BEFORE INSERT ON backend_job_cleanup_history_origins +WHEN NOT EXISTS(SELECT 1 FROM backend_job_attempts a + WHERE a.workspace_id=NEW.workspace_id AND a.job_id=NEW.job_id AND a.attempt_id=NEW.attempt_id + AND a.attempt=NEW.attempt AND a.input_digest=NEW.input_digest AND a.state='reserved' + AND a.worker_id IS NULL AND a.runtime_id IS NULL AND a.runtime_run_id IS NULL + AND a.worker_cleanup_state IS NULL AND a.worker_cleanup_claim_id IS NULL) + OR EXISTS(SELECT 1 FROM worker_create_reservations r + WHERE r.workspace_id=NEW.workspace_id AND r.allocation_key='backend-job:'||NEW.attempt_id) + OR EXISTS(SELECT 1 FROM backend_job_cleanup_invocation_owners o + WHERE o.workspace_id=NEW.workspace_id AND o.job_id=NEW.job_id AND o.attempt_id=NEW.attempt_id) + OR EXISTS(SELECT 1 FROM backend_job_cleanup_invocation_ends e + WHERE e.workspace_id=NEW.workspace_id AND e.job_id=NEW.job_id AND e.attempt_id=NEW.attempt_id) +BEGIN SELECT RAISE(ABORT,'cleanup history origin requires a newly inserted unallocated attempt'); END; +CREATE TRIGGER backend_job_cleanup_claim_requires_history +BEFORE UPDATE OF worker_cleanup_state,worker_cleanup_claim_id ON backend_job_attempts +WHEN NEW.worker_cleanup_state='executing' + AND (OLD.worker_cleanup_state IS NOT 'executing' OR NEW.worker_cleanup_claim_id IS NOT OLD.worker_cleanup_claim_id) + AND ( + NOT EXISTS(SELECT 1 FROM backend_job_cleanup_history_origins c + WHERE c.workspace_id=NEW.workspace_id AND c.job_id=NEW.job_id AND c.attempt_id=NEW.attempt_id + AND c.attempt=NEW.attempt AND c.input_digest=NEW.input_digest) + OR (OLD.worker_cleanup_state IS NOT 'executing' AND NEW.worker_cleanup_claim_id IS OLD.worker_cleanup_claim_id) + OR (OLD.worker_cleanup_state IS 'executing' AND NEW.worker_cleanup_claim_id IS NOT OLD.worker_cleanup_claim_id) + OR (NEW.worker_cleanup_claim_id IS NOT OLD.worker_cleanup_claim_id + AND OLD.worker_cleanup_claim_id IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM backend_job_cleanup_invocation_ends e + WHERE e.workspace_id=OLD.workspace_id AND e.job_id=OLD.job_id AND e.attempt_id=OLD.attempt_id + AND e.claim_id=OLD.worker_cleanup_claim_id)) + OR EXISTS(SELECT 1 FROM backend_job_cleanup_invocation_owners o + WHERE o.workspace_id=OLD.workspace_id AND o.job_id=OLD.job_id AND o.attempt_id=OLD.attempt_id + AND NOT EXISTS(SELECT 1 FROM backend_job_cleanup_invocation_ends old_end + WHERE old_end.workspace_id=o.workspace_id AND old_end.job_id=o.job_id + AND old_end.attempt_id=o.attempt_id AND old_end.claim_id=o.claim_id))) +BEGIN SELECT RAISE(ABORT,'cleanup claim requires complete original invocation history'); END; +CREATE TRIGGER backend_job_cleanup_no_pending_reset +BEFORE UPDATE OF worker_cleanup_state ON backend_job_attempts +WHEN OLD.worker_cleanup_state='executing' AND NEW.worker_cleanup_state='pending' +BEGIN SELECT RAISE(ABORT,'executing cleanup cannot be reset to pending'); END; +CREATE VIEW backend_job_cleanup_ended_attempts AS +SELECT a.workspace_id,a.job_id,a.attempt_id,a.worker_cleanup_claim_id AS claim_id +FROM backend_job_attempts a +JOIN backend_job_cleanup_history_origins c + ON c.workspace_id=a.workspace_id AND c.job_id=a.job_id AND c.attempt_id=a.attempt_id + AND c.attempt=a.attempt AND c.input_digest=a.input_digest +JOIN backend_job_cleanup_invocation_ends e + ON e.workspace_id=a.workspace_id AND e.job_id=a.job_id AND e.attempt_id=a.attempt_id + AND e.claim_id=a.worker_cleanup_claim_id +WHERE NOT EXISTS(SELECT 1 FROM backend_job_cleanup_invocation_owners o + WHERE o.workspace_id=a.workspace_id AND o.job_id=a.job_id AND o.attempt_id=a.attempt_id + AND NOT EXISTS(SELECT 1 FROM backend_job_cleanup_invocation_ends old_end + WHERE old_end.workspace_id=o.workspace_id AND old_end.job_id=o.job_id + AND old_end.attempt_id=o.attempt_id AND old_end.claim_id=o.claim_id)); +"####; + +// crates/content-history-sqlite/src/schema.sql +const HISTORY_SQL: &str = r####"-- Installed by the owning database's versioned migration, never on ordinary reads. +CREATE TABLE content_history_targets ( + workspace TEXT NOT NULL, + target TEXT NOT NULL, + head BLOB CHECK(head IS NULL OR length(head) = 32), + erased INTEGER NOT NULL DEFAULT 0 CHECK(erased IN (0, 1)), + PRIMARY KEY(workspace, target), + CHECK(erased = 0 OR head IS NULL) +); +CREATE TABLE content_history_objects ( + workspace TEXT NOT NULL, + kind INTEGER NOT NULL CHECK(kind IN (1, 2)), + hash BLOB NOT NULL CHECK(length(hash) = 32), + encoding BLOB NOT NULL, + PRIMARY KEY(workspace, kind, hash) +); +CREATE TABLE content_history_records ( + workspace TEXT NOT NULL, + target TEXT NOT NULL, + hash BLOB NOT NULL CHECK(length(hash) = 32), + encoding BLOB NOT NULL, + PRIMARY KEY(workspace, target, hash), + FOREIGN KEY(workspace, target) REFERENCES content_history_targets(workspace, target) +); +-- All retained histories own their complete closure, not just the current head. +CREATE TABLE content_history_references ( + workspace TEXT NOT NULL, + target TEXT NOT NULL, + history BLOB NOT NULL, + kind INTEGER NOT NULL, + hash BLOB NOT NULL, + PRIMARY KEY(workspace, target, history, kind, hash), + FOREIGN KEY(workspace, target, history) + REFERENCES content_history_records(workspace, target, hash), + FOREIGN KEY(workspace, kind, hash) + REFERENCES content_history_objects(workspace, kind, hash) +); +CREATE INDEX content_history_object_owners ON content_history_references(workspace, kind, hash); +"####; diff --git a/crates/workspace-server/src/storage_owner_tests.rs b/crates/workspace-server/src/storage_owner_tests.rs new file mode 100644 index 000000000..02e3a6498 --- /dev/null +++ b/crates/workspace-server/src/storage_owner_tests.rs @@ -0,0 +1,151 @@ +//! Real Server catalog -> FeatureStorage -> Subjektiv connection tests. The +//! fixture creates saved domain data through public APIs, then removes only the +//! new physical identity metadata from a SQLite backup to represent a pre-owner +//! database. No production paths, Runtime, or authorization provider is borrowed. +use super::*; +use crate::subjektiv::{SubjectRole, SubjektivStore}; +use feature_storage::FeatureStorage; + +fn catalog(path: &Path) -> SqliteWorkspaceStore { + let store = SqliteWorkspaceStore::open_for_test(path).unwrap(); + store.with_conn(|conn| { + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) + VALUES('storage-owner','user','storage-owner','Owner','1','1'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) + VALUES('storage-workspace','storage-owner','Saved','active','1','1');")?; + Ok(()) + }).unwrap(); + store +} + +fn legacy_subject(source: &Path, destination: &Path, attribution: &str) -> String { + let storage = FeatureStorage::new(source).scope(attribution).unwrap(); + let registration = SubjektivStore::register(&storage).unwrap(); + let database = storage.open(®istration).unwrap(); + let subject_store = SubjektivStore::from_database(database.clone()).unwrap(); + let subject = subject_store + .create_subject_with_behavior( + SubjectRole::new("retained").unwrap(), + "original behavior".into(), + ) + .unwrap(); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + database + .with_connection(|conn| { + let mut copy = Connection::open(destination)?; + Backup::new(conn, &mut copy)?.run_to_completion(128, Duration::from_millis(1), None)?; + copy.execute_batch("DROP TABLE __yoi_storage_identity;")?; + Ok(()) + }) + .unwrap(); + subject.id +} + +#[test] +fn catalog_authorized_legacy_adoption_reopens_and_preserves_domain_data() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let catalog = catalog(&path); + let owner = FeatureStorage::for_server_database(&path).unwrap(); + let scope = owner.scope("storage-workspace").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let feature_path = temp + .path() + .join("feature-storage/storage-workspace/features/subjektiv.sqlite"); + let subject = legacy_subject( + &temp.path().join("source"), + &feature_path, + "storage-workspace", + ); + assert!( + scope.open(®istration).is_err(), + "path existence is not adoption authority" + ); + catalog + .adopt_workspace_feature_storage(&scope, ®istration, "store_scope") + .unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + assert_eq!(store.subject(&subject).unwrap().unwrap().id, subject); + drop(store); + scope.shutdown().unwrap(); + let owner = FeatureStorage::for_server_database(&path).unwrap(); + let scope = owner.scope("storage-workspace").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + catalog + .adopt_workspace_feature_storage(&scope, ®istration, "store_scope") + .unwrap(); + assert_eq!( + SubjektivStore::open(&scope, ®istration) + .unwrap() + .subject(&subject) + .unwrap() + .unwrap() + .id, + subject + ); +} + +#[test] +fn missing_catalog_or_different_feature_attribution_never_grants_adoption() { + for attribution in ["storage-workspace", "another-workspace"] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let catalog = catalog(&path); + let scope = FeatureStorage::for_server_database(&path) + .unwrap() + .scope("storage-workspace") + .unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let feature_path = temp + .path() + .join("feature-storage/storage-workspace/features/subjektiv.sqlite"); + legacy_subject(&temp.path().join("source"), &feature_path, attribution); + if attribution == "storage-workspace" { + catalog + .with_conn(|conn| { + conn.execute("DELETE FROM workspaces", [])?; + Ok(()) + }) + .unwrap(); + } + assert!( + catalog + .adopt_workspace_feature_storage(&scope, ®istration, "store_scope") + .is_err() + ); + let raw = Connection::open(&feature_path).unwrap(); + let count: i64 = raw + .query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE name='__yoi_storage_identity'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0, "refusal must not mint identity"); + } +} + +#[test] +fn catalog_cached_connection_rejects_replaced_file_and_missing_reopen() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let store = catalog(&path); + let replacement = temp.path().join("replacement.db"); + store + .with_conn(|conn| { + let mut copy = Connection::open(&replacement)?; + Backup::new(conn, &mut copy)?.run_to_completion(128, Duration::from_millis(1), None)?; + Ok(()) + }) + .unwrap(); + std::fs::rename(&path, temp.path().join("old.db")).unwrap(); + std::fs::rename(&replacement, &path).unwrap(); + assert!(store.with_conn(|_| Ok(())).is_err()); + assert!(SqliteWorkspaceStore::open_for_test(&path).is_err()); + std::fs::remove_file(&path).unwrap(); + assert!(SqliteWorkspaceStore::open_for_test(&path).is_err()); + assert!( + !path.exists(), + "bound catalog must not silently CREATE a successor" + ); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 8ef11a11f..f66d5dff5 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -129,6 +129,9 @@ pub use ticket::{ }; const OLDEST_SCHEMA_VERSION: i64 = 50; +#[path = "storage_formats.rs"] +mod storage_formats; + const LATEST_SCHEMA_VERSION: i64 = 113; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; @@ -2785,9 +2788,21 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result>; } +#[derive(Clone)] +enum StoreConnection { + Managed(feature_storage::FeatureDatabase), + Fixture(Arc>), +} + +impl From for Error { + fn from(error: feature_storage::FeatureStorageError) -> Self { + Error::Store(error.to_string()) + } +} + #[derive(Clone)] pub struct SqliteWorkspaceStore { - conn: Arc>, + conn: StoreConnection, db_executor: crate::db_executor::DbExecutor, } @@ -2797,8 +2812,18 @@ pub mod fixture_support; impl SqliteWorkspaceStore { pub fn open(path: impl AsRef) -> Result { - let conn = Connection::open(path)?; - Self::from_connection(conn) + let path = path.as_ref(); + let owner = feature_storage::FeatureStorage::for_server_database(path)?; + let database = owner.open_host_catalog( + path, + "server-catalog", + "Host configured Server database", + |conn| prepare_connection(conn), + )?; + Ok(Self { + conn: StoreConnection::Managed(database), + db_executor: crate::db_executor::DbExecutor::shared(), + }) } pub fn in_memory() -> Result { @@ -2842,7 +2867,7 @@ impl SqliteWorkspaceStore { ) -> Result { prepare_connection(&conn)?; Ok(Self { - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), db_executor, }) } @@ -2851,7 +2876,11 @@ impl SqliteWorkspaceStore { // fixture's admission/SQL/drain lifetime or a permanent global worker. #[cfg(test)] pub(crate) fn open_for_test(path: impl AsRef) -> Result { - Self::from_connection_for_test(Connection::open(path)?) + Ok( + Self::open(path)?.with_db_executor(crate::db_executor::DbExecutor::new( + crate::db_executor::DbExecutorLimits::default(), + )?), + ) } #[cfg(test)] @@ -2878,22 +2907,30 @@ impl SqliteWorkspaceStore { } pub(crate) fn with_conn(&self, f: impl FnOnce(&Connection) -> Result) -> Result { - let conn = self - .conn - .lock() - .map_err(|_| Error::Store("sqlite connection lock poisoned".to_string()))?; - f(&conn) + match &self.conn { + StoreConnection::Managed(database) => database.try_with_connection(f), + StoreConnection::Fixture(connection) => { + let conn = connection + .lock() + .map_err(|_| Error::Store("sqlite connection lock poisoned".into()))?; + f(&conn) + } + } } pub(crate) fn with_conn_mut( &self, f: impl FnOnce(&mut Connection) -> Result, ) -> Result { - let mut conn = self - .conn - .lock() - .map_err(|_| Error::Store("sqlite connection lock poisoned".to_string()))?; - f(&mut conn) + match &self.conn { + StoreConnection::Managed(database) => database.try_with_connection_mut(f), + StoreConnection::Fixture(connection) => { + let mut conn = connection + .lock() + .map_err(|_| Error::Store("sqlite connection lock poisoned".into()))?; + f(&mut conn) + } + } } /// Acquire SQLite write ownership before the first authority/journal read (Restore and @@ -2915,6 +2952,43 @@ impl SqliteWorkspaceStore { }) } + /// The current catalog row authorizes Host storage setup, not model access + /// or past provisioning. Persisted Feature attribution must independently + /// match before adopting a data-bearing legacy file. + pub(crate) fn adopt_workspace_feature_storage( + &self, + storage: &feature_storage::ScopedFeatureStorage, + registration: &feature_storage::RegisteredFeature, + attribution_table: &str, + ) -> Result<()> { + let table = match attribution_table { + "store_scope" => "store_scope", + "drive_state" => "drive_state", + _ => { + return Err(Error::InvalidInput( + "unsupported storage attribution owner".into(), + )); + } + }; + self.with_immediate_transaction(|tx| { + let row: Option<(String,String)> = tx.query_row( + "SELECT owner_account_id,created_at FROM workspaces WHERE workspace_id=?1 AND state='active'",[storage.scope_id()], + |row| Ok((row.get(0)?,row.get(1)?))).optional()?; + let (owner,created) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?; + let evidence = format!("catalog:{}:{owner}:{created}",storage.scope_id()); + let request = format!("legacy-storage:{}:{}",storage.scope_id(),registration.feature_id()); + storage.adopt_legacy_if_present(registration,&request,&evidence,|feature_tx| { + let stored: String = feature_tx.query_row( + &format!("SELECT workspace_id FROM {table} WHERE singleton=1"),[],|row|row.get(0))?; + if stored != storage.scope_id() { + return Err(feature_storage::FeatureStorageError::Identity("legacy Feature attribution differs from authorized Workspace".into())); + } + Ok(()) + })?; + Ok(()) + }) + } + /// Host-only setup; never expose this operation through a public bootstrap route. /// Creating a user, granting hosting authority and issuing its short-lived initial /// bearer are atomic. Selecting an existing user never issues credentials. A repeat @@ -16987,6 +17061,9 @@ fn workspace_schema_migration_history(conn: &Connection) -> Result Result<()> { + if current > LATEST_SCHEMA_VERSION { + return storage_formats::verify_history(conn, current); + } let rows = workspace_schema_migration_history(conn)?; let Some((baseline_version, baseline_name)) = rows.first() else { return Err(Error::Store(format!( @@ -17030,6 +17107,9 @@ fn verify_schema_history(conn: &Connection, current: i64) -> Result<()> { fn workspace_schema_migration_plan(conn: &Connection) -> Result { let current = current_schema_version(conn)?; + if let Some(plan) = storage_formats::published_plan(conn, current)? { + return Ok(plan); + } if current > LATEST_SCHEMA_VERSION { return Err(Error::Store(format!( "database schema version {current} is newer than this server supports ({LATEST_SCHEMA_VERSION}); refusing to serve with an older binary" @@ -17041,7 +17121,7 @@ fn workspace_schema_migration_plan(conn: &Connection) -> Result Result> { .map_err(Error::from) } +#[cfg(test)] +#[path = "storage_owner_tests.rs"] +mod storage_owner_tests; + #[cfg(test)] #[path = "store_test_support.rs"] mod test_support; diff --git a/crates/workspace-server/src/store_test_support.rs b/crates/workspace-server/src/store_test_support.rs index 430c2e09c..a434cb7b6 100644 --- a/crates/workspace-server/src/store_test_support.rs +++ b/crates/workspace-server/src/store_test_support.rs @@ -25,7 +25,7 @@ impl SqliteWorkspaceStore { let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; configure_sqlite(&conn)?; Ok(Self { - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), db_executor: crate::db_executor::DbExecutor::new( crate::db_executor::DbExecutorLimits::default(), )?, @@ -56,7 +56,7 @@ impl SqliteWorkspaceStore { // Existing DBs above still pass through full production startup checks. configure_sqlite(&conn)?; Ok(Self { - conn: Arc::new(Mutex::new(conn)), + conn: StoreConnection::Fixture(Arc::new(Mutex::new(conn))), db_executor: crate::db_executor::DbExecutor::new( crate::db_executor::DbExecutorLimits::default(), )?, diff --git a/crates/workspace-server/src/workspace_drive_host.rs b/crates/workspace-server/src/workspace_drive_host.rs index c072b7dd6..a84761d7b 100644 --- a/crates/workspace-server/src/workspace_drive_host.rs +++ b/crates/workspace-server/src/workspace_drive_host.rs @@ -76,6 +76,14 @@ impl WorkspaceDriveHost { drive: Mutex::new(None), }) } + pub(crate) fn adopt_legacy_storage( + &self, + storage: &WorkspaceFeatureStorage, + catalog: &crate::SqliteWorkspaceStore, + ) -> Result<()> { + catalog.adopt_workspace_feature_storage(storage, &self.registration, "drive_state") + } + pub(crate) async fn open( &self, storage: &WorkspaceFeatureStorage, diff --git a/tools/state-contract-name-exceptions.json b/tools/state-contract-name-exceptions.json index 4a32b44bc..25187bccc 100644 --- a/tools/state-contract-name-exceptions.json +++ b/tools/state-contract-name-exceptions.json @@ -1,4 +1,15 @@ [ + { + "path": "crates/subjektiv/src/storage_format_tests.rs", + "kind": "saved_data", + "reason": "最古対応Subjektiv保存形式のデータ入り移行fixture。旧Subject JSONの保存keyを実形式のまま検証し、移行前原文と移行後データの保持を確認するため残す。現行状態契約の新設ではない。", + "lines": [ + { + "sha256": "5b22a15424eb27bcd68e737cf3549b7ce4231632cdda2bd2d826b4a2c766e270", + "count": 1 + } + ] + }, { "path": "tests/e2e/fixtures/worker-codex-v1/manifest.json", "kind": "saved_data",