From d53b9e35017802c04d51f1e3cfd7ae58425a1ecb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:37:55 -0800 Subject: [PATCH 1/3] fix(graph): mark collected only after sealed retirement Generation deletion could commit staging-row removal and mark the in-memory locator collected before attempting sealed-artifact retirement. An unlink failure then returned an error with the durable sealed bytes still present, while the in-memory collected set falsely declared the generation gone. Retire the sealed artifact before transitioning the locator to collected. Strengthen the existing real filesystem regression with negative and positive collected-state assertions across failure, retry and success. Refs #4616 --- .../src/generation_runtime.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index d2fdd35dd58..d9fcf6edf83 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -1840,6 +1840,11 @@ impl GraphDb { self.retire_sealed_generation_store(locator)?; return Ok(GenerationContentsDeletion::RetentionPending); } + // Native row deletion may already have committed, but the generation + // is not collected until its independently durable sealed artifact is + // gone. If unlink refuses, keep the in-memory retirement state retryable + // instead of advertising collection while bytes still exist on disk. + self.retire_sealed_generation_store(locator)?; let mut state = self.wait_verified_generations_write()?; state.known.remove(locator); state.quarantined.remove(locator); @@ -1858,7 +1863,6 @@ impl GraphDb { state.heads.remove(&locator.projection); } drop(state); - self.retire_sealed_generation_store(locator)?; Ok(GenerationContentsDeletion::Deleted) } @@ -4006,6 +4010,14 @@ mod tests { .contains("sealed directory removal failed") ); assert_eq!(std::fs::read(&directory).unwrap(), b"not a directory"); + assert!( + !database + .wait_verified_generations_write() + .unwrap() + .collected + .contains(&locator), + "a failed sealed unlink must not mark its generation collected" + ); std::fs::remove_file(&directory).unwrap(); std::fs::create_dir(&directory).unwrap(); @@ -4015,6 +4027,14 @@ mod tests { Ok(GenerationContentsDeletion::Deleted) )); assert!(!directory.exists()); + assert!( + database + .wait_verified_generations_write() + .unwrap() + .collected + .contains(&locator), + "only successful physical retirement may mark the generation collected" + ); // Idempotent replay after the directory is gone still succeeds. assert!(matches!( database.delete_generation_contents(&locator, &|| Ok(())), From b3a13ddb8cc60ad73b1c4dd9d4145093d42d7468 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:58:00 -0800 Subject: [PATCH 2/3] test(graph): bind sealed retirement retry to a durable receipt The successful retry fixture previously created an arbitrary directory with no receipt. The ownership-verified sealed retire path correctly refuses such an artifact, so combining the safe collector with this regression would fail even though collection is correct. Use a self-describing, generation-bound receipt in the retry, preserve an unrelated sibling sentinel, and require typed sealed-retirement failure without hard-coding the former unsafe remover's exact message. Refs #4616 --- .../src/generation_runtime.rs | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index d9fcf6edf83..6c86cb7ea14 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -4005,9 +4005,8 @@ mod tests { .delete_generation_contents(&locator, &|| Ok(())) .expect_err("a failed sealed unlink must not complete retention"); assert!( - error - .to_string() - .contains("sealed directory removal failed") + error.to_string().contains("sealed"), + "an unremovable artifact must return an explicit sealed-retirement error: {error}" ); assert_eq!(std::fs::read(&directory).unwrap(), b"not a directory"); assert!( @@ -4021,11 +4020,44 @@ mod tests { std::fs::remove_file(&directory).unwrap(); std::fs::create_dir(&directory).unwrap(); + // Retirement requires an exact durable identity from the directory + // being removed. A receipt-free synthetic directory must never confer + // deletion authority when the strict collector is integrated. + let identity = generation.identity(); + let receipt = serde_json::json!({ + "version": 1, + "form": "compact", + "namespace": locator.projection.namespace.as_str(), + "projection": locator.projection.projection.as_str(), + "generation": locator.generation.as_str(), + "physical_namespace": namespace.as_str(), + "recovered_digest": sealed_digest(&generation).as_str(), + "entities": 0, + "relations": 0, + "graph_format": GraphFormatVersion::current().get(), + "row_sum": null, + "base": null, + "source_generation": identity.source_generation.as_str(), + "watermark": identity.watermark.as_str() + }); + std::fs::write( + directory.join("sealed.json"), + serde_json::to_vec(&receipt).unwrap(), + ) + .unwrap(); std::fs::write(directory.join("derived-rows"), b"retired").unwrap(); + let unrelated = directory.parent().unwrap().join("unrelated-entry"); + std::fs::create_dir(&unrelated).unwrap(); + std::fs::write(unrelated.join("sentinel"), b"untouched").unwrap(); assert!(matches!( database.delete_generation_contents(&locator, &|| Ok(())), Ok(GenerationContentsDeletion::Deleted) )); + assert_eq!( + std::fs::read(unrelated.join("sentinel")).unwrap(), + b"untouched", + "retiring one sealed generation must not traverse another child" + ); assert!(!directory.exists()); assert!( database From d89da7117ad718e5cc3e23612b9b33391628f9eb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:11:04 -0800 Subject: [PATCH 3/3] test(graph): require explicit sealed retirement error on unlink Assert the concrete unavailable error from the sealed-directory unlink boundary, not any error that happens to mention sealed. Accept the two currently reviewable collector implementations until the ownership-safe collector is integrated, without weakening the failure/retry assertions. Refs #4616 --- crates/tracedecay-graph-db/src/generation_runtime.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index 6c86cb7ea14..a514bbc76de 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -4005,8 +4005,13 @@ mod tests { .delete_generation_contents(&locator, &|| Ok(())) .expect_err("a failed sealed unlink must not complete retention"); assert!( - error.to_string().contains("sealed"), - "an unremovable artifact must return an explicit sealed-retirement error: {error}" + matches!( + &error, + GraphDbError::Unavailable { message } + if message.starts_with("sealed generation store sealed directory removal failed:") + || message.starts_with("sealed generation store sealed generation retirement refused:") + ), + "an unremovable artifact must fail at the sealed-directory unlink boundary: {error}" ); assert_eq!(std::fs::read(&directory).unwrap(), b"not a directory"); assert!(