feat(cluster): execute approved graph deletes in cluster apply

Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.

Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.

The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
aaltshuler 2026-06-10 14:34:02 +03:00
parent f4e9105272
commit d1d04217ab
2 changed files with 513 additions and 9 deletions

View file

@ -1358,7 +1358,7 @@ fn cluster_e2e_graph_root_destruction_drifts_then_apply_recreates_empty_graph()
/// (applied), its composite (derived) — shows all four dispositions at once
/// before the graph-plane schema apply closes the loop.
#[test]
fn cluster_e2e_multi_graph_mixed_dispositions_then_converge() {
fn cluster_e2e_multi_graph_mixed_dispositions_then_approve_and_converge() {
let temp = tempdir().unwrap();
write_multi_graph_cluster_fixture(temp.path());
// No manual init: Stage 4A creates both graphs.
@ -1468,7 +1468,55 @@ policies:
let mut sorted = order.clone();
sorted.sort_unstable();
assert_eq!(order, sorted, "{mixed}");
// Conclusion (approve + converge) extends below once the delete executor lands.
// The conclusion: an apply without approval stays blocked; the approved
// delete converges the cluster, tombstoning the removed graph.
let still_blocked = cluster_json(temp.path(), "apply");
assert_eq!(still_blocked["converged"], false, "{still_blocked}");
let approve = parse_stdout_json(&output_success(
cli()
.arg("--as")
.arg("andrew")
.arg("cluster")
.arg("approve")
.arg("graph.engineering")
.arg("--config")
.arg(temp.path())
.arg("--json"),
));
assert_eq!(approve["ok"], true, "{approve}");
assert_eq!(approve["approved_by"], "andrew");
let converge = cluster_json(temp.path(), "apply");
assert_eq!(converge["ok"], true, "{converge}");
assert_eq!(converge["converged"], true, "{converge}");
assert!(!temp.path().join("graphs/engineering.omni").exists());
let status = cluster_json(temp.path(), "status");
assert_eq!(status["observations"]["graph.engineering"]["kind"], "tombstone");
let final_plan = cluster_json(temp.path(), "plan");
assert!(
final_plan["changes"].as_array().unwrap().is_empty(),
"{final_plan}"
);
}
/// An approval without an approver is meaningless: approve requires --as.
#[test]
fn cluster_e2e_approve_requires_actor() {
let temp = tempdir().unwrap();
write_cluster_config_fixture(temp.path());
let output = output_failure(
cli()
.arg("cluster")
.arg("approve")
.arg("graph.knowledge")
.arg("--config")
.arg(temp.path()),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("--as"), "{stderr}");
}
/// Stage 4A headline: a declared graph is created by `cluster apply` itself —