mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-21 02:28:07 +02:00
feat(cluster): cluster approve — digest-bound approval artifacts
RFC-004 §D4, gate half: graph deletes (and their subtree) now classify
Blocked/approval_required instead of Deferred; the new cluster approve
command (requires the global --as actor) writes
__cluster/approvals/{ulid}.json bound to the desired config digest and the
change's before/after digests, so config or state drift invalidates the
artifact automatically (approval_stale warning, never authorizes). One gate
per subtree: compute_approvals lists only the graph-level delete, and
ApprovalRequirement gains a satisfied flag surfaced by plan. Consumption and
the delete executor land next — until then approved deletes stay blocked so
a gate-only build can never strip state without removing the root.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f799d4578c
commit
f4e9105272
3 changed files with 605 additions and 45 deletions
|
|
@ -11,8 +11,8 @@ use omnigraph::db::{Omnigraph, ReadTarget, SnapshotId};
|
|||
use omnigraph::loader::LoadMode;
|
||||
use omnigraph::storage::normalize_root_uri;
|
||||
use omnigraph_cluster::{
|
||||
ApplyOptions, ApplyOutput, DiagnosticSeverity, ForceUnlockOutput, PlanOutput, StateSyncOutput, StatusOutput,
|
||||
ValidateOutput, apply_config_dir_with_options, force_unlock_config_dir, import_config_dir, plan_config_dir,
|
||||
ApplyOptions, ApplyOutput, ApproveOutput, DiagnosticSeverity, ForceUnlockOutput, PlanOutput, StateSyncOutput, StatusOutput,
|
||||
ValidateOutput, apply_config_dir_with_options, approve_config_dir, force_unlock_config_dir, import_config_dir, plan_config_dir,
|
||||
refresh_config_dir, status_config_dir, validate_config_dir,
|
||||
};
|
||||
use omnigraph_compiler::query::parser::parse_query;
|
||||
|
|
@ -371,6 +371,18 @@ enum ClusterCommand {
|
|||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Record a digest-bound approval for a gated (irreversible) change,
|
||||
/// e.g. a graph delete. Requires the global --as actor.
|
||||
Approve {
|
||||
/// Typed resource address of the gated change (e.g. graph.scratch).
|
||||
resource: String,
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Read the local JSON state ledger without scanning live graph resources.
|
||||
Status {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
|
|
@ -1011,6 +1023,33 @@ fn finish_cluster_apply(output: &ApplyOutput, json: bool) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_cluster_approve(output: &ApproveOutput, json: bool) -> Result<()> {
|
||||
if json {
|
||||
print_json(output)?;
|
||||
} else if output.ok {
|
||||
println!(
|
||||
"cluster approve: {} {} approved by {} (approval {})",
|
||||
output
|
||||
.operation
|
||||
.as_ref()
|
||||
.map(|operation| format!("{operation:?}").to_lowercase())
|
||||
.unwrap_or_default(),
|
||||
output.resource.as_deref().unwrap_or("?"),
|
||||
output.approved_by.as_deref().unwrap_or("?"),
|
||||
output.approval_id.as_deref().unwrap_or("?"),
|
||||
);
|
||||
print_cluster_diagnostics(&output.diagnostics);
|
||||
} else {
|
||||
println!("cluster approve failed");
|
||||
print_cluster_diagnostics(&output.diagnostics);
|
||||
}
|
||||
if !output.ok {
|
||||
io::stdout().flush()?;
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_cluster_status(output: &StatusOutput, json: bool) -> Result<()> {
|
||||
if json {
|
||||
print_json(output)?;
|
||||
|
|
@ -3581,6 +3620,19 @@ async fn main() -> Result<()> {
|
|||
.await;
|
||||
finish_cluster_apply(&output, json)?;
|
||||
}
|
||||
ClusterCommand::Approve {
|
||||
resource,
|
||||
config,
|
||||
json,
|
||||
} => {
|
||||
let Some(approver) = cli.as_actor.as_deref() else {
|
||||
bail!(
|
||||
"`cluster approve` requires the global --as <ACTOR> flag: an approval without an approver is meaningless"
|
||||
);
|
||||
};
|
||||
let output = approve_config_dir(config, &resource, approver).await;
|
||||
finish_cluster_approve(&output, json)?;
|
||||
}
|
||||
ClusterCommand::Status { config, json } => {
|
||||
let output = status_config_dir(config);
|
||||
finish_cluster_status(&output, json)?;
|
||||
|
|
|
|||
|
|
@ -1424,22 +1424,29 @@ policies:
|
|||
let mixed = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(mixed["ok"], true, "{mixed}");
|
||||
assert_eq!(mixed["converged"], false, "{mixed}");
|
||||
// Stage 4C: deletes are gated on a digest-bound approval, one gate per
|
||||
// subtree (the graph-level approval carries schema + queries).
|
||||
assert_eq!(
|
||||
change_for(&mixed, "graph.engineering")["disposition"],
|
||||
"deferred"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "schema.engineering")["disposition"],
|
||||
"deferred"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "query.engineering.find_service")["disposition"],
|
||||
"blocked"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "query.engineering.find_service")["reason"],
|
||||
"dependency_not_applied"
|
||||
change_for(&mixed, "graph.engineering")["reason"],
|
||||
"approval_required"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "schema.engineering")["reason"],
|
||||
"approval_required"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "query.engineering.find_service")["reason"],
|
||||
"approval_required"
|
||||
);
|
||||
let gate_plan = cluster_json(temp.path(), "plan");
|
||||
let gates = gate_plan["approvals_required"].as_array().unwrap();
|
||||
assert_eq!(gates.len(), 1, "{gate_plan}");
|
||||
assert_eq!(gates[0]["resource"], "graph.engineering");
|
||||
assert_eq!(gates[0]["satisfied"], false);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "query.knowledge.find_person")["disposition"],
|
||||
"applied"
|
||||
|
|
@ -1461,7 +1468,7 @@ policies:
|
|||
let mut sorted = order.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(order, sorted, "{mixed}");
|
||||
// Graph deletion cannot converge until stage 4C's approval artifacts.
|
||||
// Conclusion (approve + converge) extends below once the delete executor lands.
|
||||
}
|
||||
|
||||
/// Stage 4A headline: a declared graph is created by `cluster apply` itself —
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue