feat: bind FeatureStorage identity and restore to persistent owners
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<String> {
|
||||
database
|
||||
.with_connection(|connection| {
|
||||
Ok(connection
|
||||
.prepare("SELECT value FROM records ORDER BY value")?
|
||||
.query_map([], |r| r.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn insert(database: &FeatureDatabase, value: &str) {
|
||||
database
|
||||
.transaction(|tx| {
|
||||
tx.execute("INSERT INTO records VALUES (?1)", [value])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn assert_identity_error<T>(result: Result<T>) {
|
||||
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();
|
||||
}
|
||||
@@ -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<Option<String>> {
|
||||
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
let exists = |name: &str| -> Result<bool> {
|
||||
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<bool> {
|
||||
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::<String>()
|
||||
.to_ascii_lowercase()
|
||||
};
|
||||
if normalized(&sql) != normalized(expected) {
|
||||
return Err(FeatureStorageError::Identity(
|
||||
"legacy physical metadata shape differs".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+698
-114
File diff suppressed because it is too large
Load Diff
@@ -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<File> {
|
||||
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<Connection> {
|
||||
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<bool> {
|
||||
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<Option<Binding>> {
|
||||
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<Binding> {
|
||||
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<Option<String>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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::<std::result::Result<Vec<_>, _>>()?;
|
||||
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::<std::result::Result<Vec<_>, _>>()?;
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Vec<Value>>)>;
|
||||
|
||||
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<Receipt> {
|
||||
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::<std::result::Result<Vec<_>, _>>()
|
||||
.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<String> = connection
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<std::result::Result<Vec<Value>, _>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.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();
|
||||
}
|
||||
@@ -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<String>) -> FeatureStorageError {
|
||||
FeatureStorageError::MigrationHistory {
|
||||
feature: SUBJEKTIV_FEATURE_ID.into(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn recognize(applied: &[(u32, String)]) -> feature_storage::Result<Format> {
|
||||
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<String>);
|
||||
|
||||
fn domain_shape(connection: &Connection) -> feature_storage::Result<Vec<SchemaObject>> {
|
||||
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::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(shape)
|
||||
}
|
||||
|
||||
fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>> {
|
||||
// Cache reference shapes only; every target is reread under its caller's transaction.
|
||||
static SHAPES: [OnceLock<Vec<SchemaObject>>; 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,10 +141,13 @@ pub(super) fn with_authority_database<T>(
|
||||
api: &WorkspaceApi,
|
||||
check: impl FnOnce() -> crate::Result<T>,
|
||||
) -> crate::Result<T> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>)> {
|
||||
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::<rusqlite::Result<Vec<_>>>().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::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.map(|row| format!("{:?}", row.unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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<Option<WorkerWorkdirLinkRecord>>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum StoreConnection {
|
||||
Managed(feature_storage::FeatureDatabase),
|
||||
Fixture(Arc<Mutex<Connection>>),
|
||||
}
|
||||
|
||||
impl From<feature_storage::FeatureStorageError> for Error {
|
||||
fn from(error: feature_storage::FeatureStorageError) -> Self {
|
||||
Error::Store(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteWorkspaceStore {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
conn: StoreConnection,
|
||||
db_executor: crate::db_executor::DbExecutor,
|
||||
}
|
||||
|
||||
@@ -2797,8 +2812,18 @@ pub mod fixture_support;
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
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<Self> {
|
||||
@@ -2842,7 +2867,7 @@ impl SqliteWorkspaceStore {
|
||||
) -> Result<Self> {
|
||||
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<Path>) -> Result<Self> {
|
||||
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<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
|
||||
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<T>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Connection) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
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<Vec<(i64, Str
|
||||
}
|
||||
|
||||
fn verify_schema_history(conn: &Connection, current: i64) -> 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<WorkspaceSchemaMigrationPlan> {
|
||||
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<WorkspaceSchemaM
|
||||
)));
|
||||
}
|
||||
if current != 0 {
|
||||
verify_schema_history(conn, current)?;
|
||||
storage_formats::verify_history(conn, current)?;
|
||||
}
|
||||
|
||||
let migrations = if current == 0 {
|
||||
@@ -17150,7 +17230,7 @@ fn apply_migrations_with_secret_source(
|
||||
}
|
||||
}
|
||||
|
||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||
storage_formats::verify_history(conn, current_schema_version(conn)?)?;
|
||||
crate::plugin_catalog::verify_with_data(conn)?;
|
||||
verify_plugin_data_schema(conn)?;
|
||||
verify_plugin_service_schema(conn)?;
|
||||
@@ -17186,6 +17266,10 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
|
||||
.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;
|
||||
|
||||
@@ -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(),
|
||||
)?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user