feat(cli): resolve cluster actor via the per-operator config cascade

Cluster FACTS stay unlayered (cluster.yaml only), but the operator's
identity is a per-operator fact — exactly the per-operator omnigraph.yaml's
permanent job, and the cascade every data-plane write already uses. cluster
apply/approve now resolve: --as flag wins and skips any config read
entirely (containers and CI stay config-free); without it, the standard cwd
search supplies cli.actor, with a malformed config failing loudly and
actionably ('pass --as to skip this lookup') rather than silently dropping
attribution. approve's no-actor error now names both sources.

Tests pin the contract from both sides: cli.actor is the no-flag default
for apply (echoed actor) and approve (approved_by), the flag overrides it,
a malformed omnigraph.yaml in cwd breaks nothing except the no-flag actor
lookup, and a conflicting well-formed one leaks nothing into cluster
outputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
aaltshuler 2026-06-10 22:29:49 +03:00
parent b8300736be
commit f3374ac6dc
2 changed files with 254 additions and 14 deletions

View file

@ -6,7 +6,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use clap::{Arg, ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use color_eyre::eyre::{Result, bail};
use color_eyre::eyre::{Result, WrapErr, bail};
use omnigraph::db::{Omnigraph, ReadTarget, SnapshotId};
use omnigraph::loader::LoadMode;
use omnigraph::storage::normalize_root_uri;
@ -1257,6 +1257,22 @@ async fn open_local_db_with_policy(graph: &ResolvedCliGraph) -> Result<Omnigraph
/// policy is configured and this returns `None`, the engine-layer
/// footgun guard intentionally denies — silent bypass via "I forgot the
/// actor" is what the guard prevents.
/// Actor resolution for cluster operations. Cluster FACTS stay unlayered
/// (cluster.yaml only), but the operator's identity is a per-operator fact —
/// the per-operator config's permanent job. An explicit --as never touches
/// any config (containers and CI stay config-free); without it, the standard
/// cwd omnigraph.yaml search supplies `cli.actor`, and a malformed config
/// fails loudly rather than silently dropping attribution.
fn resolve_cluster_actor(cli_as: Option<&str>) -> Result<Option<String>> {
if let Some(actor) = cli_as {
return Ok(Some(actor.to_string()));
}
let config = load_cli_config(None).wrap_err(
"resolving the default actor from the per-operator omnigraph.yaml (pass --as <ACTOR> to skip this lookup)",
)?;
Ok(config.cli.actor.clone())
}
fn resolve_cli_actor<'a>(cli_as: Option<&'a str>, config: &'a OmnigraphConfig) -> Option<&'a str> {
cli_as.or(config.cli.actor.as_deref())
}
@ -3610,16 +3626,12 @@ async fn main() -> Result<()> {
finish_cluster_plan(&output, json)?;
}
ClusterCommand::Apply { config, json } => {
// The global --as actor attributes graph-moving operations
// (sidecars, audit entries, engine schema-apply commits).
// Cluster config stays unlayered: no omnigraph.yaml fallback.
let output = apply_config_dir_with_options(
config,
ApplyOptions {
actor: cli.as_actor.clone(),
},
)
.await;
// The actor attributes graph-moving operations (sidecars,
// audit entries, engine schema-apply commits). Cluster FACTS
// stay unlayered; the operator's identity resolves --as flag
// first, then the per-operator omnigraph.yaml `cli.actor`.
let actor = resolve_cluster_actor(cli.as_actor.as_deref())?;
let output = apply_config_dir_with_options(config, ApplyOptions { actor }).await;
finish_cluster_apply(&output, json)?;
}
ClusterCommand::Approve {
@ -3627,12 +3639,12 @@ async fn main() -> Result<()> {
config,
json,
} => {
let Some(approver) = cli.as_actor.as_deref() else {
let Some(approver) = resolve_cluster_actor(cli.as_actor.as_deref())? else {
bail!(
"`cluster approve` requires the global --as <ACTOR> flag: an approval without an approver is meaningless"
"`cluster approve` requires an approver: pass the global --as <ACTOR> flag or set `cli.actor` in your omnigraph.yaml — an approval without an approver is meaningless"
);
};
let output = approve_config_dir(config, &resource, approver).await;
let output = approve_config_dir(config, &resource, &approver).await;
finish_cluster_approve(&output, json)?;
}
ClusterCommand::Status { config, json } => {