feat: replace Subject retirement with durable explicit deletion
This commit is contained in:
@@ -12,3 +12,49 @@ Hosts retain body ownership across inactivity, reserve actual execution quiescen
|
||||
- [`api`]: shared Host operations and bounded responses
|
||||
- [`job`]: consolidation selection rules and result validation
|
||||
- [`journal`]: individually committed activity operations, source coverage, retry receipts, and bounded reading
|
||||
|
||||
## Explicit Subject deletion
|
||||
|
||||
`begin_subject_deletion(subject_id, operation_id)` commits a permanent content-free
|
||||
fence in this store **before** the Host stops execution or erases external content.
|
||||
The operation ID is unique within this storage scope. Exact retries return current
|
||||
saved state; a different key for that Subject or reuse for another Subject conflicts.
|
||||
Pending fences survive restart and a failed purge. Normal reads, writes, saved-receipt
|
||||
replay, cached Host/Journal contexts and authority lookup cannot use a fenced Subject.
|
||||
Subject discovery hides fenced records before pagination. SQL INSERT/UPDATE guards
|
||||
cover direct and indirect owners, checking both OLD and NEW ownership on UPDATE.
|
||||
Deleting a grant never restores non-Subject fallback: content-free Worker identity
|
||||
fences remain, and the Host must consult them before fallback authorization.
|
||||
|
||||
The Host uses `subject_deletion` / `pending_subject_deletions` for recovery and
|
||||
`deletion_subject_workers` / `deletion_subject_sessions` / `deletion_subject_jobs`
|
||||
only as cleanup inventories, not authority or model-visible content. It must fence
|
||||
creation reservations, quiesce Workers and in-flight delivery, purge Job inputs/results
|
||||
and live Job execution copies, and clear resident/derived caches. Source Worker/Session
|
||||
originals and pre-existing archives have independent retention lifecycles: the Workspace
|
||||
Host archives source originals rather than erasing them. Those archives may contain
|
||||
previously injected Memory; Workdirs, Tickets, exports and external provider copies are
|
||||
also outside Subject content erasure. These boundaries must be explained to the user.
|
||||
The domain rechecks after out-of-DB capture callbacks and before returning Job read
|
||||
responses; already returned DTOs cannot be revoked by SQLite. Host lifecycle locking
|
||||
must cover delivery/reinjection of previously fetched content as well as finish.
|
||||
|
||||
Only after external cleanup succeeds may the Host call
|
||||
`finish_subject_deletion(subject_id, operation_id)`. One transaction deletes Subject,
|
||||
Memory/history, staging/evidence, Surface snapshots/runs, permission sources/receipts,
|
||||
child grants, writer/session bindings, Journal content/generations/deliveries/receipts,
|
||||
links, and the Subject's `legacy_record_archive` rows. A purge error rolls back all
|
||||
those deletions without undoing the separately committed fence. Ordinary history
|
||||
retention and explicit Journal Session-erasure rules are unchanged for other Subjects.
|
||||
Retained records contain only deletion operation/time/completion and execution/session
|
||||
identities needed to prevent reuse; existing content-free Session-erasure markers and
|
||||
workspace-only migration metadata remain. Other Subjects and storage scopes are untouched.
|
||||
|
||||
The deletion migration adds fences without erasing or reactivating existing data. Old `retired`
|
||||
JSON/SQL records deserialize as `SubjectState::LegacyRetired` and remain readable
|
||||
until explicitly deleted. There is no current retirement API. Normal Worker deletion
|
||||
still does not implicitly delete Subject memory.
|
||||
|
||||
Completion means logical erasure from domain and Host-managed storage, **not** secure
|
||||
physical overwriting: SQLite free pages, WAL files, filesystem snapshots, backups and
|
||||
external copies can retain bytes under their own retention/erasure policies.
|
||||
|
||||
@@ -116,6 +116,7 @@ impl HostOperationContext {
|
||||
"Host operation context belongs to another storage scope".into(),
|
||||
));
|
||||
}
|
||||
Self::require_subject(store, &self.subject_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -452,3 +452,45 @@ fn query_content_condition_ignores_behavior_and_detects_aba_memory_writes() {
|
||||
Err(OperationError::Conflict(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_deletion_revalidates_cached_body_empty_batch_and_receipt_contexts() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, store) = open(temp.path(), "scope");
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
|
||||
let empty =
|
||||
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
|
||||
.unwrap();
|
||||
store.begin_subject_deletion(&subject.id, "delete").unwrap();
|
||||
for context in [&body, &empty] {
|
||||
for operation in [
|
||||
query(None),
|
||||
Op::ListCandidates(server_api::SubjektivMemoryCandidateListRequest { limit: None }),
|
||||
Op::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusRequest {
|
||||
receipt_id: "receipt".into(),
|
||||
}),
|
||||
Op::ReadCandidate(server_api::SubjektivMemoryCandidateReadRequest {
|
||||
candidate_id: "candidate".into(),
|
||||
}),
|
||||
] {
|
||||
assert!(matches!(
|
||||
execute(&store, context, operation),
|
||||
Err(OperationError::SubjectNotFound(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
store
|
||||
.finish_subject_deletion(&subject.id, "delete")
|
||||
.unwrap();
|
||||
assert!(
|
||||
execute(
|
||||
&store,
|
||||
&empty,
|
||||
Op::ListCandidates(server_api::SubjektivMemoryCandidateListRequest { limit: None })
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Explicit Subject erasure is a two-phase Host operation. Beginning commits a permanent,
|
||||
//! content-free fence before the Host stops/removes Workers and purges Jobs and execution copies.
|
||||
//! Source Worker/Session originals remain under their separate archive lifecycle.
|
||||
//! Failure leaves that fence and the cleanup inventory intact; the Host retries
|
||||
//! the same operation ID. Finishing asserts that external cleanup succeeded and atomically erases
|
||||
//! this store's content. This store cannot verify external Runtime/Job/Session cleanup itself.
|
||||
//!
|
||||
//! Tombstones contain no behavior, permission vectors, Memory, Journal or Session text. Worker
|
||||
//! identities remain permanently fenced so removal of a grant cannot become non-Subject fallback
|
||||
//! authority. Existing Journal Session erasure markers are retained to reject delayed admission;
|
||||
//! they are content-free and are not a second copy of the deleted material.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectDeletion {
|
||||
pub subject_id: String,
|
||||
pub operation_id: String,
|
||||
pub completed: bool,
|
||||
pub started_at: String,
|
||||
}
|
||||
|
||||
/// Host-only cleanup identity, never Job input, output, profile or authorization material.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeletionSubjectJob {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
}
|
||||
|
||||
// All content tables are enumerated deliberately. Indirect tables are erased before their
|
||||
// owner rows, both for FK checks and so their fencing predicates remain resolvable.
|
||||
const INDIRECT: &[(&str, &str)] = &[
|
||||
(
|
||||
"journal_generation_input_deliveries",
|
||||
"generation_id IN (SELECT g.id FROM journal_generations g JOIN journals j ON j.id=g.journal_id WHERE j.subject_id={subject})",
|
||||
),
|
||||
(
|
||||
"legacy_record_archive",
|
||||
"json_extract(row_json, '$.subject_id')={subject}",
|
||||
),
|
||||
(
|
||||
"journal_generation_deliveries",
|
||||
"generation_id IN (SELECT g.id FROM journal_generations g JOIN journals j ON j.id=g.journal_id WHERE j.subject_id={subject})",
|
||||
),
|
||||
(
|
||||
"journal_operations",
|
||||
"journal_id IN (SELECT id FROM journals WHERE subject_id={subject})",
|
||||
),
|
||||
(
|
||||
"journal_generations",
|
||||
"journal_id IN (SELECT id FROM journals WHERE subject_id={subject})",
|
||||
),
|
||||
(
|
||||
"journal_sources",
|
||||
"journal_id IN (SELECT id FROM journals WHERE subject_id={subject})",
|
||||
),
|
||||
(
|
||||
"journal_session_bindings",
|
||||
"binding_id IN (SELECT binding_id FROM journal_writer_bindings WHERE subject_id={subject})",
|
||||
),
|
||||
];
|
||||
const DIRECT: &[&str] = &[
|
||||
"memory_journal_links",
|
||||
"journal_corrections",
|
||||
"journal_entries",
|
||||
"journals",
|
||||
"journal_writer_bindings",
|
||||
"subject_session_attributions",
|
||||
"worker_execution_grants",
|
||||
"subject_permission_receipts",
|
||||
"subject_permissions",
|
||||
"subject_permission_changes",
|
||||
"candidate_decision_receipts",
|
||||
"surface_generation_state",
|
||||
"surface_generation_runs",
|
||||
"surface_snapshot_refs",
|
||||
"surface_snapshot_seals",
|
||||
"surface_snapshots",
|
||||
"staging_resolution_targets",
|
||||
"staging_resolution_seals",
|
||||
"staging_resolutions",
|
||||
"memory_change_candidates",
|
||||
"memory_change_derivations",
|
||||
"memory_change_seals",
|
||||
"memory_changes",
|
||||
"memory_records",
|
||||
"staging_records",
|
||||
"subjects",
|
||||
];
|
||||
|
||||
fn owner_expression(table: &str, row: &str) -> String {
|
||||
match table {
|
||||
"legacy_record_archive" => format!("json_extract({row}.row_json, '$.subject_id')"),
|
||||
"journal_generation_input_deliveries" | "journal_generation_deliveries" => format!(
|
||||
"(SELECT j.subject_id FROM journal_generations g JOIN journals j ON j.id=g.journal_id WHERE g.id={row}.generation_id)"
|
||||
),
|
||||
"journal_operations" | "journal_generations" | "journal_sources" => {
|
||||
format!("(SELECT subject_id FROM journals WHERE id={row}.journal_id)")
|
||||
}
|
||||
"journal_session_bindings" => format!(
|
||||
"(SELECT subject_id FROM journal_writer_bindings WHERE binding_id={row}.binding_id)"
|
||||
),
|
||||
_ => format!("{row}.subject_id"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE subject_deletions (
|
||||
subject_id TEXT PRIMARY KEY,
|
||||
operation_id TEXT NOT NULL UNIQUE,
|
||||
completed INTEGER NOT NULL CHECK(completed IN (0,1)),
|
||||
started_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE subject_deleted_workers (
|
||||
subject_id TEXT NOT NULL REFERENCES subject_deletions(subject_id) ON DELETE RESTRICT,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
PRIMARY KEY(subject_id,runtime_id,worker_id)
|
||||
);
|
||||
CREATE TABLE subject_deleted_sessions (
|
||||
subject_id TEXT NOT NULL REFERENCES subject_deletions(subject_id) ON DELETE RESTRICT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY(subject_id,session_id)
|
||||
);
|
||||
CREATE TRIGGER subject_deleted_sessions_retained BEFORE DELETE ON subject_deleted_sessions
|
||||
BEGIN SELECT RAISE(ABORT, 'deleted Session fences are permanent'); END;
|
||||
CREATE TRIGGER subject_deleted_sessions_immutable BEFORE UPDATE ON subject_deleted_sessions
|
||||
BEGIN SELECT RAISE(ABORT, 'deleted Session fences are immutable'); END;
|
||||
CREATE INDEX subject_deleted_worker_identity ON subject_deleted_workers(runtime_id,worker_id);
|
||||
CREATE TRIGGER subject_deletions_retained BEFORE DELETE ON subject_deletions
|
||||
BEGIN SELECT RAISE(ABORT, 'Subject deletion fences are permanent'); END;
|
||||
CREATE TRIGGER subject_deletions_identity BEFORE UPDATE ON subject_deletions
|
||||
WHEN OLD.subject_id<>NEW.subject_id OR OLD.operation_id<>NEW.operation_id
|
||||
OR OLD.started_at<>NEW.started_at OR OLD.completed>NEW.completed
|
||||
BEGIN SELECT RAISE(ABORT, 'Subject deletion identity is permanent'); END;
|
||||
CREATE TRIGGER subject_deleted_workers_retained BEFORE DELETE ON subject_deleted_workers
|
||||
BEGIN SELECT RAISE(ABORT, 'deleted Worker fences are permanent'); END;
|
||||
CREATE TRIGGER subject_deleted_workers_immutable BEFORE UPDATE ON subject_deleted_workers
|
||||
BEGIN SELECT RAISE(ABORT, 'deleted Worker fences are immutable'); END;",
|
||||
)?;
|
||||
|
||||
// Preserve each existing history-retention condition, adding only the explicit Subject
|
||||
// erasure exception. Frozen earlier migrations are not edited. Normal history deletion
|
||||
// (including another Subject's history) remains forbidden.
|
||||
let triggers = tx
|
||||
.prepare("SELECT name,tbl_name,sql FROM sqlite_master WHERE type='trigger'")?
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
for (name, table, sql) in triggers {
|
||||
if !DIRECT.contains(&table.as_str()) && !INDIRECT.iter().any(|(t, _)| *t == table) {
|
||||
continue;
|
||||
}
|
||||
let upper = sql.to_ascii_uppercase();
|
||||
if !upper.contains("BEFORE DELETE") {
|
||||
continue;
|
||||
}
|
||||
let begin = upper.find("BEGIN").expect("trigger body");
|
||||
let (header, body) = sql.split_at(begin);
|
||||
let exception = format!(
|
||||
"NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id={})",
|
||||
owner_expression(&table, "OLD")
|
||||
);
|
||||
let header = if let Some(when) = header.to_ascii_uppercase().find("WHEN") {
|
||||
format!(
|
||||
"{} WHEN ({}) AND {exception} ",
|
||||
&header[..when],
|
||||
header[when + 4..].trim()
|
||||
)
|
||||
} else {
|
||||
format!("{header} WHEN {exception} ")
|
||||
};
|
||||
tx.execute_batch(&format!("DROP TRIGGER \"{name}\"; {header}{body}"))?;
|
||||
}
|
||||
for table in DIRECT
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(INDIRECT.iter().map(|(t, _)| *t))
|
||||
.chain(["journal_erased_sessions"])
|
||||
{
|
||||
for action in ["INSERT", "UPDATE"] {
|
||||
let new_owner = owner_expression(table, "NEW");
|
||||
let predicate = if action == "UPDATE" {
|
||||
format!(
|
||||
"subject_id={new_owner} OR subject_id={}",
|
||||
owner_expression(table, "OLD")
|
||||
)
|
||||
} else {
|
||||
format!("subject_id={new_owner}")
|
||||
};
|
||||
tx.execute_batch(&format!(
|
||||
"CREATE TRIGGER deletion_fence_{table}_{action} BEFORE {action} ON {table}
|
||||
WHEN EXISTS(SELECT 1 FROM subject_deletions WHERE {predicate})
|
||||
BEGIN SELECT RAISE(ABORT, 'Subject deletion has begun'); END;"
|
||||
))?;
|
||||
}
|
||||
}
|
||||
// Identity reuse must not reconnect purged content or turn a deleted child into
|
||||
// an unscoped Worker. Check both old and new on ownership-changing UPDATEs.
|
||||
for table in [
|
||||
"subject_session_attributions",
|
||||
"journal_writer_bindings",
|
||||
"worker_execution_grants",
|
||||
] {
|
||||
for action in ["INSERT", "UPDATE"] {
|
||||
let rows = if action == "UPDATE" {
|
||||
vec!["OLD", "NEW"]
|
||||
} else {
|
||||
vec!["NEW"]
|
||||
};
|
||||
let mut predicates = Vec::new();
|
||||
for row in rows {
|
||||
predicates.push(format!("EXISTS(SELECT 1 FROM subject_deleted_workers WHERE runtime_id={row}.runtime_id AND worker_id={row}.worker_id)"));
|
||||
if table == "subject_session_attributions" {
|
||||
predicates.push(format!("EXISTS(SELECT 1 FROM subject_deleted_sessions WHERE session_id={row}.session_id)"));
|
||||
}
|
||||
if table == "worker_execution_grants" {
|
||||
predicates.push(format!("EXISTS(SELECT 1 FROM subject_deleted_workers WHERE runtime_id=json_extract({row}.record_json,'$.origin_runtime_id') AND worker_id=json_extract({row}.record_json,'$.origin_worker_id'))"));
|
||||
}
|
||||
}
|
||||
tx.execute_batch(&format!("CREATE TRIGGER deletion_identity_{table}_{action} BEFORE {action} ON {table} WHEN {} BEGIN SELECT RAISE(ABORT, 'deleted Subject execution identity'); END;", predicates.join(" OR ")))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_deleting(connection: &Connection, subject: &str) -> Result<bool> {
|
||||
// Older migration fixtures deliberately use current Rust reads before upgrade. Production
|
||||
// opens only registered current stores and must fail closed if the fence table is absent.
|
||||
#[cfg(test)]
|
||||
if !connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='subject_deletions' AND type='table')", [], |r|r.get::<_,bool>(0))? {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(connection.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=?1)",
|
||||
[subject],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(crate) fn require_not_deleting(connection: &Connection, subject: &str) -> Result<()> {
|
||||
if is_deleting(connection, subject)? {
|
||||
return Err(SubjektivError::SubjectNotFound(subject.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn require_live_worker(
|
||||
connection: &Connection,
|
||||
runtime: &str,
|
||||
worker: &str,
|
||||
) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
if !connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='subject_deleted_workers' AND type='table')", [], |r|r.get::<_,bool>(0))? { return Ok(()); }
|
||||
if connection.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM subject_deleted_workers WHERE runtime_id=?1 AND worker_id=?2)",
|
||||
params![runtime, worker],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)? {
|
||||
return Err(SubjektivError::AuthorizationDenied(
|
||||
"Worker belongs to a deleted Subject".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn deleted_session_subject(
|
||||
connection: &Connection,
|
||||
session: &str,
|
||||
) -> Result<Option<String>> {
|
||||
#[cfg(test)]
|
||||
if !connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='subject_deleted_sessions' AND type='table')", [], |r|r.get::<_,bool>(0))? { return Ok(None); }
|
||||
Ok(connection
|
||||
.query_row(
|
||||
"SELECT subject_id FROM subject_deleted_sessions WHERE session_id=?1",
|
||||
[session],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn deletion(connection: &Connection, subject: &str) -> Result<Option<SubjectDeletion>> {
|
||||
Ok(connection.query_row("SELECT subject_id,operation_id,completed,started_at FROM subject_deletions WHERE subject_id=?1", [subject], |r|Ok(SubjectDeletion { subject_id:r.get(0)?, operation_id:r.get(1)?, completed:r.get(2)?, started_at:r.get(3)? })).optional()?)
|
||||
}
|
||||
fn require_deletion(connection: &Connection, subject: &str) -> Result<SubjectDeletion> {
|
||||
deletion(connection, subject)?.ok_or_else(|| SubjektivError::SubjectNotFound(subject.into()))
|
||||
}
|
||||
|
||||
impl SubjektivStore {
|
||||
/// Commits the read/write/authority fence, even for legacy-retired Subjects. Operation IDs
|
||||
/// are unique within this store's scope; neither another key nor another Subject may reuse
|
||||
/// a deletion. Exact retries return the saved current result, including completed deletions.
|
||||
pub fn begin_subject_deletion(
|
||||
&self,
|
||||
subject: &str,
|
||||
operation: &str,
|
||||
) -> Result<SubjectDeletion> {
|
||||
validate_label("subject id", subject)?;
|
||||
validate_label("deletion operation id", operation)?;
|
||||
self.database.try_transaction(|tx| {
|
||||
if let Some(saved) = deletion(tx,subject)? {
|
||||
return if saved.operation_id == operation { Ok(saved) } else { Err(SubjektivError::SubjectDeletionConflict(operation.into())) };
|
||||
}
|
||||
if tx.query_row("SELECT EXISTS(SELECT 1 FROM subject_deletions WHERE operation_id=?1)",[operation],|r|r.get::<_,bool>(0))? {
|
||||
return Err(SubjektivError::SubjectDeletionConflict(operation.into()));
|
||||
}
|
||||
require_subject(tx,subject)?;
|
||||
tx.execute("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,?2,0,?3)",params![subject,operation,now()])?;
|
||||
// Capture bodies, historical attribution, delegated children, and grant origins.
|
||||
// This transaction also fences delayed grants/attributions, so no new identity can
|
||||
// appear after the inventory is frozen. Runtime create reservations remain Host-owned.
|
||||
tx.execute("INSERT INTO subject_deleted_workers(subject_id,runtime_id,worker_id)
|
||||
SELECT subject_id,runtime_id,worker_id FROM subject_session_attributions WHERE subject_id=?1
|
||||
UNION SELECT subject_id,runtime_id,worker_id FROM journal_writer_bindings WHERE subject_id=?1
|
||||
UNION SELECT subject_id,runtime_id,worker_id FROM worker_execution_grants WHERE subject_id=?1
|
||||
UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM worker_execution_grants WHERE subject_id=?1",[subject])?;
|
||||
tx.execute("INSERT INTO subject_deleted_sessions(subject_id,session_id)
|
||||
SELECT subject_id,session_id FROM subject_session_attributions WHERE subject_id=?1
|
||||
UNION SELECT subject_id,source_session_id FROM journals WHERE subject_id=?1
|
||||
UNION SELECT subject_id,session_id FROM journal_erased_sessions WHERE subject_id=?1", [subject])?;
|
||||
require_deletion(tx,subject)
|
||||
})
|
||||
}
|
||||
|
||||
/// Host-only, content-free cleanup inventory. Usable after begin, including exact finish
|
||||
/// retries. Tombstones survive finish to prevent non-Subject permission fallback.
|
||||
pub fn deletion_subject_workers(&self, subject: &str) -> Result<Vec<(String, String)>> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
require_deletion(tx,subject)?;
|
||||
Ok(tx.prepare("SELECT runtime_id,worker_id FROM subject_deleted_workers WHERE subject_id=?1 ORDER BY runtime_id,worker_id")?.query_map([subject],|r|Ok((r.get(0)?,r.get(1)?)))?.collect::<std::result::Result<Vec<_>,_>>()?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Host-only Session identities; retained source rows are never exposed through this path.
|
||||
/// The Host must erase originals/archives before finish removes attribution.
|
||||
pub fn deletion_subject_sessions(&self, subject: &str) -> Result<Vec<String>> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
require_deletion(tx,subject)?;
|
||||
Ok(tx.prepare("SELECT session_id FROM subject_deleted_sessions WHERE subject_id=?1 ORDER BY session_id")?.query_map([subject],|r|r.get(0))?.collect::<std::result::Result<Vec<_>,_>>()?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Host-only Job cleanup inventory. Job storage owns inputs, outcomes and Worker archives;
|
||||
/// this store retains only these identities until Host cleanup has succeeded.
|
||||
pub fn deletion_subject_jobs(&self, subject: &str) -> Result<Vec<DeletionSubjectJob>> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
require_deletion(tx,subject)?;
|
||||
Ok(tx.prepare("SELECT job_id,attempt_id FROM surface_generation_runs WHERE subject_id=?1 AND job_id IS NOT NULL AND attempt_id IS NOT NULL UNION SELECT g.job_id,g.attempt_id FROM journal_generations g JOIN journals j ON j.id=g.journal_id WHERE j.subject_id=?1 ORDER BY 1,2")?.query_map([subject],|r|Ok(DeletionSubjectJob {job_id:r.get(0)?,attempt_id:r.get(1)?}))?.collect::<std::result::Result<Vec<_>,_>>()?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Host-only recovery state; this never exposes Subject content or authority.
|
||||
pub fn subject_deletion(&self, subject: &str) -> Result<Option<SubjectDeletion>> {
|
||||
self.database
|
||||
.try_with_connection(|tx| deletion(tx, subject))
|
||||
}
|
||||
|
||||
/// Durable unfinished operations, including after Host restart. Completed tombstones
|
||||
/// remain queryable individually but are not returned as pending work.
|
||||
pub fn pending_subject_deletions(&self) -> Result<Vec<SubjectDeletion>> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
Ok(tx.prepare("SELECT subject_id,operation_id,completed,started_at FROM subject_deletions WHERE completed=0 ORDER BY started_at,subject_id")?
|
||||
.query_map([], |r| Ok(SubjectDeletion { subject_id:r.get(0)?, operation_id:r.get(1)?, completed:r.get(2)?, started_at:r.get(3)? }))?
|
||||
.collect::<std::result::Result<Vec<_>,_>>()?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn worker_subject_deleted(&self, runtime: &str, worker: &str) -> Result<bool> {
|
||||
self.database.try_with_connection(|tx| Ok(tx.query_row("SELECT EXISTS(SELECT 1 FROM subject_deleted_workers WHERE runtime_id=?1 AND worker_id=?2)",params![runtime,worker],|r|r.get(0))?))
|
||||
}
|
||||
|
||||
/// Host confirmation that external cleanup is complete. All Subject content and completed
|
||||
/// receipt commit together. Deferred FKs allow cyclic current/history references to disappear
|
||||
/// without weakening FK enforcement outside this transaction. Failure rolls back all erasure.
|
||||
pub fn finish_subject_deletion(
|
||||
&self,
|
||||
subject: &str,
|
||||
operation: &str,
|
||||
) -> Result<SubjectDeletion> {
|
||||
self.database.try_transaction(|tx| {
|
||||
let saved = require_deletion(tx, subject)?;
|
||||
if saved.operation_id != operation {
|
||||
return Err(SubjektivError::SubjectDeletionConflict(operation.into()));
|
||||
}
|
||||
if saved.completed {
|
||||
return Ok(saved);
|
||||
}
|
||||
tx.execute_batch("PRAGMA defer_foreign_keys=ON;")?;
|
||||
for (table, predicate) in INDIRECT {
|
||||
tx.execute(
|
||||
&format!(
|
||||
"DELETE FROM {table} WHERE {}",
|
||||
predicate.replace("{subject}", "?1")
|
||||
),
|
||||
[subject],
|
||||
)?;
|
||||
}
|
||||
for table in DIRECT {
|
||||
tx.execute(
|
||||
&format!("DELETE FROM {table} WHERE subject_id=?1"),
|
||||
[subject],
|
||||
)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE subject_deletions SET completed=1 WHERE subject_id=?1",
|
||||
[subject],
|
||||
)?;
|
||||
require_deletion(tx, subject)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,489 @@
|
||||
use super::*;
|
||||
use crate::tests::{candidate, legacy_retired_fixture, open_store, role};
|
||||
use feature_storage::FeatureStorage;
|
||||
use memory::extract::CandidateKind;
|
||||
|
||||
fn draft() -> MemoryDraft {
|
||||
MemoryDraft::active(
|
||||
CandidateKind::Lesson,
|
||||
"private claim",
|
||||
"private body",
|
||||
"useful",
|
||||
"initial",
|
||||
)
|
||||
}
|
||||
|
||||
fn count(store: &SubjektivStore, table: &str) -> i64 {
|
||||
store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| {
|
||||
Ok(c.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_only_denies_content_reads_writes_and_exact_replay_before_purge() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store
|
||||
.create_subject_with_behavior(role(), "private behavior".into())
|
||||
.unwrap();
|
||||
let id = &subject.id;
|
||||
let attribution = SubjectSessionAttribution::new(id, "runtime", "worker", "session").unwrap();
|
||||
store
|
||||
.record_session_attribution(attribution.clone())
|
||||
.unwrap();
|
||||
let staged = candidate(id, "candidate", store.workspace_id());
|
||||
store.stage_candidate(staged.clone()).unwrap();
|
||||
let request = CandidateDecisionRequest {
|
||||
request_id: "decision".into(),
|
||||
candidate_id: "candidate".into(),
|
||||
reason: "adopt".into(),
|
||||
decision: CandidateDecision::Apply {
|
||||
target: MemoryChangeTarget::Create,
|
||||
draft: draft(),
|
||||
},
|
||||
};
|
||||
let memory = store
|
||||
.decide_candidate(id, request.clone())
|
||||
.unwrap()
|
||||
.memory
|
||||
.unwrap();
|
||||
let generation = store
|
||||
.prepare_job_surface_generation(id, Some(("job", "attempt")))
|
||||
.unwrap();
|
||||
let snapshot = store
|
||||
.publish_surface_generation(
|
||||
id,
|
||||
&generation.id,
|
||||
vec![SurfacePoint {
|
||||
body_md: "private surface".into(),
|
||||
memory_refs: vec![MemoryChangeRef {
|
||||
memory_id: memory.id.clone(),
|
||||
change_id: memory.change_id.clone(),
|
||||
}],
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let fence = store.begin_subject_deletion(id, "delete").unwrap();
|
||||
assert!(!fence.completed);
|
||||
// The fence must work while every body remains physically present.
|
||||
assert_eq!(count(&store, "subjects"), 1);
|
||||
assert_eq!(count(&store, "memory_records"), 1);
|
||||
assert_eq!(count(&store, "candidate_decision_receipts"), 1);
|
||||
assert!(store.subject(id).unwrap().is_none());
|
||||
assert!(store.list_subjects(1).unwrap().items.is_empty());
|
||||
macro_rules! denied { ($($result:expr),+ $(,)?) => { $(assert!($result.is_err(), stringify!($result));)+ }; }
|
||||
denied!(
|
||||
store.session_attribution("session"),
|
||||
store.subject_session_attributions(id),
|
||||
store.subject_session_attribution_page(id, None, "2999", None, 1),
|
||||
store.record_session_attribution(attribution),
|
||||
store.staging_candidate(id, "candidate"),
|
||||
store.pending_staging_candidates(id, 1),
|
||||
store.pending_staging_backlog(id),
|
||||
store.staging_resolution(id, "candidate"),
|
||||
store.memory(id, &memory.id),
|
||||
store.memory_change(id, &memory.id, memory.change_id.clone()),
|
||||
store.scoped_memory(id, &memory.id),
|
||||
store.scoped_memory_change(id, &memory.id, memory.change_id.clone()),
|
||||
store.list_memories(id),
|
||||
store.list_memory_changes(id, &memory.id),
|
||||
store.memory_fingerprint(id),
|
||||
store.surface_snapshot(id, &snapshot.id),
|
||||
store.resident_surface(id),
|
||||
store.resident_context(id),
|
||||
store.require_job_surface_generation(id, &generation.id, "job", "attempt"),
|
||||
store.validate_job_surface_outcome(
|
||||
id,
|
||||
&generation.id,
|
||||
generation.memory_fingerprint,
|
||||
"ready",
|
||||
Some(&snapshot.id),
|
||||
None,
|
||||
"job",
|
||||
"attempt"
|
||||
),
|
||||
store.prepare_surface_generation(id),
|
||||
store.publish_surface_generation(id, &generation.id, vec![]),
|
||||
store.fail_surface_generation(id, &generation.id, "failed"),
|
||||
store.update_subject_behavior(id, &subject.behavior_md, "late behavior".into()),
|
||||
store.stage_candidate(staged),
|
||||
store.decide_candidate(id, request),
|
||||
store.create_memory(id, draft()),
|
||||
store.subject_permissions(id),
|
||||
store.list_subject_worker_execution_grants(id),
|
||||
);
|
||||
assert_eq!(
|
||||
store.deletion_subject_workers(id).unwrap(),
|
||||
vec![("runtime".into(), "worker".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
store.deletion_subject_sessions(id).unwrap(),
|
||||
vec!["session"]
|
||||
);
|
||||
assert_eq!(store.deletion_subject_jobs(id).unwrap()[0].job_id, "job");
|
||||
assert!(store.worker_subject_deleted("runtime", "worker").unwrap());
|
||||
assert!(
|
||||
store
|
||||
.finish_subject_deletion(id, "delete")
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
for table in DIRECT {
|
||||
assert_eq!(count(&store, table), 0, "{table}");
|
||||
}
|
||||
assert!(store.worker_execution_grant("runtime", "worker").is_err());
|
||||
assert_eq!(
|
||||
store.deletion_subject_sessions(id).unwrap(),
|
||||
vec!["session"]
|
||||
);
|
||||
assert_eq!(
|
||||
store.finish_subject_deletion(id, "delete").unwrap(),
|
||||
store.begin_subject_deletion(id, "delete").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_failure_rolls_back_content_but_not_the_committed_fence_and_restart_recovers() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let id: String;
|
||||
let pending;
|
||||
{
|
||||
let (manager, scope, store) = open_store(temp.path(), "workspace-a");
|
||||
id = store.create_subject(role()).unwrap().id;
|
||||
store.create_memory(&id, draft()).unwrap();
|
||||
pending = store.begin_subject_deletion(&id, "delete").unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||
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());
|
||||
assert_eq!(count(&store, "memory_records"), 1);
|
||||
assert_eq!(count(&store, "memory_changes"), 1);
|
||||
assert_eq!(store.subject_deletion(&id).unwrap(), Some(pending.clone()));
|
||||
drop(store);
|
||||
drop(scope);
|
||||
manager.shutdown().unwrap();
|
||||
}
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
assert_eq!(
|
||||
store.pending_subject_deletions().unwrap(),
|
||||
vec![pending.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
store.begin_subject_deletion(&id, "delete").unwrap(),
|
||||
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")
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
assert!(store.pending_subject_deletions().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_identity_conflicts_are_scope_wide_but_do_not_cross_workspaces() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let a = store.create_subject(role()).unwrap().id;
|
||||
let b = store.create_subject(role()).unwrap().id;
|
||||
let saved = store.begin_subject_deletion(&a, "same-key").unwrap();
|
||||
assert_eq!(store.begin_subject_deletion(&a, "same-key").unwrap(), saved);
|
||||
for result in [
|
||||
store.begin_subject_deletion(&a, "different"),
|
||||
store.begin_subject_deletion(&b, "same-key"),
|
||||
store.finish_subject_deletion(&a, "different"),
|
||||
] {
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(SubjektivError::SubjectDeletionConflict(_))
|
||||
));
|
||||
}
|
||||
assert!(store.begin_subject_deletion("absent", "new-key").is_err());
|
||||
assert!(store.finish_subject_deletion(&b, "same-key").is_err());
|
||||
let page = store.list_subjects(1).unwrap();
|
||||
assert_eq!(page.items[0].id, b);
|
||||
assert!(!page.has_more);
|
||||
store.finish_subject_deletion(&a, "same-key").unwrap();
|
||||
store.create_memory(&b, draft()).unwrap();
|
||||
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-b");
|
||||
assert!(other.subject_deletion(&a).unwrap().is_none());
|
||||
assert!(other.begin_subject_deletion(&b, "same-key").is_err());
|
||||
let c = other.create_subject(role()).unwrap().id;
|
||||
other.begin_subject_deletion(&c, "same-key").unwrap();
|
||||
assert_eq!(store.list_memories(&b).unwrap().len(), 1);
|
||||
let fresh = store.create_subject(role()).unwrap().id;
|
||||
assert!(store.list_memories(&fresh).unwrap().is_empty());
|
||||
assert!(store.subject_permissions(&fresh).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_begin_and_write_serialize_and_exact_finish_is_replayable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let id = store.create_subject(role()).unwrap().id;
|
||||
let barrier = std::sync::Barrier::new(3);
|
||||
std::thread::scope(|scope| {
|
||||
let a = scope.spawn(|| {
|
||||
barrier.wait();
|
||||
store.begin_subject_deletion(&id, "delete")
|
||||
});
|
||||
let b = scope.spawn(|| {
|
||||
barrier.wait();
|
||||
store.create_memory(&id, draft())
|
||||
});
|
||||
barrier.wait();
|
||||
assert!(!a.join().unwrap().unwrap().completed);
|
||||
// A write either precedes begin and is later purged, or loses to the fence.
|
||||
let _ = b.join().unwrap();
|
||||
});
|
||||
assert!(store.create_memory(&id, draft()).is_err());
|
||||
assert_eq!(store.pending_subject_deletions().unwrap().len(), 1);
|
||||
store.finish_subject_deletion(&id, "delete").unwrap();
|
||||
assert_eq!(count(&store, "memory_records"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let a = store.create_subject(role()).unwrap().id;
|
||||
let b = store.create_subject(role()).unwrap().id;
|
||||
store
|
||||
.stage_candidate(candidate(&a, "a", store.workspace_id()))
|
||||
.unwrap();
|
||||
store
|
||||
.stage_candidate(candidate(&b, "b", store.workspace_id()))
|
||||
.unwrap();
|
||||
store.begin_subject_deletion(&a, "delete").unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||
let tables = c.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")?.query_map([], |r| r.get::<_,String>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||
for table in tables {
|
||||
let columns = c.prepare(&format!("PRAGMA table_info({table})"))?.query_map([], |r| r.get::<_,String>(1))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||
if !columns.iter().any(|v| v == "subject_id") || table.starts_with("subject_delet") { continue; }
|
||||
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions", "unclassified Subject table: {table}");
|
||||
}
|
||||
for table in DIRECT.iter().copied().chain(INDIRECT.iter().map(|v| v.0)).chain(["journal_erased_sessions"]) {
|
||||
let sql: String = c.query_row("SELECT sql FROM sqlite_master WHERE name=?1", [format!("deletion_fence_{table}_UPDATE")], |r| r.get(0))?;
|
||||
assert!(sql.contains("OLD.") && sql.contains("NEW."), "{table}: {sql}");
|
||||
}
|
||||
// OLD fenced -> NEW unfenced must not evade a guard.
|
||||
assert!(c.execute("UPDATE subjects SET subject_id=?2 WHERE subject_id=?1", params![a, "new-id"]).is_err());
|
||||
assert!(c.execute("UPDATE subjects SET subject_id=?2 WHERE subject_id=?1", params![b, a]).is_err());
|
||||
assert!(c.execute("DELETE FROM staging_records WHERE subject_id=?1", [&b]).is_err());
|
||||
assert!(c.execute("DELETE FROM subject_deletions WHERE subject_id=?1", [&a]).is_err());
|
||||
assert!(c.execute("UPDATE subject_deletions SET operation_id='changed' WHERE subject_id=?1", [&a]).is_err());
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
store.finish_subject_deletion(&a, "delete").unwrap();
|
||||
assert!(store.staging_candidate(&b, "b").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_retired_is_readable_serde_compatible_and_explicitly_deletable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let id = store.create_subject(role()).unwrap().id;
|
||||
store.create_memory(&id, draft()).unwrap();
|
||||
legacy_retired_fixture(&store, &id).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SubjectState>("\"retired\"").unwrap(),
|
||||
SubjectState::LegacyRetired
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&SubjectState::LegacyRetired).unwrap(),
|
||||
"\"retired\""
|
||||
);
|
||||
assert_eq!(store.list_memories(&id).unwrap().len(), 1);
|
||||
assert!(store.create_memory(&id, draft()).is_err());
|
||||
store.begin_subject_deletion(&id, "delete-legacy").unwrap();
|
||||
store.finish_subject_deletion(&id, "delete-legacy").unwrap();
|
||||
assert_eq!(count(&store, "memory_changes"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_preserves_legacy_content_until_explicit_delete_including_archive() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let id: String;
|
||||
{
|
||||
let manager = FeatureStorage::new(temp.path());
|
||||
let scope = manager.workspace("workspace-a").unwrap();
|
||||
let registration = scope
|
||||
.register(FeatureRegistration::new(
|
||||
SUBJEKTIV_FEATURE_ID,
|
||||
&MIGRATIONS[..6],
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
id = "legacy-subject".into();
|
||||
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||
let raw = serde_json::json!({"schema_version":1,"id":id,"role":"workspace_companion","behavior_md":"legacy private behavior","state":"active","created_at":"2026-01-01","updated_at":"2026-01-01"}).to_string();
|
||||
tx.execute("INSERT INTO subjects(subject_id,role,state,store_revision,record_json,created_at,updated_at) VALUES(?1,'workspace_companion','active',0,?2,'2026-01-01','2026-01-01')", params![id,raw])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
// Build a saved SQL fixture rather than using current-only APIs against an old schema.
|
||||
store
|
||||
.database
|
||||
.try_transaction::<_, SubjektivError>(|tx| {
|
||||
let raw: String = tx.query_row(
|
||||
"SELECT record_json FROM subjects WHERE subject_id=?1",
|
||||
[&id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let mut value: serde_json::Value = serde_json::from_str(&raw)?;
|
||||
value["state"] = "retired".into();
|
||||
tx.execute(
|
||||
"UPDATE subjects SET state='retired',record_json=?2 WHERE subject_id=?1",
|
||||
params![id, value.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(store);
|
||||
drop(scope);
|
||||
manager.shutdown().unwrap();
|
||||
}
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
assert_eq!(
|
||||
store.subject(&id).unwrap().unwrap().state,
|
||||
SubjectState::LegacyRetired
|
||||
);
|
||||
assert!(count(&store, "legacy_record_archive") > 1);
|
||||
store.begin_subject_deletion(&id, "delete").unwrap();
|
||||
store.finish_subject_deletion(&id, "delete").unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||
let remaining: i64 = c.query_row("SELECT COUNT(*) FROM legacy_record_archive WHERE json_extract(row_json,'$.subject_id')=?1", [&id], |r|r.get(0))?;
|
||||
assert_eq!(remaining, 0);
|
||||
// Workspace-only metadata remains and is not a copy of the Subject's content.
|
||||
assert_eq!(c.query_row("SELECT COUNT(*) FROM legacy_record_archive WHERE source_table='store_scope'", [], |r|r.get::<_,i64>(0))?, 1);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_deletions_on_independent_connections_save_one_request_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let (_second_manager, _second_scope, second) = open_store(temp.path(), "workspace-a");
|
||||
let id = store.create_subject(role()).unwrap().id;
|
||||
let barrier = std::sync::Barrier::new(3);
|
||||
std::thread::scope(|scope| {
|
||||
let a = scope.spawn(|| {
|
||||
barrier.wait();
|
||||
store.begin_subject_deletion(&id, "first")
|
||||
});
|
||||
let b = scope.spawn(|| {
|
||||
barrier.wait();
|
||||
second.begin_subject_deletion(&id, "second")
|
||||
});
|
||||
barrier.wait();
|
||||
let results = [a.join().unwrap(), b.join().unwrap()];
|
||||
assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1);
|
||||
assert_eq!(
|
||||
results
|
||||
.iter()
|
||||
.filter(|r| matches!(r, Err(SubjektivError::SubjectDeletionConflict(_))))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
});
|
||||
let saved = second.subject_deletion(&id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.begin_subject_deletion(&id, &saved.operation_id)
|
||||
.unwrap(),
|
||||
saved
|
||||
);
|
||||
std::thread::scope(|scope| {
|
||||
let a = scope.spawn(|| store.finish_subject_deletion(&id, &saved.operation_id));
|
||||
let b = scope.spawn(|| second.finish_subject_deletion(&id, &saved.operation_id));
|
||||
assert_eq!(a.join().unwrap().unwrap(), b.join().unwrap().unwrap());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_migration_failure_rolls_back_fences_and_trigger_rewrites_then_retries() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let id;
|
||||
{
|
||||
let manager = FeatureStorage::new(temp.path());
|
||||
let scope = manager.workspace("workspace-a").unwrap();
|
||||
let registration = scope
|
||||
.register(FeatureRegistration::new(
|
||||
SUBJEKTIV_FEATURE_ID,
|
||||
&MIGRATIONS[..13],
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
id = store.create_subject(role()).unwrap().id;
|
||||
store
|
||||
.stage_candidate(candidate(&id, "preserved", store.workspace_id()))
|
||||
.unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||
// Fail near the end, after retention triggers have already been rewritten.
|
||||
c.execute_batch("CREATE TRIGGER deletion_fence_journal_sources_INSERT BEFORE INSERT ON journal_sources BEGIN SELECT 1; END;")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
drop(store);
|
||||
drop(scope);
|
||||
manager.shutdown().unwrap();
|
||||
}
|
||||
let manager = FeatureStorage::new(temp.path());
|
||||
let scope = manager.workspace("workspace-a").unwrap();
|
||||
let current = SubjektivStore::register(&scope).unwrap();
|
||||
assert!(SubjektivStore::open(&scope, ¤t).is_err());
|
||||
drop(scope);
|
||||
manager.shutdown().unwrap();
|
||||
let manager = FeatureStorage::new(temp.path());
|
||||
let scope = manager.workspace("workspace-a").unwrap();
|
||||
// Reopen only the fixture's pre-deletion registration to inspect the failed migration.
|
||||
let old = scope
|
||||
.register(FeatureRegistration::new(
|
||||
SUBJEKTIV_FEATURE_ID,
|
||||
&MIGRATIONS[..13],
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&scope, &old).unwrap();
|
||||
store
|
||||
.database
|
||||
.try_with_connection::<_, SubjektivError>(|c| {
|
||||
assert!(!c.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='subject_deletions')",
|
||||
[],
|
||||
|r| r.get::<_, bool>(0)
|
||||
)?);
|
||||
let sql: String = c.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE name='staging_records_no_delete'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert!(!sql.contains("subject_deletions"));
|
||||
assert!(
|
||||
c.execute("DELETE FROM staging_records WHERE subject_id=?1", [&id])
|
||||
.is_err()
|
||||
);
|
||||
c.execute_batch("DROP TRIGGER deletion_fence_journal_sources_INSERT;")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(store);
|
||||
drop(scope);
|
||||
manager.shutdown().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
assert!(store.staging_candidate(&id, "preserved").unwrap().is_some());
|
||||
assert!(store.pending_subject_deletions().unwrap().is_empty());
|
||||
store.begin_subject_deletion(&id, "delete").unwrap();
|
||||
store.finish_subject_deletion(&id, "delete").unwrap();
|
||||
}
|
||||
@@ -39,6 +39,7 @@ fn denied() -> JournalError {
|
||||
}
|
||||
|
||||
pub(super) fn check_writer(tx: &Connection, host: &JournalHostContext) -> JournalResult<()> {
|
||||
crate::require_subject_in_connection(tx, &host.subject_id)?;
|
||||
let attr = host.attribution.as_ref().ok_or_else(denied)?;
|
||||
let binding = host.writer_binding_id.as_ref().ok_or_else(denied)?;
|
||||
if !tx.query_row("SELECT EXISTS(SELECT 1 FROM journal_writer_bindings b JOIN journal_session_bindings s ON s.binding_id=b.binding_id WHERE b.binding_id=?1 AND b.subject_id=?2 AND b.runtime_id=?3 AND b.worker_id=?4 AND b.active=1 AND s.session_id=?5)", params![binding,host.subject_id,attr.runtime_id,attr.worker_id,attr.session_id], |r| r.get::<_,bool>(0))? {
|
||||
@@ -63,6 +64,7 @@ impl SubjektivStore {
|
||||
binding: &JournalWriterBinding,
|
||||
) -> JournalResult<Option<String>> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
crate::require_subject_in_connection(tx, &binding.subject_id)?;
|
||||
Ok(tx.query_row("SELECT j.source_session_id FROM journals j JOIN journal_session_bindings s ON s.session_id=j.source_session_id WHERE j.subject_id=?1 AND s.binding_id=(SELECT binding_id FROM journal_writer_bindings WHERE subject_id=?1 AND active=0 ORDER BY rowid DESC LIMIT 1) ORDER BY j.updated_at DESC,j.id DESC LIMIT 1", [&binding.subject_id], |r| r.get(0)).optional()?)
|
||||
})
|
||||
}
|
||||
@@ -77,6 +79,7 @@ impl SubjektivStore {
|
||||
worker: &str,
|
||||
) -> JournalResult<bool> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
crate::require_subject_in_connection(tx, subject)?;
|
||||
Ok(tx.query_row(
|
||||
"SELECT NOT EXISTS(SELECT 1 FROM journal_writer_bindings
|
||||
WHERE subject_id=?1 AND runtime_id=?2 AND worker_id=?3)
|
||||
@@ -95,12 +98,14 @@ impl SubjektivStore {
|
||||
worker: &str,
|
||||
) -> JournalResult<bool> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
crate::require_subject_in_connection(tx, subject)?;
|
||||
Ok(tx.query_row("SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE subject_id=?1 AND runtime_id=?2 AND worker_id=?3 AND active=0)", params![subject,runtime,worker], |r| r.get(0))?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn journal_writer_active(&self, binding: &JournalWriterBinding) -> JournalResult<bool> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
crate::require_subject_in_connection(tx, &binding.subject_id)?;
|
||||
Ok(tx.query_row("SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE binding_id=?1 AND subject_id=?2 AND runtime_id=?3 AND worker_id=?4 AND active=1)", params![binding.binding_id,binding.subject_id,binding.runtime_id,binding.worker_id], |r| r.get(0))?)
|
||||
})
|
||||
}
|
||||
@@ -111,6 +116,7 @@ impl SubjektivStore {
|
||||
session: &str,
|
||||
) -> JournalResult<bool> {
|
||||
self.database.try_with_connection(|tx| {
|
||||
crate::require_subject_in_connection(tx, &binding.subject_id)?;
|
||||
Ok(tx.query_row("SELECT EXISTS(SELECT 1 FROM journal_session_bindings WHERE binding_id=?1 AND session_id=?2)", params![binding.binding_id,session], |r| r.get(0))?)
|
||||
})
|
||||
}
|
||||
@@ -135,6 +141,8 @@ impl SubjektivStore {
|
||||
}
|
||||
}
|
||||
self.database.try_transaction(|tx| {
|
||||
crate::require_subject_in_connection(tx, subject)?;
|
||||
crate::deletion::require_live_worker(tx, runtime, worker)?;
|
||||
let state: Option<String> = tx.query_row("SELECT state FROM subjects WHERE subject_id=?1", [subject], |r| r.get(0)).optional()?;
|
||||
if state.as_deref() != Some("active") { return Err(JournalError::PermissionDenied("Subject is absent or retired".into())); }
|
||||
let current: Option<JournalWriterBinding> = tx.query_row("SELECT subject_id,runtime_id,worker_id,binding_id FROM journal_writer_bindings WHERE subject_id=?1 AND active=1", [subject], |r| Ok(JournalWriterBinding { subject_id:r.get(0)?,runtime_id:r.get(1)?,worker_id:r.get(2)?,binding_id:r.get(3)? })).optional()?;
|
||||
@@ -200,6 +208,7 @@ impl SubjektivStore {
|
||||
/// It never fixes an open entry or declares the represented work complete.
|
||||
pub fn release_journal_writer(&self, binding: &JournalWriterBinding) -> JournalResult<()> {
|
||||
self.database.try_transaction(|tx| {
|
||||
crate::require_subject_in_connection(tx, &binding.subject_id)?;
|
||||
let active: Option<bool> = tx.query_row("SELECT active FROM journal_writer_bindings WHERE binding_id=?1 AND subject_id=?2 AND runtime_id=?3 AND worker_id=?4",params![binding.binding_id,binding.subject_id,binding.runtime_id,binding.worker_id],|r|r.get(0)).optional()?;
|
||||
let Some(active) = active else { return Err(denied()); };
|
||||
if !active { return Ok(()); }
|
||||
|
||||
@@ -1158,3 +1158,69 @@ fn failed_link_migration_rolls_back_ddl_and_preserves_existing_data_for_retry()
|
||||
assert_eq!(linked(&f.links(&selector, true).sources[0]).1, entry);
|
||||
assert_eq!(f.snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_deletion_denies_all_memory_journal_context_and_reconcile_entry_points() {
|
||||
let f = Fixture::new();
|
||||
let staged = f.stage("candidate", &[0]);
|
||||
let selector = f.apply(&staged);
|
||||
let entry = f.fixed("entry", 0, vec![]);
|
||||
f.links(&selector, true);
|
||||
f.store()
|
||||
.begin_subject_deletion(&f.host.subject_id, "delete")
|
||||
.unwrap();
|
||||
for request in [
|
||||
MemoryJournalRequest::Links {
|
||||
selector: selector.clone(),
|
||||
},
|
||||
MemoryJournalRequest::Reconcile {
|
||||
selector: selector.clone(),
|
||||
},
|
||||
MemoryJournalRequest::LinksPage {
|
||||
selector: selector.clone(),
|
||||
source_offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
MemoryJournalRequest::ReconcilePage {
|
||||
selector: selector.clone(),
|
||||
source_offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
MemoryJournalRequest::Read {
|
||||
selector,
|
||||
source_index: 0,
|
||||
journal_id: f.journal_id.clone(),
|
||||
entry_id: entry.clone(),
|
||||
expected_token: None,
|
||||
byte_offset: 0,
|
||||
max_bytes: 8,
|
||||
},
|
||||
MemoryJournalRequest::Related {
|
||||
journal_id: f.journal_id.clone(),
|
||||
entry_id: entry,
|
||||
after: None,
|
||||
limit: 1,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
f.store().execute_memory_journal(&f.host, request),
|
||||
Err(JournalError::Subject(
|
||||
crate::SubjektivError::SubjectNotFound(_)
|
||||
))
|
||||
));
|
||||
}
|
||||
f.store()
|
||||
.finish_subject_deletion(&f.host.subject_id, "delete")
|
||||
.unwrap();
|
||||
f.store()
|
||||
.database
|
||||
.try_with_connection::<_, JournalError>(|c| {
|
||||
assert_eq!(
|
||||
c.query_row("SELECT COUNT(*) FROM memory_journal_links", [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ impl JournalInput {
|
||||
// below operates on this value only, never by making another live capture request.
|
||||
let budget = job::MAX_JOB_INPUT_BYTES.saturating_sub(envelope);
|
||||
input.capture = capture_once(journal.process_order, journal.process_byte, budget)?;
|
||||
// Capture runs outside SQLite; a delayed callback cannot deliver deleted content.
|
||||
store
|
||||
.database
|
||||
.try_with_connection(|tx| super::store::check_host(store, tx, host, true))?;
|
||||
input.selected_ranges = capture_ranges(&input.capture)?;
|
||||
if input.selected_ranges.is_empty() {
|
||||
return Ok(input);
|
||||
|
||||
@@ -469,6 +469,11 @@ fn execute_inner(
|
||||
Ok::<_, JournalError>(())
|
||||
})?;
|
||||
}
|
||||
// Also check empty source reads and Initial snapshot responses (no delivery write).
|
||||
store.database.try_with_connection(|tx| {
|
||||
super::store::job(store, tx, context, false)?;
|
||||
Ok::<_, JournalError>(())
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ pub(super) fn check_session_host(
|
||||
"different storage scope".into(),
|
||||
));
|
||||
}
|
||||
crate::require_subject_in_connection(tx, &host.subject_id)?;
|
||||
let attr = host
|
||||
.attribution
|
||||
.as_ref()
|
||||
@@ -134,6 +135,7 @@ pub(super) fn check_host(
|
||||
"different storage scope".into(),
|
||||
));
|
||||
}
|
||||
crate::require_subject_in_connection(tx, &host.subject_id)?;
|
||||
if write {
|
||||
let state: String = tx.query_row(
|
||||
"SELECT state FROM subjects WHERE subject_id=?1",
|
||||
|
||||
@@ -349,7 +349,7 @@ mod input_delivery {
|
||||
open_action("must not save"),
|
||||
f.ranges.clone(),
|
||||
);
|
||||
f.store().retire_subject(&f.host.subject_id).unwrap();
|
||||
crate::tests::legacy_retired_fixture(f.store(), &f.host.subject_id).unwrap();
|
||||
f.reopen();
|
||||
assert!(matches!(
|
||||
service::attest_input_delivery(f.store(), &ctx, &digest(), &f.ranges),
|
||||
@@ -1210,6 +1210,21 @@ mod snapshot_inputs {
|
||||
service::finish(f.store(), &recovery).unwrap(),
|
||||
saved_completion
|
||||
);
|
||||
// Deletion, unlike writer release, invalidates even exact saved input/completion replay.
|
||||
f.store()
|
||||
.begin_subject_deletion(&host.subject_id, "delete")
|
||||
.unwrap();
|
||||
subject_deleted(
|
||||
f.store()
|
||||
.prepare_journal_input(&host, &input, generation.job.clone()),
|
||||
);
|
||||
subject_deleted(service::finish(f.store(), &recovery));
|
||||
subject_deleted(JournalJobContext::validated_recovery(
|
||||
f.store(),
|
||||
&host,
|
||||
&generation.id,
|
||||
generation.job.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2833,7 +2848,7 @@ fn job_service_source_reads_are_generation_bound_and_stop_at_supplied_byte_end()
|
||||
#[test]
|
||||
fn explicit_erasure_remains_available_for_retired_subject_without_reopening_recording() {
|
||||
let f = Fixture::new();
|
||||
f.store().retire_subject(&f.host.subject_id).unwrap();
|
||||
crate::tests::legacy_retired_fixture(f.store(), &f.host.subject_id).unwrap();
|
||||
assert!(f.store().journal_retention_required(&f.host).unwrap());
|
||||
f.store()
|
||||
.erase_journal_session(&f.host, "erase-retired")
|
||||
@@ -4086,10 +4101,7 @@ fn retired_subject_keeps_reader_access_but_rejects_existing_write_context() {
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
fixture
|
||||
.store()
|
||||
.retire_subject(&fixture.host.subject_id)
|
||||
.unwrap();
|
||||
crate::tests::legacy_retired_fixture(fixture.store(), &fixture.host.subject_id).unwrap();
|
||||
assert!(matches!(
|
||||
fixture.store().execute_journal(&ctx, late),
|
||||
Err(JournalError::PermissionDenied(_))
|
||||
@@ -5745,3 +5757,311 @@ fn late_gap_admission_and_partial_updates_reject_stale_source_page_cursors() {
|
||||
Err(JournalError::InvalidInput(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn subject_deleted<T: std::fmt::Debug>(result: JournalResult<T>) {
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(JournalError::Subject(
|
||||
crate::SubjektivError::SubjectNotFound(_)
|
||||
))
|
||||
),
|
||||
"unexpected result: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_deletion_fence_denies_all_journal_read_write_binding_and_recovery_paths() {
|
||||
use transport::{
|
||||
JournalJobRequest as Request, JournalSnapshot, JournalTextRead, JournalTextTarget,
|
||||
};
|
||||
let mut f = Fixture::new();
|
||||
let (generation, ctx) = f.generation("delete-job", f.ranges.clone());
|
||||
let operation = f.operation(
|
||||
"saved",
|
||||
open_action("private body"),
|
||||
vec![f.ranges[0].clone()],
|
||||
);
|
||||
let receipt = f.store().execute_journal(&ctx, operation.clone()).unwrap();
|
||||
let entry = receipt.entry_id.unwrap();
|
||||
let reader = JournalHostContext::validated_reader(f.store(), &f.host.subject_id).unwrap();
|
||||
let writer = f
|
||||
.store()
|
||||
.claim_journal_writer(&f.host.subject_id, "runtime", "worker")
|
||||
.unwrap();
|
||||
let digest = format!("sha256:{}", "a".repeat(64));
|
||||
service::attest_input_delivery(f.store(), &ctx, &digest, &f.ranges).unwrap();
|
||||
f.store().database.try_transaction::<_, JournalError>(|tx| {
|
||||
tx.execute("INSERT INTO journal_generation_deliveries(generation_id,source_order,ranges_json) VALUES(?1,0,'[]')", [&generation.id])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
f.store().database.try_transaction::<_, JournalError>(|tx| {
|
||||
tx.execute("INSERT INTO legacy_record_archive(source_table,row_position,row_json) VALUES('subjects',0,?1)", [serde_json::json!({"subject_id":f.host.subject_id,"record_json":"archived private body"}).to_string()])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
f.store()
|
||||
.begin_subject_deletion(&f.host.subject_id, "delete")
|
||||
.unwrap();
|
||||
// Exercise actual SQL owner resolution for *every* indirect content table,
|
||||
// not only table-name inventory. Fence errors must precede PK/immutable/FK errors.
|
||||
f.store()
|
||||
.database
|
||||
.try_with_connection::<_, JournalError>(|tx| {
|
||||
for (table, field, new_owner) in [
|
||||
("journal_sources", "journal_id", "other-journal"),
|
||||
("journal_operations", "journal_id", "other-journal"),
|
||||
("journal_generations", "journal_id", "other-journal"),
|
||||
("journal_session_bindings", "binding_id", "other-binding"),
|
||||
(
|
||||
"journal_generation_deliveries",
|
||||
"generation_id",
|
||||
"other-generation",
|
||||
),
|
||||
(
|
||||
"journal_generation_input_deliveries",
|
||||
"generation_id",
|
||||
"other-generation",
|
||||
),
|
||||
(
|
||||
"legacy_record_archive",
|
||||
"row_json",
|
||||
"{\"subject_id\":\"other-subject\"}",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
tx.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r
|
||||
.get::<_, i64>(0))?
|
||||
> 0,
|
||||
"{table}"
|
||||
);
|
||||
let insert = tx
|
||||
.execute(&format!("INSERT INTO {table} SELECT * FROM {table}"), [])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
insert.to_string().contains("Subject deletion has begun"),
|
||||
"{table}: {insert}"
|
||||
);
|
||||
let update = tx
|
||||
.execute(&format!("UPDATE {table} SET {field}=?1"), [new_owner])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
update.to_string().contains("Subject deletion has begun"),
|
||||
"{table}: {update}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
macro_rules! denied { ($($result:expr),+ $(,)?) => { $(subject_deleted($result);)+ }; }
|
||||
for host in [&f.host, &reader] {
|
||||
denied!(
|
||||
f.store().list_journals(host, None, 1),
|
||||
f.store().read_journal(host, &f.journal_id),
|
||||
f.store().list_journal_entries(host, &f.journal_id, None, 1),
|
||||
f.store().read_journal_text(
|
||||
host,
|
||||
&f.journal_id,
|
||||
Some(&entry),
|
||||
JournalTextField::Body,
|
||||
None,
|
||||
0,
|
||||
8
|
||||
),
|
||||
f.store().read_journal_source(host, &f.journal_id, 0),
|
||||
f.store().list_journal_sources(host, &f.journal_id, None, 1),
|
||||
f.store()
|
||||
.query_journal_operation(host, &f.journal_id, "saved"),
|
||||
f.store()
|
||||
.read_journal_operation_status(host, &f.journal_id, "saved"),
|
||||
f.store()
|
||||
.read_journal_operation_coverage(host, &f.journal_id, "saved", 0, 1),
|
||||
f.store()
|
||||
.list_journal_operations(host, &f.journal_id, None, 1),
|
||||
f.store().journal_entry_summary(host, &f.journal_id, &entry),
|
||||
f.store()
|
||||
.journal_correction_targets(host, &f.journal_id, &entry),
|
||||
f.store()
|
||||
.browse_journal_entries(host, None, None, None, None, 1),
|
||||
f.store()
|
||||
.journal_has_unreflected_sources(host, &f.journal_id),
|
||||
f.store().journal_startup_snapshot(host, None),
|
||||
);
|
||||
}
|
||||
denied!(
|
||||
f.store().journal_compaction_snapshot(&f.host),
|
||||
f.store().journal_session_erased(&f.host),
|
||||
f.store().journal_for_session(&f.host),
|
||||
f.store().journal_retention_required(&f.host),
|
||||
f.store().erase_journal_session(&f.host, "erase"),
|
||||
f.store().release_erased_journal_body(&f.host),
|
||||
f.store().journal_predecessor_session(&writer),
|
||||
f.store()
|
||||
.journal_body_is_unpublished(&f.host.subject_id, "runtime", "worker"),
|
||||
f.store()
|
||||
.journal_worker_was_released(&f.host.subject_id, "runtime", "worker"),
|
||||
f.store().journal_writer_active(&writer),
|
||||
f.store()
|
||||
.journal_session_writer_matches(&writer, "source-session"),
|
||||
f.store()
|
||||
.claim_journal_writer(&f.host.subject_id, "runtime", "worker"),
|
||||
f.store()
|
||||
.bind_journal_session(&writer, f.host.attribution.clone().unwrap()),
|
||||
f.store().release_journal_writer(&writer),
|
||||
f.store().accept_journal_sources(&f.host, "late", &f.ranges),
|
||||
f.store()
|
||||
.mark_journal_sources_unavailable(&f.host, &f.journal_id, &f.ranges),
|
||||
f.store()
|
||||
.mark_journal_sources_available(&f.host, &f.journal_id, &f.ranges),
|
||||
f.store().active_journal_generation(&f.host, &f.journal_id),
|
||||
f.store().prepare_journal_generation(
|
||||
&f.host,
|
||||
&f.journal_id,
|
||||
generation.job.clone(),
|
||||
f.ranges.clone()
|
||||
),
|
||||
f.store()
|
||||
.close_journal_generation(&f.host, &generation.id, &generation.job),
|
||||
f.store().read_journal_generation(&f.host, &generation.id),
|
||||
f.store().journal_resume_state(&f.host, &f.journal_id),
|
||||
f.store().execute_journal(&ctx, operation.clone()),
|
||||
JournalJobContext::validated_recovery(
|
||||
f.store(),
|
||||
&f.host,
|
||||
&generation.id,
|
||||
generation.job.clone()
|
||||
),
|
||||
service::attest_input_delivery(f.store(), &ctx, &digest, &f.ranges),
|
||||
service::finish(f.store(), &ctx),
|
||||
service::execute_snapshot(f.store(), &ctx, &digest, Request::ReadState),
|
||||
service::execute_snapshot(
|
||||
f.store(),
|
||||
&ctx,
|
||||
&digest,
|
||||
Request::QueryOperation {
|
||||
request_id: "saved".into()
|
||||
}
|
||||
),
|
||||
service::execute_snapshot(
|
||||
f.store(),
|
||||
&ctx,
|
||||
&digest,
|
||||
Request::ReadText {
|
||||
read: JournalTextRead {
|
||||
target: JournalTextTarget::Position {
|
||||
snapshot: JournalSnapshot::Initial
|
||||
},
|
||||
offset: 0,
|
||||
limit: 8,
|
||||
content_token: None
|
||||
}
|
||||
}
|
||||
),
|
||||
);
|
||||
// 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();
|
||||
assert!(
|
||||
f.store()
|
||||
.finish_subject_deletion(&f.host.subject_id, "delete")
|
||||
.is_err()
|
||||
);
|
||||
f.reopen();
|
||||
subject_deleted(f.store().execute_journal(&ctx, operation));
|
||||
f.sql("DROP TRIGGER injected_subject_purge_failure;")
|
||||
.unwrap();
|
||||
f.store()
|
||||
.finish_subject_deletion(&f.host.subject_id, "delete")
|
||||
.unwrap();
|
||||
f.store()
|
||||
.database
|
||||
.try_with_connection::<_, JournalError>(|tx| {
|
||||
for table in [
|
||||
"journals",
|
||||
"journal_entries",
|
||||
"journal_corrections",
|
||||
"journal_sources",
|
||||
"journal_generations",
|
||||
"journal_operations",
|
||||
"journal_generation_deliveries",
|
||||
"journal_generation_input_deliveries",
|
||||
"journal_writer_bindings",
|
||||
"journal_session_bindings",
|
||||
] {
|
||||
assert_eq!(
|
||||
tx.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0,
|
||||
"{table}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_deletion_during_capture_rejects_even_empty_delayed_input() {
|
||||
use session_store::capture::CaptureDto;
|
||||
let f = Fixture::new();
|
||||
let result = recording::JournalInput::build(f.store(), &f.host, &f.journal_id, |_, _, _| {
|
||||
f.store()
|
||||
.begin_subject_deletion(&f.host.subject_id, "delete")?;
|
||||
Ok(CaptureDto {
|
||||
version: 1,
|
||||
session_id: "source-session".into(),
|
||||
segment_id: "segment".into(),
|
||||
view_entry_count: 0,
|
||||
entries: vec![],
|
||||
})
|
||||
});
|
||||
subject_deleted(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_deletion_during_source_callback_cannot_return_or_attest_cached_material() {
|
||||
use transport::{JournalJobRequest as Request, JournalTextRead, JournalTextTarget};
|
||||
let f = Fixture::new();
|
||||
let (_, ctx) = f.generation("delayed-source", f.ranges.clone());
|
||||
let result = service::execute_bounded(
|
||||
f.store(),
|
||||
&ctx,
|
||||
Request::ReadText {
|
||||
read: JournalTextRead {
|
||||
target: JournalTextTarget::Source {
|
||||
pointer: f.ranges[0].source.pointer.clone(),
|
||||
},
|
||||
offset: 0,
|
||||
limit: 8,
|
||||
content_token: None,
|
||||
},
|
||||
},
|
||||
|source, start, _| {
|
||||
f.store()
|
||||
.begin_subject_deletion(&f.host.subject_id, "delete")?;
|
||||
Ok(recording::JournalMaterial {
|
||||
range: JournalSourceRange {
|
||||
source: source.clone(),
|
||||
bytes: JournalByteRange {
|
||||
start,
|
||||
end: start + 8,
|
||||
},
|
||||
},
|
||||
text: "private!".into(),
|
||||
})
|
||||
},
|
||||
);
|
||||
subject_deleted(result);
|
||||
f.store()
|
||||
.database
|
||||
.try_with_connection::<_, JournalError>(|tx| {
|
||||
assert_eq!(
|
||||
tx.query_row(
|
||||
"SELECT COUNT(*) FROM journal_generation_deliveries",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
+63
-28
@@ -1,7 +1,9 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod api;
|
||||
pub mod deletion;
|
||||
pub mod job;
|
||||
pub use deletion::{DeletionSubjectJob, SubjectDeletion};
|
||||
pub mod journal;
|
||||
mod legacy_migrations;
|
||||
pub mod permissions;
|
||||
@@ -51,7 +53,9 @@ pub enum SubjektivError {
|
||||
InvalidRecord(String),
|
||||
#[error("subject `{0}` was not found in this Workspace")]
|
||||
SubjectNotFound(String),
|
||||
#[error("subject `{0}` is retired")]
|
||||
#[error("Subject deletion request `{0}` conflicts with a saved deletion")]
|
||||
SubjectDeletionConflict(String),
|
||||
#[error("subject `{0}` is legacy-retired")]
|
||||
SubjectRetired(String),
|
||||
#[error("the selected Worker Profile does not enable subjektiv")]
|
||||
ProfileDisabled,
|
||||
@@ -146,7 +150,10 @@ impl<'de> Deserialize<'de> for SubjectRole {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubjectState {
|
||||
Active,
|
||||
Retired,
|
||||
/// Saved pre-deletion lifecycle state. The old `retired` spelling remains readable
|
||||
/// and serializable solely to preserve existing data; no retirement operation exists.
|
||||
#[serde(rename = "retired")]
|
||||
LegacyRetired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -644,6 +651,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
||||
"bind Journal generation delivery to immutable Job input digest",
|
||||
journal::service::migrate_input_delivery,
|
||||
),
|
||||
FeatureMigration::new(
|
||||
14,
|
||||
"add permanent Subject deletion fences and explicit erasure",
|
||||
deletion::migrate,
|
||||
),
|
||||
];
|
||||
|
||||
pub const REGISTRATION: FeatureRegistration =
|
||||
@@ -772,6 +784,9 @@ impl SubjektivStore {
|
||||
|
||||
pub fn subject(&self, subject_id: &str) -> Result<Option<SubjectRecord>> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
if deletion::is_deleting(connection, subject_id)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM subjects WHERE subject_id = ?1",
|
||||
@@ -862,11 +877,13 @@ impl SubjektivStore {
|
||||
self.database.try_with_connection(|connection| {
|
||||
let sql = if after.is_some() {
|
||||
"SELECT record_json FROM subjects
|
||||
WHERE created_at > ?1 OR (created_at = ?1 AND subject_id > ?2)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM subject_deletions d WHERE d.subject_id=subjects.subject_id)
|
||||
AND (created_at > ?1 OR (created_at = ?1 AND subject_id > ?2))
|
||||
ORDER BY created_at ASC, subject_id ASC
|
||||
LIMIT ?3"
|
||||
} else {
|
||||
"SELECT record_json FROM subjects
|
||||
WHERE NOT EXISTS (SELECT 1 FROM subject_deletions d WHERE d.subject_id=subjects.subject_id)
|
||||
ORDER BY created_at ASC, subject_id ASC
|
||||
LIMIT ?1"
|
||||
};
|
||||
@@ -895,27 +912,6 @@ impl SubjektivStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Retires a Subject independently of stopping Workers. Memory/history remain unedited, undeleted, and readable.
|
||||
/// Retirement is irreversible; new candidates and Memory changes require an active Subject.
|
||||
pub fn retire_subject(&self, subject_id: &str) -> Result<SubjectRecord> {
|
||||
self.database.try_transaction(|transaction| {
|
||||
let mut subject = require_subject(transaction, subject_id)?;
|
||||
if subject.state == SubjectState::Retired {
|
||||
return Ok(subject);
|
||||
}
|
||||
subject.state = SubjectState::Retired;
|
||||
subject.updated_at = now();
|
||||
let raw = serde_json::to_string(&subject)?;
|
||||
transaction.execute(
|
||||
"UPDATE subjects
|
||||
SET state = 'retired', record_json = ?2, updated_at = ?3
|
||||
WHERE subject_id = ?1",
|
||||
params![subject_id, raw, subject.updated_at],
|
||||
)?;
|
||||
Ok(subject)
|
||||
})
|
||||
}
|
||||
|
||||
/// Saves an immutable candidate without changing committed Memory.
|
||||
/// Replay with the same candidate identity and payload returns the original record; different content conflicts.
|
||||
/// Extraction failure after saving never revokes the candidate. The Host commits the extraction pointer separately.
|
||||
@@ -1003,6 +999,10 @@ impl SubjektivStore {
|
||||
) -> Result<Option<SubjectSessionAttribution>> {
|
||||
validate_label("session id", session_id)?;
|
||||
self.database.try_with_connection(|connection| {
|
||||
if let Some(subject) = deletion::deleted_session_subject(connection, session_id)? {
|
||||
return Err(SubjektivError::SubjectNotFound(subject));
|
||||
}
|
||||
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM subject_session_attributions
|
||||
@@ -1011,7 +1011,12 @@ impl SubjektivStore {
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
raw.map(|raw| parse_session_attribution(&raw)).transpose()
|
||||
raw.map(|raw| {
|
||||
let record = parse_session_attribution(&raw)?;
|
||||
deletion::require_not_deleting(connection, &record.subject_id)?;
|
||||
Ok(record)
|
||||
})
|
||||
.transpose()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1021,6 +1026,7 @@ impl SubjektivStore {
|
||||
) -> Result<Vec<SubjectSessionAttribution>> {
|
||||
validate_label("subject id", subject_id)?;
|
||||
self.database.try_with_connection(|connection| {
|
||||
require_subject_in_connection(connection, subject_id)?;
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT record_json FROM subject_session_attributions
|
||||
WHERE subject_id = ?1
|
||||
@@ -1062,6 +1068,7 @@ impl SubjektivStore {
|
||||
}
|
||||
let query_limit = limit.saturating_add(1);
|
||||
self.database.try_with_connection(|connection| {
|
||||
require_subject_in_connection(connection, subject_id)?;
|
||||
let raw = match (session_id, after) {
|
||||
(Some(session_id), _) => {
|
||||
let mut statement = connection.prepare(
|
||||
@@ -1128,6 +1135,7 @@ impl SubjektivStore {
|
||||
candidate_id: &str,
|
||||
) -> Result<Option<SubjectStagingRecord>> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
deletion::require_not_deleting(connection, subject_id)?;
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM staging_records
|
||||
@@ -1292,6 +1300,7 @@ impl SubjektivStore {
|
||||
validate_nonempty("resolution reason", &request.reason)?;
|
||||
let request_raw = serde_json::to_string(&request)?;
|
||||
self.database.try_transaction(|transaction| {
|
||||
deletion::require_not_deleting(transaction, subject_id)?;
|
||||
if let Some((stored_request, stored_receipt)) = transaction
|
||||
.query_row(
|
||||
"SELECT request_json, receipt_json
|
||||
@@ -1426,6 +1435,7 @@ impl SubjektivStore {
|
||||
candidate_id: &str,
|
||||
) -> Result<Option<StagingResolution>> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
deletion::require_not_deleting(connection, subject_id)?;
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT resolution_json FROM staging_resolutions
|
||||
@@ -1440,6 +1450,7 @@ impl SubjektivStore {
|
||||
|
||||
pub fn memory(&self, subject_id: &str, memory_id: &str) -> Result<Option<MemoryRecord>> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
deletion::require_not_deleting(connection, subject_id)?;
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM memory_records
|
||||
@@ -1459,6 +1470,7 @@ impl SubjektivStore {
|
||||
change_id: String,
|
||||
) -> Result<Option<MemoryRecord>> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
deletion::require_not_deleting(connection, subject_id)?;
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM memory_changes
|
||||
@@ -1854,6 +1866,7 @@ impl SubjektivStore {
|
||||
snapshot_id: &str,
|
||||
) -> Result<Option<SurfaceSnapshot>> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
deletion::require_not_deleting(connection, subject_id)?;
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT snapshot_json FROM surface_snapshots
|
||||
@@ -2427,6 +2440,11 @@ fn write_session_attribution(
|
||||
record: SubjectSessionAttribution,
|
||||
raw: String,
|
||||
) -> Result<SubjectSessionAttribution> {
|
||||
deletion::require_live_worker(transaction, &record.runtime_id, &record.worker_id)?;
|
||||
if let Some(subject) = deletion::deleted_session_subject(transaction, &record.session_id)? {
|
||||
return Err(SubjektivError::SubjectNotFound(subject));
|
||||
}
|
||||
|
||||
let existing = transaction
|
||||
.query_row(
|
||||
"SELECT record_json FROM subject_session_attributions WHERE session_id = ?1",
|
||||
@@ -2621,6 +2639,7 @@ fn require_subject_in_connection(
|
||||
connection: &rusqlite::Connection,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjectRecord> {
|
||||
deletion::require_not_deleting(connection, subject_id)?;
|
||||
let raw = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM subjects WHERE subject_id = ?1",
|
||||
@@ -2637,7 +2656,7 @@ fn require_active_subject(
|
||||
subject_id: &str,
|
||||
) -> Result<SubjectRecord> {
|
||||
let subject = require_subject(transaction, subject_id)?;
|
||||
if subject.state == SubjectState::Retired {
|
||||
if subject.state == SubjectState::LegacyRetired {
|
||||
return Err(SubjektivError::SubjectRetired(subject_id.to_string()));
|
||||
}
|
||||
Ok(subject)
|
||||
@@ -3278,6 +3297,7 @@ fn require_job_generation(
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
) -> Result<String> {
|
||||
deletion::require_not_deleting(connection, subject_id)?;
|
||||
let change_id: Option<String> = connection.query_row(
|
||||
"SELECT memory_fingerprint FROM surface_generation_runs WHERE subject_id = ?1 AND generation_id = ?2 AND job_id = ?3 AND attempt_id = ?4",
|
||||
params![subject_id, generation_id, job_id, attempt_id], |row| row.get(0),
|
||||
@@ -3370,6 +3390,21 @@ mod tests {
|
||||
use super::*;
|
||||
use feature_storage::FeatureStorage;
|
||||
|
||||
/// Builds saved pre-deletion lifecycle data; not a current product operation.
|
||||
pub(crate) fn legacy_retired_fixture(
|
||||
store: &SubjektivStore,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjectRecord> {
|
||||
store.database.try_transaction(|tx| {
|
||||
let mut subject = require_subject(tx, subject_id)?;
|
||||
subject.state = SubjectState::LegacyRetired;
|
||||
subject.updated_at = now();
|
||||
tx.execute("UPDATE subjects SET state='retired',record_json=?2,updated_at=?3 WHERE subject_id=?1",
|
||||
params![subject_id,serde_json::to_string(&subject)?,subject.updated_at])?;
|
||||
Ok(subject)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn role() -> SubjectRole {
|
||||
SubjectRole::new("workspace_companion").unwrap()
|
||||
}
|
||||
@@ -5047,8 +5082,8 @@ mod tests {
|
||||
Err(SubjektivError::InvalidStateTransition { .. })
|
||||
));
|
||||
|
||||
let retired = store.retire_subject(&subject.id).unwrap();
|
||||
assert_eq!(retired.state, SubjectState::Retired);
|
||||
let retired = crate::tests::legacy_retired_fixture(&store, &subject.id).unwrap();
|
||||
assert_eq!(retired.state, SubjectState::LegacyRetired);
|
||||
assert!(matches!(
|
||||
store.create_memory(&subject.id, draft("after retirement", "invalid")),
|
||||
Err(SubjektivError::SubjectRetired(_))
|
||||
|
||||
@@ -94,6 +94,10 @@ impl SubjektivStore {
|
||||
pub fn bind_worker_execution_grant(&self, grant: &WorkerExecutionGrant) -> Result<()> {
|
||||
validate_grant(self.scope_id(), grant)?;
|
||||
self.database.try_transaction(|tx| {
|
||||
require_subject(tx, &grant.subject_id)?;
|
||||
for (runtime, worker) in [(&grant.runtime_id, &grant.worker_id), (&grant.origin_runtime_id, &grant.origin_worker_id)] {
|
||||
if tx.query_row("SELECT EXISTS(SELECT 1 FROM subject_deleted_workers WHERE runtime_id=?1 AND worker_id=?2)", params![runtime,worker], |r|r.get::<_,bool>(0))? { return Err(denied("Worker belongs to a deleted Subject")); }
|
||||
}
|
||||
if let Some(existing) = grant_in_connection(tx, &grant.runtime_id, &grant.worker_id)? {
|
||||
return if existing == *grant { Ok(()) } else {
|
||||
Err(SubjektivError::WorkerExecutionGrantConflict(format!("{}/{}", grant.runtime_id, grant.worker_id)))
|
||||
@@ -184,9 +188,12 @@ fn grant_in_connection(
|
||||
runtime: &str,
|
||||
worker: &str,
|
||||
) -> Result<Option<WorkerExecutionGrant>> {
|
||||
// Absence must never become non-Subject fallback authority after purge.
|
||||
deletion::require_live_worker(connection, runtime, worker)?;
|
||||
|
||||
let raw: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2",
|
||||
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)",
|
||||
params![runtime, worker],
|
||||
|row| row.get(0),
|
||||
)
|
||||
|
||||
@@ -315,7 +315,7 @@ fn redelegation_requires_current_source_and_explicit_retained_parent_ceiling() {
|
||||
store.worker_execution_grant("runtime", "child").unwrap(),
|
||||
Some(child)
|
||||
);
|
||||
store.retire_subject(&subject).unwrap();
|
||||
crate::tests::legacy_retired_fixture(&store, &subject).unwrap();
|
||||
stale.source_change_id = revoked.change_id;
|
||||
assert!(store.bind_worker_execution_grant(&stale).is_err());
|
||||
}
|
||||
@@ -582,7 +582,7 @@ fn subject_grant_listing_is_scoped_retained_ordered_and_fails_on_overflow() {
|
||||
&update("revoke", Some(&source.change_id), Vec::new()),
|
||||
)
|
||||
.unwrap();
|
||||
store.retire_subject(&subject).unwrap();
|
||||
crate::tests::legacy_retired_fixture(&store, &subject).unwrap();
|
||||
let listed = store
|
||||
.list_subject_worker_execution_grants(&subject)
|
||||
.unwrap();
|
||||
@@ -654,3 +654,51 @@ fn failed_schema_migration_rolls_back_new_tables_and_preserves_old_data() {
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_purge() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let replacement = update("allow", None, vec![permission("file.read", ".", true)]);
|
||||
let source = store
|
||||
.set_subject_permissions(&subject, &replacement)
|
||||
.unwrap();
|
||||
let child = grant(&subject, &source.change_id, "child");
|
||||
store.bind_worker_execution_grant(&child).unwrap();
|
||||
store.begin_subject_deletion(&subject, "delete").unwrap();
|
||||
assert!(store.subject_permissions(&subject).is_err());
|
||||
assert!(
|
||||
store
|
||||
.set_subject_permissions(&subject, &replacement)
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.bind_worker_execution_grant(&child).is_err());
|
||||
assert!(store.worker_execution_grant("runtime", "child").is_err());
|
||||
assert!(store.worker_subject_deleted("runtime", "body").unwrap());
|
||||
assert!(store.worker_subject_deleted("runtime", "child").unwrap());
|
||||
assert_eq!(store.deletion_subject_workers(&subject).unwrap().len(), 2);
|
||||
store.finish_subject_deletion(&subject, "delete").unwrap();
|
||||
assert!(store.worker_execution_grant("runtime", "child").is_err());
|
||||
assert!(store.worker_execution_grant("runtime", "body").is_err());
|
||||
assert!(
|
||||
store
|
||||
.worker_execution_grant("runtime", "unrelated")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let fresh = store.create_subject(role()).unwrap().id;
|
||||
assert!(store.subject_permissions(&fresh).unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.record_session_attribution(
|
||||
SubjectSessionAttribution::new(&fresh, "runtime", "child", "new-session").unwrap()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.claim_journal_writer(&fresh, "runtime", "body")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user