diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 44d4c0c..ae33d14 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -18,10 +18,10 @@ any — run against a graph, served (--server / --profile) or embedded (--store URI): query, mutate, load, branch, snapshot, export, commit, schema show/apply.\n \ served — require a server: graphs.\n \ direct — direct storage access; reject --server (init, optimize, repair, cleanup, \ -schema plan, lint, queries validate).\n \ -control — manage a cluster via --config: cluster.\n \ -local — no graph; local config & tooling: policy, embed, login, logout, config, \ -version, queries list.\n\ +schema plan, lint).\n \ +control — manage or inspect a cluster (cluster via --config; policy & queries via \ +--cluster).\n \ +local — no graph; local config & tooling: embed, login, logout, config, version.\n\ See the 'Command capabilities' section of the CLI reference for which flags apply where.")] pub(crate) struct Cli { /// Actor id for direct-engine writes; overrides `cli.actor`. No effect on @@ -96,8 +96,6 @@ pub(crate) enum Command { /// the catalog (served — addressed via --server/--profile). With /// `--query`/`-e`, selects which query in that ad-hoc source to run. name: Option, - #[arg(long)] - config: Option, /// Ad-hoc query file (a `.gq` you're authoring / break-glass). #[arg(long, conflicts_with = "query_string")] query: Option, @@ -126,8 +124,6 @@ pub(crate) enum Command { /// from the catalog (served — addressed via --server/--profile). With /// `--query`/`-e`, selects which query in that ad-hoc source to run. name: Option, - #[arg(long)] - config: Option, /// Ad-hoc mutation file (a `.gq` you're authoring / break-glass). #[arg(long, conflicts_with = "query_string")] query: Option, @@ -154,8 +150,6 @@ pub(crate) enum Command { name: String, /// Positional args bound to the alias's declared `args` params, in order. args: Vec, - #[arg(long)] - config: Option, #[command(flatten)] params: ParamsArgs, #[arg(long, conflicts_with = "json")] @@ -168,8 +162,6 @@ pub(crate) enum Command { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] data: PathBuf, /// Target branch (defaults to main). Without --from it must exist. #[arg(long)] @@ -191,8 +183,6 @@ pub(crate) enum Command { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] data: PathBuf, #[arg(long)] branch: Option, @@ -213,8 +203,6 @@ pub(crate) enum Command { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] branch: Option, #[arg(long)] json: bool, @@ -224,8 +212,6 @@ pub(crate) enum Command { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] branch: Option, #[arg(long, hide = true)] jsonl: bool, @@ -270,16 +256,12 @@ pub(crate) enum Command { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] json: bool, }, /// Classify and explicitly repair manifest/head drift Repair { /// Graph URI uri: Option, - #[arg(long)] - config: Option, /// Publish verified maintenance drift. Without this flag, repair only /// previews what it would do. #[arg(long)] @@ -295,8 +277,6 @@ pub(crate) enum Command { Cleanup { /// Graph URI uri: Option, - #[arg(long)] - config: Option, /// Number of recent versions to keep per table. Either `--keep` or /// `--older-than` (or both) must be set. #[arg(long)] @@ -326,8 +306,6 @@ pub(crate) enum Command { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] query: PathBuf, #[arg(long)] schema: Option, @@ -480,8 +458,6 @@ pub(crate) enum GraphsCommand { #[arg(long)] uri: Option, #[arg(long)] - config: Option, - #[arg(long)] json: bool, }, } @@ -494,8 +470,6 @@ pub(crate) enum BranchCommand { #[arg(long)] uri: Option, #[arg(long)] - config: Option, - #[arg(long)] from: Option, name: String, #[arg(long)] @@ -507,8 +481,6 @@ pub(crate) enum BranchCommand { #[arg(long)] uri: Option, #[arg(long)] - config: Option, - #[arg(long)] json: bool, }, /// Delete a branch @@ -516,8 +488,6 @@ pub(crate) enum BranchCommand { /// Graph URI #[arg(long)] uri: Option, - #[arg(long)] - config: Option, name: String, #[arg(long)] json: bool, @@ -527,8 +497,6 @@ pub(crate) enum BranchCommand { /// Graph URI #[arg(long)] uri: Option, - #[arg(long)] - config: Option, source: String, #[arg(long)] into: Option, @@ -544,8 +512,6 @@ pub(crate) enum SchemaCommand { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] schema: PathBuf, #[arg(long)] json: bool, @@ -560,8 +526,6 @@ pub(crate) enum SchemaCommand { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] schema: PathBuf, #[arg(long)] json: bool, @@ -583,8 +547,6 @@ pub(crate) enum SchemaCommand { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] json: bool, }, } @@ -597,8 +559,6 @@ pub(crate) enum CommitCommand { /// Graph URI uri: Option, #[arg(long)] - config: Option, - #[arg(long)] branch: Option, #[arg(long)] json: bool, @@ -608,8 +568,6 @@ pub(crate) enum CommitCommand { /// Graph URI #[arg(long)] uri: Option, - #[arg(long)] - config: Option, commit_id: String, #[arg(long)] json: bool, @@ -618,20 +576,24 @@ pub(crate) enum CommitCommand { #[derive(Debug, Subcommand)] pub(crate) enum PolicyCommand { - /// Validate policy YAML and compiled Cedar policy state - Validate { - #[arg(long)] - config: Option, - }, - /// Run declarative policy tests from policy.tests.yaml + /// Compile and validate the Cedar policy bundle(s) applied in a cluster. + /// + /// Sources the bundle(s) from the cluster's applied policies + /// (`--cluster `); pass the global `--graph ` to pick one + /// graph's bundle when several apply. + Validate {}, + /// Run declarative policy tests against a cluster's applied bundle. + /// + /// The cluster model has no per-bundle tests file, so the cases are + /// supplied explicitly with `--tests ` and checked against the + /// bundle selected by `--cluster` (+ optional `--graph`). Test { + /// Path to a policy.tests.yaml file. #[arg(long)] - config: Option, + tests: PathBuf, }, - /// Explain one policy decision locally + /// Explain one policy decision against a cluster's applied bundle. Explain { - #[arg(long)] - config: Option, #[arg(long)] actor: String, #[arg(long)] @@ -645,24 +607,19 @@ pub(crate) enum PolicyCommand { #[derive(Debug, Subcommand)] pub(crate) enum QueriesCommand { - /// Type-check the stored-query registry against the live schema. + /// Type-check a cluster's stored-query registry against its schemas. /// - /// Distinct from `omnigraph lint` (which lints one `.gq` file): - /// this validates the whole `queries:` registry — opening the graph - /// to read its schema and confirming every stored query still - /// type-checks. Exits non-zero on any breakage. + /// Distinct from `omnigraph lint` (which lints one `.gq` file): this + /// validates the whole `queries:` registry of a cluster (`--cluster + /// `, optional `--graph `) by reading each graph's applied + /// schema and confirming every stored query still type-checks. Exits + /// non-zero on any breakage. Validate { - /// Graph URI - uri: Option, - #[arg(long)] - config: Option, #[arg(long)] json: bool, }, - /// List the registered stored queries (name, MCP exposure, params). + /// List a cluster's registered stored queries (name, params). List { - #[arg(long)] - config: Option, #[arg(long)] json: bool, }, diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 653da42..7151f5e 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -40,22 +40,20 @@ use serde_json::Value; use crate::cli::CliLoadMode; use crate::helpers::{ - ResolvedCliGraph, apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri, - legacy_change_request_body, open_local_db_with_policy, query_params_from_json, + apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri, + legacy_change_request_body, query_params_from_json, remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token, resolve_server_flag, select_named_query, }; use crate::output::{LoadOutput, load_output_from_result, load_output_from_tables}; -use omnigraph_server::config::OmnigraphConfig; pub(crate) enum GraphClient { - /// Local engine at `uri`. Reads (`resolve()`) leave `graph`/`actor` - /// empty and open without policy; writes (`resolve_with_policy()`) - /// fill them, opening through `open_local_db_with_policy` and - /// attributing the resolved actor. + /// Local engine at `uri`. Reads (`resolve()`) leave `actor` empty; + /// writes (`resolve_with_policy()`) attribute the resolved actor. + /// Direct-store access carries no Cedar policy (RFC-011: policy lives + /// in the cluster/server, not in per-operator addressing). Embedded { uri: String, - graph: Option, actor: Option, }, /// Remote HTTP server. The actor is resolved server-side from the @@ -75,7 +73,6 @@ pub(crate) enum GraphClient { /// is then correct, or the real request surfaces the failure. Only fires on the /// no-graph path, so a `--graph`/`default_graph` happy path does no extra I/O. async fn require_graph_for_multi_graph_server( - config: &OmnigraphConfig, scope: &crate::scope::ResolvedScope, ) -> Result<()> { let (Some(server), None) = (scope.server.as_deref(), scope.graph.as_deref()) else { @@ -84,7 +81,7 @@ async fn require_graph_for_multi_graph_server( let Some(base) = resolve_server_flag(Some(server), None)? else { return Ok(()); }; - let token = resolve_remote_bearer_token(config, Some(&base))?; + let token = resolve_remote_bearer_token(Some(&base))?; let probe = GraphClient::Remote { http: build_http_client()?, base_url: base, @@ -126,7 +123,6 @@ impl GraphClient { /// path, not the policy-bearing `resolve_cli_graph`). Used by reads /// and `query` (which opens without policy, like the reads). pub(crate) async fn resolve( - config: &OmnigraphConfig, server: Option<&str>, graph: Option<&str>, uri: Option, @@ -141,7 +137,7 @@ impl GraphClient { crate::planes::Capability::Any, crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri }, )?; - require_graph_for_multi_graph_server(config, &scope).await?; + require_graph_for_multi_graph_server(&scope).await?; let (server, graph, uri) = ( scope.server.as_deref(), scope.graph.as_deref(), @@ -149,8 +145,8 @@ impl GraphClient { ); let via_server = server.is_some(); let uri = apply_server_flag(server, graph, uri)?; - let token = resolve_remote_bearer_token(config, uri.as_deref())?; - let uri = crate::helpers::resolve_uri(config, uri)?; + let token = resolve_remote_bearer_token(uri.as_deref())?; + let uri = crate::helpers::resolve_uri(uri)?; reject_positional_remote(via_server, &uri)?; if is_remote_uri(&uri) { Ok(GraphClient::Remote { @@ -159,11 +155,7 @@ impl GraphClient { token, }) } else { - Ok(GraphClient::Embedded { - uri, - graph: None, - actor: None, - }) + Ok(GraphClient::Embedded { uri, actor: None }) } } @@ -174,7 +166,6 @@ impl GraphClient { /// resolution order matches the write arms exactly: server flag → /// bearer token → graph. pub(crate) async fn resolve_with_policy( - config: &OmnigraphConfig, server: Option<&str>, graph: Option<&str>, uri: Option, @@ -189,7 +180,7 @@ impl GraphClient { crate::planes::Capability::Any, crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri }, )?; - require_graph_for_multi_graph_server(config, &scope).await?; + require_graph_for_multi_graph_server(&scope).await?; let (server, graph, uri) = ( scope.server.as_deref(), scope.graph.as_deref(), @@ -197,8 +188,8 @@ impl GraphClient { ); let via_server = server.is_some(); let uri = apply_server_flag(server, graph, uri)?; - let token = resolve_remote_bearer_token(config, uri.as_deref())?; - let resolved = resolve_cli_graph(config, uri)?; + let token = resolve_remote_bearer_token(uri.as_deref())?; + let resolved = resolve_cli_graph(uri)?; reject_positional_remote(via_server, &resolved.uri)?; if resolved.is_remote { // A served write resolves the actor server-side from the bearer @@ -216,10 +207,9 @@ impl GraphClient { token, }) } else { - let actor = resolve_cli_actor(cli_as, config)?; + let actor = resolve_cli_actor(cli_as)?; Ok(GraphClient::Embedded { - uri: resolved.uri.clone(), - graph: Some(resolved), + uri: resolved.uri, actor, }) } @@ -233,28 +223,15 @@ impl GraphClient { } } - /// The selected graph name, when a policy-bearing embedded client was - /// resolved against a named graph. `None` for remote and for reads. - pub(crate) fn selected(&self) -> Option<&str> { - match self { - GraphClient::Embedded { graph, .. } => graph.as_ref().and_then(ResolvedCliGraph::selected), - GraphClient::Remote { .. } => None, - } - } - pub(crate) fn is_remote(&self) -> bool { matches!(self, GraphClient::Remote { .. }) } - /// Open the local engine the way the resolved client demands: with - /// policy when a `graph` context is present (write path), bare - /// otherwise (read/`query` path). Captures today's two open paths in - /// one place so each verb stays a single match arm. - async fn open_embedded(uri: &str, graph: &Option) -> Result { - match graph { - Some(graph) => open_local_db_with_policy(graph).await, - None => Ok(Omnigraph::open(uri).await?), - } + /// Open the local engine. Direct-store access carries no Cedar policy + /// (RFC-011), so both read and write paths open bare; the actor is still + /// attributed on the write via the `_as` engine APIs. + async fn open_embedded(uri: &str) -> Result { + Ok(Omnigraph::open(uri).await?) } pub(crate) async fn branch_list(&self) -> Result { @@ -416,8 +393,8 @@ impl GraphClient { .await?; Ok(load_output_from_tables(base_url, branch, mode.as_str(), &output)) } - GraphClient::Embedded { uri, graph, actor } => { - let db = Self::open_embedded(uri, graph).await?; + GraphClient::Embedded { uri, actor } => { + let db = Self::open_embedded(uri).await?; let result = db .load_file_as(branch, from, data, mode.into(), actor.as_deref()) .await?; @@ -459,8 +436,8 @@ impl GraphClient { ) .await } - GraphClient::Embedded { uri, graph, actor } => { - let db = Self::open_embedded(uri, graph).await?; + GraphClient::Embedded { uri, actor } => { + let db = Self::open_embedded(uri).await?; let result = db .load_file_as(branch, Some(from), data, mode.into(), actor.as_deref()) .await?; @@ -498,10 +475,10 @@ impl GraphClient { ) .await } - GraphClient::Embedded { uri, graph, actor } => { + GraphClient::Embedded { uri, actor } => { let (selected_name, query_params) = select_named_query(query_source, query_name)?; let params = query_params_from_json(&query_params, params_json)?; - let db = Self::open_embedded(uri, graph).await?; + let db = Self::open_embedded(uri).await?; let actor = actor.as_deref(); let result = db .mutate_as(branch, query_source, &selected_name, ¶ms, actor) @@ -552,10 +529,10 @@ impl GraphClient { ) .await } - GraphClient::Embedded { uri, graph, .. } => { + GraphClient::Embedded { uri, .. } => { let (selected_name, query_params) = select_named_query(query_source, query_name)?; let params = query_params_from_json(&query_params, params_json)?; - let db = Self::open_embedded(uri, graph).await?; + let db = Self::open_embedded(uri).await?; let result = db .query(target.clone(), query_source, &selected_name, ¶ms) .await?; @@ -631,8 +608,8 @@ impl GraphClient { ) .await } - GraphClient::Embedded { uri, graph, actor } => { - let db = Self::open_embedded(uri, graph).await?; + GraphClient::Embedded { uri, actor } => { + let db = Self::open_embedded(uri).await?; let actor = actor.as_deref(); db.branch_create_from_as(ReadTarget::branch(from), name, actor) .await?; @@ -662,8 +639,8 @@ impl GraphClient { ) .await } - GraphClient::Embedded { uri, graph, actor } => { - let db = Self::open_embedded(uri, graph).await?; + GraphClient::Embedded { uri, actor } => { + let db = Self::open_embedded(uri).await?; let actor = actor.as_deref(); db.branch_delete_as(name, actor).await?; Ok(BranchDeleteOutput { @@ -694,8 +671,8 @@ impl GraphClient { ) .await } - GraphClient::Embedded { uri, graph, actor } => { - let db = Self::open_embedded(uri, graph).await?; + GraphClient::Embedded { uri, actor } => { + let db = Self::open_embedded(uri).await?; let actor = actor.as_deref(); let outcome = db.branch_merge_as(source, into, actor).await?; Ok(BranchMergeOutput { @@ -745,8 +722,8 @@ impl GraphClient { ) .await } - GraphClient::Embedded { uri, graph, actor } => { - let db = Self::open_embedded(uri, graph).await?; + GraphClient::Embedded { uri, actor } => { + let db = Self::open_embedded(uri).await?; let result = db .apply_schema_as_with_catalog_check( schema_source, @@ -815,9 +792,9 @@ impl GraphClient { /// `graphs list` — enumerate the graphs a remote multi-graph server /// serves (`GET /graphs`). Remote-only by design: there is no local - /// enumeration endpoint, so the Embedded arm fails loudly pointing the - /// operator at `omnigraph.yaml`. Routing it through the enum still buys - /// the shared `resolve()` addressing/token preamble. + /// enumeration endpoint, so the Embedded arm fails loudly. Routing it + /// through the enum still buys the shared `resolve()` addressing/token + /// preamble. pub(crate) async fn list_graphs(&self) -> Result { match self { GraphClient::Remote { @@ -835,9 +812,9 @@ impl GraphClient { .await } GraphClient::Embedded { .. } => bail!( - "`omnigraph graphs list` requires a remote multi-graph server URL \ - (http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \ - directly." + "`omnigraph graphs list` requires a remote multi-graph server \ + (--server ). To enumerate the graphs in a cluster, run \ + `omnigraph cluster status --config `." ), } } diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index 1683ef2..5d06b2a 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -119,231 +119,164 @@ pub(crate) fn bearer_token_from_env(var_name: &str) -> Option { normalize_bearer_token(std::env::var(var_name).ok()) } -pub(crate) fn parse_env_assignment(line: &str) -> Option<(String, String)> { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - return None; - } - - let line = line.strip_prefix("export ").unwrap_or(line).trim(); - let (name, value) = line.split_once('=')?; - let name = name.trim(); - if name.is_empty() { - return None; - } - - let value = value.trim(); - let value = if value.len() >= 2 - && ((value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\''))) - { - &value[1..value.len() - 1] - } else { - value - }; - - Some((name.to_string(), value.to_string())) -} - -pub(crate) fn bearer_token_from_env_file(path: &Path, var_name: &str) -> Result> { - if !path.exists() { - return Ok(None); - } - - for line in fs::read_to_string(path)?.lines() { - let Some((name, value)) = parse_env_assignment(line) else { - continue; - }; - if name == var_name { - return Ok(normalize_bearer_token(Some(value))); - } - } - - Ok(None) -} - -pub(crate) fn load_env_file_into_process(path: &Path) -> Result<()> { - if !path.exists() { - return Ok(()); - } - - for line in fs::read_to_string(path)?.lines() { - let Some((name, value)) = parse_env_assignment(line) else { - continue; - }; - if std::env::var_os(&name).is_none() { - unsafe { - std::env::set_var(name, value); - } - } - } - - Ok(()) -} - -pub(crate) fn load_cli_config(config_path: Option<&PathBuf>) -> Result { - let config = load_config(config_path)?; - if let Some(path) = config.resolve_auth_env_file() { - load_env_file_into_process(&path)?; - } - Ok(config) -} - #[derive(Debug, Clone)] pub(crate) struct ResolvedCliGraph { pub(crate) uri: String, - pub(crate) selected: Option, - pub(crate) graph_id: String, - pub(crate) policy_file: Option, pub(crate) is_remote: bool, } -impl ResolvedCliGraph { - pub(crate) fn selected(&self) -> Option<&str> { - self.selected.as_deref() - } -} - -pub(crate) struct ResolvedPolicyContext { - pub(crate) policy_file: PathBuf, - pub(crate) graph_id: String, -} - -pub(crate) fn resolve_policy_context(config: &OmnigraphConfig) -> Result { - let selected = config.resolve_policy_tooling_graph_selection()?; - let policy_file = config.resolve_policy_file_for(selected).ok_or_else(|| { - color_eyre::eyre::eyre!( - "policy.file or graphs..policy.file must be set in omnigraph.yaml" - ) - })?; - let graph_id = match selected { - Some(name) => graph_resource_id_for_selection(Some(name), ""), - None => graph_resource_id_for_selection(None, "default"), +/// Resolve the cluster for a control-plane tooling command (`policy`, +/// `queries`) from `--cluster`. A configured name (`clusters:` in operator +/// config) is rewritten to its root; a literal dir / `s3://`/`file://` root is +/// passed through. A `--profile`/`OMNIGRAPH_PROFILE` cluster binding also +/// resolves here when `--cluster` is absent. No omnigraph.yaml. +pub(crate) fn require_cluster_scope( + cluster: Option<&str>, + profile: Option<&str>, + command: &str, +) -> Result { + let op = operator::load_operator_config()?; + let resolve_name = |name: &str| { + op.cluster_root(name) + .map(str::to_string) + .unwrap_or_else(|| name.to_string()) }; - Ok(ResolvedPolicyContext { - policy_file, - graph_id, - }) + if let Some(cluster) = cluster { + return Ok(resolve_name(cluster)); + } + // A cluster profile (flag, else OMNIGRAPH_PROFILE) binds the cluster too. + let profile_name = profile + .map(str::to_string) + .or_else(|| std::env::var(scope::PROFILE_ENV).ok().filter(|s| !s.is_empty())); + if let Some(name) = profile_name { + let profile = op.profile(&name).ok_or_else(|| { + color_eyre::eyre::eyre!("unknown profile '{name}' (not defined under `profiles:`)") + })?; + if let crate::operator::ScopeBinding::Cluster(cluster) = profile.binding(&name)? { + return Ok(resolve_name(&cluster)); + } + } + bail!( + "`{command}` needs a cluster — pass --cluster (or a name from `clusters:` \ + in ~/.omnigraph/config.yaml), or select a cluster profile" + ) } -pub(crate) fn resolve_policy_engine(context: &ResolvedPolicyContext) -> Result { - PolicyEngine::load_graph(&context.policy_file, &context.graph_id) +/// Read a cluster's serving snapshot for a control-plane tooling command, +/// flattening the readiness `Diagnostic` list into one loud error. The single +/// snapshot entry point for `policy`/`queries` so the not-servable message stays +/// identical across them. +async fn read_serving_snapshot_or_report( + cluster: &str, +) -> Result { + omnigraph_cluster::read_serving_snapshot(cluster) + .await + .map_err(|diagnostics| { + color_eyre::eyre::eyre!( + "cluster `{cluster}` is not servable:\n {}", + diagnostics + .iter() + .map(|d| d.message.clone()) + .collect::>() + .join("\n ") + ) + }) } -pub(crate) fn resolve_policy_engine_for_graph(graph: &ResolvedCliGraph) -> Result { - let policy_file = graph.policy_file.as_ref().ok_or_else(|| { - color_eyre::eyre::eyre!( - "policy.file or graphs..policy.file must be set in omnigraph.yaml" - ) - })?; - PolicyEngine::load_graph(policy_file, &graph.graph_id) +/// Resolve the Cedar policy bundle(s) for a `--cluster` policy-tooling command +/// (RFC-011). Sources the applied policies from the cluster's serving snapshot; +/// each `ServingPolicy` carries its `source` (digest-verified content) and the +/// scopes it `applies_to` (`cluster` | `graph.`). The optional `graph` +/// selects a graph's bundle when several apply. +pub(crate) async fn read_cluster_policies( + cluster: &str, +) -> Result> { + Ok(read_serving_snapshot_or_report(cluster).await?.policies) } -pub(crate) async fn open_local_db_with_policy(graph: &ResolvedCliGraph) -> Result { - let db = Omnigraph::open(&graph.uri).await?; - if graph.policy_file.is_some() { - let engine = Arc::new(resolve_policy_engine_for_graph(graph)?); - Ok(db.with_policy(engine as Arc)) - } else { - Ok(db) +/// Pick the single policy bundle that applies to the selection. With `--graph`, +/// the bundle bound to `graph.` (or the cluster-wide one); without it, the +/// sole bundle if there's exactly one. Ambiguity or absence is a loud error. +pub(crate) fn select_cluster_policy<'p>( + cluster: &str, + policies: &'p [omnigraph_cluster::ServingPolicy], + graph: Option<&str>, +) -> Result<&'p omnigraph_cluster::ServingPolicy> { + if let Some(graph_id) = graph { + let graph_ref = format!("graph.{graph_id}"); + let matching: Vec<&omnigraph_cluster::ServingPolicy> = policies + .iter() + .filter(|p| { + p.applies_to + .iter() + .any(|s| s == &graph_ref || s == "cluster") + }) + .collect(); + return match matching.as_slice() { + [only] => Ok(only), + [] => bail!( + "cluster `{cluster}` has no policy bundle bound to graph `{graph_id}` \ + (or to the cluster scope)" + ), + many => bail!( + "graph `{graph_id}` in cluster `{cluster}` matches {} policy bundles ([{}]); \ + the cluster model expects one bundle per graph scope", + many.len(), + many.iter().map(|p| p.name.as_str()).collect::>().join(", ") + ), + }; + } + match policies { + [only] => Ok(only), + [] => bail!("cluster `{cluster}` has no applied policy bundles"), + many => bail!( + "cluster `{cluster}` has {} policy bundles ([{}]); pass --graph to select one", + many.len(), + many.iter().map(|p| p.name.as_str()).collect::>().join(", ") + ), } } -/// THE actor chain (RFC-007 §D3) — every command that needs an identity +/// THE actor chain (RFC-011) — every command that needs an identity /// resolves through this one function (one path per concern): -/// `--as` > legacy `cli.actor` in omnigraph.yaml (RFC-008 window) > -/// `operator.actor` in ~/.omnigraph/config.yaml > none. -pub(crate) fn resolve_actor( - cli_as: Option<&str>, - legacy_config_actor: Option<&str>, -) -> Result> { +/// `--as` > `operator.actor` in ~/.omnigraph/config.yaml > none. +pub(crate) fn resolve_actor(cli_as: Option<&str>) -> Result> { if let Some(actor) = cli_as { return Ok(Some(actor.to_string())); } - if let Some(actor) = legacy_config_actor { - return Ok(Some(actor.to_string())); - } Ok(operator::load_operator_config()? .actor() .map(str::to_string)) } pub(crate) fn resolve_cluster_actor(cli_as: Option<&str>) -> Result> { - if let Some(actor) = cli_as { - return Ok(Some(actor.to_string())); - } - let config = load_config(None).wrap_err( - "resolving the default actor from omnigraph.yaml (pass --as to skip this lookup)", - )?; - resolve_actor(None, config.cli.actor.as_deref()) + resolve_actor(cli_as) } -pub(crate) fn resolve_cli_actor( - cli_as: Option<&str>, - config: &OmnigraphConfig, -) -> Result> { - resolve_actor(cli_as, config.cli.actor.as_deref()) +pub(crate) fn resolve_cli_actor(cli_as: Option<&str>) -> Result> { + resolve_actor(cli_as) } -pub(crate) fn resolve_policy_tests_path(context: &ResolvedPolicyContext) -> PathBuf { - context.policy_file.with_file_name("policy.tests.yaml") -} - -pub(crate) fn normalize_policy_graph_uri(uri: &str) -> Result { - if is_remote_uri(uri) { - Ok(uri.trim_end_matches('/').to_string()) - } else { - Ok(normalize_root_uri(uri)?) - } -} - -pub(crate) fn resolve_remote_bearer_token( - config: &OmnigraphConfig, - explicit_uri: Option<&str>, -) -> Result> { - // `--target` is gone; the legacy explicit-target name is always None. - let explicit_target: Option<&str> = None; +/// The bearer token for a remote request (RFC-011): the operator keyed chain +/// for the matching server (`OMNIGRAPH_TOKEN_` env → 0600 credentials +/// file), then the default `OMNIGRAPH_BEARER_TOKEN` env. No omnigraph.yaml +/// chain. +pub(crate) fn resolve_remote_bearer_token(explicit_uri: Option<&str>) -> Result> { // The keyed hop (RFC-007 §D4, gh-host model): when the effective remote // URL belongs to an operator-defined server, that server's keyed chain // applies first — OMNIGRAPH_TOKEN_ env, then the 0600 credentials - // file. Ok(None) falls through to the legacy chain unchanged, and the - // keyed token is structurally scoped to its own server (§D5 rule 3): - // a URL matching no operator server never sees it. - if let Some(remote_url) = effective_remote_url(config, explicit_uri, explicit_target) { + // file. The keyed token is structurally scoped to its own server: a URL + // matching no operator server never sees it. + if let Some(remote_url) = explicit_uri.filter(|uri| is_remote_uri(uri)) { let operator_config = operator::load_operator_config()?; - if let Some(server) = operator_config.find_server_for_url(&remote_url) { + if let Some(server) = operator_config.find_server_for_url(remote_url) { if let Some(token) = operator::resolve_keyed_token(server)? { return Ok(Some(token)); } } } - let scoped_env = - config.graph_bearer_token_env(explicit_uri, explicit_target, config.cli_graph_name()); - let mut env_names = Vec::new(); - if let Some(name) = scoped_env { - env_names.push(name.to_string()); - } - if env_names - .iter() - .all(|name| name != DEFAULT_BEARER_TOKEN_ENV) - { - env_names.push(DEFAULT_BEARER_TOKEN_ENV.to_string()); - } - - let env_file = config.resolve_auth_env_file(); - for env_name in env_names { - if let Some(token) = bearer_token_from_env(&env_name) { - return Ok(Some(token)); - } - if let Some(path) = env_file.as_ref() { - if let Some(token) = bearer_token_from_env_file(path, &env_name)? { - return Ok(Some(token)); - } - } - } - - Ok(None) + Ok(bearer_token_from_env(DEFAULT_BEARER_TOKEN_ENV)) } /// `--server ` (RFC-007 PR 3): resolve an operator-defined server @@ -391,7 +324,6 @@ pub(crate) fn resolve_server_flag( /// params. The keyed token applies via the ordinary URL match. pub(crate) async fn execute_operator_alias( client: &reqwest::Client, - config: &OmnigraphConfig, alias_name: &str, alias: &crate::operator::OperatorAlias, alias_args: &[String], @@ -399,7 +331,7 @@ pub(crate) async fn execute_operator_alias( ) -> Result { let uri = resolve_server_flag(Some(&alias.server), alias.graph.as_deref())? .expect("server name is present"); - let bearer_token = resolve_remote_bearer_token(config, Some(&uri))?; + let bearer_token = resolve_remote_bearer_token(Some(&uri))?; let mut params = serde_json::Map::new(); for (key, value) in &alias.params { @@ -454,22 +386,6 @@ pub(crate) fn apply_server_flag( resolve_server_flag(server, graph) } -/// The remote base URL a token resolution is FOR — the same scoping -/// `graph_bearer_token_env` uses: an explicit http(s) `--uri` wins, else -/// the config-resolved target's uri (when remote). Local URIs → None. -fn effective_remote_url( - config: &OmnigraphConfig, - explicit_uri: Option<&str>, - explicit_target: Option<&str>, -) -> Option { - if let Some(uri) = explicit_uri { - return is_remote_uri(uri).then(|| uri.to_string()); - } - let target = config.resolve_target_name(explicit_uri, explicit_target, config.cli_graph_name())?; - let uri = &config.graphs.get(target)?.uri; - is_remote_uri(uri).then(|| uri.clone()) -} - pub(crate) fn build_http_client() -> Result { Ok(reqwest::Client::new()) } @@ -510,40 +426,31 @@ pub(crate) async fn remote_json( Ok(serde_json::from_str(&text)?) } -pub(crate) fn resolve_uri(config: &OmnigraphConfig, cli_uri: Option) -> Result { - // `--target` is gone; the second arg (the legacy explicit-target name) is - // always None. A bare command still falls back to `cli.graph` (the third arg). - config.resolve_target_uri(cli_uri, None, config.cli_graph_name()) +/// The graph URI a command addresses (RFC-011): the scope-resolved URI string +/// (positional URI / `--store` / `--profile` / `defaults.store`). No +/// omnigraph.yaml `cli.graph` fallback — an absent address is a loud error. +pub(crate) fn resolve_uri(cli_uri: Option) -> Result { + cli_uri.ok_or_else(|| { + color_eyre::eyre::eyre!( + "no graph addressed — pass a positional URI, --store , --server , \ + --profile , or set a default scope in ~/.omnigraph/config.yaml" + ) + }) } -pub(crate) fn resolve_cli_graph( - config: &OmnigraphConfig, - cli_uri: Option, -) -> Result { - let selected = if cli_uri.is_some() { - None - } else { - config.cli_graph_name().map(str::to_string) - }; - config.resolve_graph_selection(selected.as_deref())?; - let uri = resolve_uri(config, cli_uri)?; - let normalized_uri = normalize_policy_graph_uri(&uri)?; - let graph_id = graph_resource_id_for_selection(selected.as_deref(), &normalized_uri); +pub(crate) fn resolve_cli_graph(cli_uri: Option) -> Result { + let uri = resolve_uri(cli_uri)?; Ok(ResolvedCliGraph { - graph_id, is_remote: is_remote_uri(&uri), - policy_file: config.resolve_policy_file_for(selected.as_deref()), - selected, uri, }) } pub(crate) fn resolve_local_graph( - config: &OmnigraphConfig, cli_uri: Option, operation: &str, ) -> Result { - let graph = resolve_cli_graph(config, cli_uri)?; + let graph = resolve_cli_graph(cli_uri)?; if graph.is_remote { bail!( "`{}` is a direct (storage-native) command and needs direct storage \ @@ -586,22 +493,19 @@ pub(crate) fn parse_duration_arg(s: &str) -> Result { Ok(std::time::Duration::from_secs(secs)) } -pub(crate) fn resolve_local_uri( - config: &OmnigraphConfig, - cli_uri: Option, - operation: &str, -) -> Result { - Ok(resolve_local_graph(config, cli_uri, operation)?.uri) +pub(crate) fn resolve_local_uri(cli_uri: Option, operation: &str) -> Result { + Ok(resolve_local_graph(cli_uri, operation)?.uri) } -/// Resolve a maintenance verb's (optimize/repair/cleanup) address to a direct -/// storage URI through the one RFC-011 scope path. Every primitive funnels -/// here: a positional URI, `--store`, `--cluster --graph `, a -/// `--profile` cluster binding, or operator defaults — all resolved at the -/// `Direct` capability (so a server scope is rejected, a cluster scope is -/// allowed), then mapped to a storage URI by `resolve_storage_uri`. +/// Resolve a direct (storage-native) verb's address to a storage URI through the +/// one RFC-011 scope path — the maintenance verbs (optimize/repair/cleanup) plus +/// `schema plan` and `lint`'s graph-target path. Every primitive funnels here: a +/// positional URI, `--store`, `--cluster --graph `, a `--profile` +/// cluster binding, or operator defaults — all resolved at the `Direct` +/// capability (so a server scope is rejected, a cluster scope is allowed when the +/// verb opts into cluster addressing), then mapped to a storage URI by +/// `resolve_storage_uri`. pub(crate) async fn resolve_maintenance_uri( - config: &OmnigraphConfig, profile: Option<&str>, store: Option<&str>, cluster: Option<&str>, @@ -622,7 +526,6 @@ pub(crate) async fn resolve_maintenance_uri( }, )?; resolve_storage_uri( - config, scope.uri, scope.cluster.as_deref(), scope.cluster_graph.as_deref(), @@ -639,7 +542,6 @@ pub(crate) async fn resolve_maintenance_uri( /// automatically, otherwise error and list the candidates so the operator can /// pass `--graph `. pub(crate) async fn resolve_storage_uri( - config: &OmnigraphConfig, cli_uri: Option, cluster: Option<&str>, cluster_graph: Option<&str>, @@ -651,7 +553,7 @@ pub(crate) async fn resolve_storage_uri( let graph_id = resolve_sole_cluster_graph(cluster).await?; resolve_cluster_graph_uri(cluster, &graph_id).await } - (None, None) => resolve_local_uri(config, cli_uri, operation), + (None, None) => resolve_local_uri(cli_uri, operation), (None, Some(_)) => { bail!("internal error: a graph was selected without a cluster scope") } @@ -687,19 +589,16 @@ async fn resolve_cluster_graph_uri(cluster: &str, graph_id: &str) -> Result, alias_branch: Option, default_branch: &str, ) -> String { cli_branch .or(alias_branch) - .or_else(|| config.cli.branch.clone()) .unwrap_or_else(|| default_branch.to_string()) } pub(crate) fn resolve_read_target( - config: &OmnigraphConfig, cli_branch: Option, cli_snapshot: Option, alias_branch: Option, @@ -707,19 +606,15 @@ pub(crate) fn resolve_read_target( if cli_branch.is_some() && cli_snapshot.is_some() { bail!("read target may specify branch or snapshot, not both"); } - Ok(read_target_from_cli( - cli_branch - .or(alias_branch) - .or_else(|| config.cli.branch.clone()), - cli_snapshot, - )) + Ok(read_target_from_cli(cli_branch.or(alias_branch), cli_snapshot)) } pub(crate) fn resolve_query_path( - config: &OmnigraphConfig, explicit_query: Option<&PathBuf>, alias_query: Option<&str>, ) -> Result { + // The `.gq` path is resolved plainly (cwd-relative) — no omnigraph.yaml + // `query.roots` search. explicit_query .map(PathBuf::from) .or_else(|| alias_query.map(PathBuf::from)) @@ -728,11 +623,9 @@ pub(crate) fn resolve_query_path( "exactly one of --query, --query-string, or --alias must be provided" ) }) - .and_then(|query_path| config.resolve_query_path(&query_path)) } pub(crate) fn resolve_query_source( - config: &OmnigraphConfig, explicit_query: Option<&PathBuf>, inline_query: Option<&str>, alias_query: Option<&str>, @@ -744,7 +637,6 @@ pub(crate) fn resolve_query_source( return Ok(inline.to_string()); } Ok(fs::read_to_string(resolve_query_path( - config, explicit_query, alias_query, )?)?) @@ -754,11 +646,9 @@ pub(crate) fn parse_alias_value(value: &str) -> Value { serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())) } -/// The format cascade (RFC-007 §D3): `--json` > `--format` > alias format > -/// legacy `cli.output_format` (RFC-008 window) > operator `defaults.output` -/// > table. +/// The format cascade (RFC-011): `--json` > `--format` > alias format > +/// operator `defaults.output` > table. pub(crate) fn resolve_read_format( - config: &OmnigraphConfig, cli_format: Option, json: bool, alias_format: Option, @@ -768,7 +658,6 @@ pub(crate) fn resolve_read_format( } cli_format .or(alias_format) - .or(config.cli.output_format) .or_else(|| { operator::load_operator_config() .ok() @@ -825,12 +714,11 @@ pub(crate) fn query_params_from_json( } pub(crate) async fn execute_query_lint( - config: &OmnigraphConfig, cli_uri: Option, schema_path: Option<&PathBuf>, query_path: &PathBuf, ) -> Result { - let resolved_query_path = resolve_query_path(config, Some(query_path), None)?; + let resolved_query_path = resolve_query_path(Some(query_path), None)?; let query_source = fs::read_to_string(&resolved_query_path)?; let query_path = resolved_query_path.to_string_lossy().into_owned(); @@ -848,12 +736,14 @@ pub(crate) async fn execute_query_lint( )); } - let has_graph_target = cli_uri.is_some() || config.cli_graph_name().is_some(); - if !has_graph_target { - bail!("lint requires --schema or a resolvable graph target"); + if cli_uri.is_none() { + bail!( + "lint requires --schema (offline) or a graph target \ + (--store / --cluster --graph )" + ); } - let uri = resolve_local_uri(config, cli_uri, "lint")?; + let uri = resolve_local_uri(cli_uri, "lint")?; let db = Omnigraph::open(&uri).await?; Ok(lint_query_file( &db.catalog(), @@ -863,20 +753,24 @@ pub(crate) async fn execute_query_lint( )) } -pub(crate) fn resolve_selected_graph( - config: &OmnigraphConfig, - cli_uri: Option, - operation: &str, -) -> Result<(String, Option)> { - let graph = resolve_local_graph(config, cli_uri, operation)?; - Ok((graph.uri, graph.selected)) -} - -pub(crate) fn load_registry_or_report( - config: &OmnigraphConfig, - selected: Option<&str>, +/// Build a `QueryRegistry` from a cluster serving snapshot's stored queries, +/// optionally scoped to one graph. The `ServingQuery.source` is the +/// digest-verified `.gq` content, so no file I/O or omnigraph.yaml is involved. +fn registry_from_serving_queries( + queries: &[omnigraph_cluster::ServingQuery], + graph: Option<&str>, ) -> Result { - QueryRegistry::load(config, config.query_entries_for(selected)).map_err(|errors| { + let specs: Vec = queries + .iter() + .filter(|q| graph.is_none_or(|g| q.graph_id == g)) + .map(|q| omnigraph_server::queries::RegistrySpec { + name: q.name.clone(), + source: q.source.clone(), + expose: false, + tool_name: None, + }) + .collect(); + QueryRegistry::from_specs(specs).map_err(|errors| { color_eyre::eyre::eyre!( "stored-query registry failed to load:\n {}", errors @@ -888,83 +782,58 @@ pub(crate) fn load_registry_or_report( }) } -pub(crate) fn graph_query_registry_names(config: &OmnigraphConfig) -> Vec<&str> { - config - .graphs - .iter() - .filter_map(|(name, graph)| (!graph.queries.is_empty()).then_some(name.as_str())) - .collect() -} - -pub(crate) fn resolve_registry_selection_for_list( - config: &OmnigraphConfig, -) -> Result> { - let selected = config.cli_graph_name().map(str::to_string); - if let Some(name) = selected.as_deref() { - config.resolve_graph_selection(Some(name))?; - return Ok(selected); - } - - if !config.query_entries().is_empty() { - return Ok(None); - } - - let graph_names = graph_query_registry_names(config); - if graph_names.is_empty() { - return Ok(None); - } - - bail!( - "stored-query registries are configured for graph{} {} but no graph was selected. Pass a positional URI or set `cli.graph`.", - if graph_names.len() == 1 { "" } else { "s" }, - graph_names.join(", "), - ) -} - -pub(crate) fn validate_registry_for_catalog( - registry: &QueryRegistry, - catalog: &omnigraph_compiler::catalog::Catalog, - label: &str, -) -> omnigraph::error::Result<()> { - let report = check(registry, catalog); - if report.has_breakages() { - return Err(omnigraph::error::OmniError::manifest( - format_check_breakages(label, &report), - )); - } - Ok(()) -} +/// `queries validate --cluster ` (RFC-011): type-check every stored query +/// in the cluster catalog against its graph's applied schema. Both the registry +/// and the schemas come from the cluster serving snapshot — no omnigraph.yaml. +/// With `--graph`, scope to a single graph. pub(crate) async fn execute_queries_validate( - uri: Option, - config_path: Option<&PathBuf>, + cluster: &str, + graph: Option<&str>, json: bool, ) -> Result<()> { - let config = load_cli_config(config_path)?; - // One selection drives both the schema URI and the registry. - let (uri, selected) = resolve_selected_graph(&config, uri, "queries validate")?; - let registry = load_registry_or_report(&config, selected.as_deref())?; - let db = Omnigraph::open(&uri).await?; - let report = check(®istry, &db.catalog()); + let snapshot = read_serving_snapshot_or_report(cluster).await?; - let output = QueriesValidateOutput { - ok: !report.has_breakages(), - breakages: report - .breakages - .iter() - .map(|b| QueriesIssue { + // Type-check per graph: each graph's stored queries against its own schema + // (read from the graph's applied storage root). A `--graph` filter scopes to + // exactly one graph; an unknown id is a loud error. + let mut breakages = Vec::new(); + let mut warnings = Vec::new(); + let mut total = 0usize; + let mut matched_any = false; + for serving_graph in &snapshot.graphs { + if graph.is_some_and(|g| g != serving_graph.graph_id) { + continue; + } + matched_any = true; + let registry = registry_from_serving_queries(&snapshot.queries, Some(&serving_graph.graph_id))?; + let db = Omnigraph::open(&serving_graph.root.to_string_lossy()).await?; + let report = check(®istry, &db.catalog()); + total += registry.len(); + for b in &report.breakages { + breakages.push(QueriesIssue { query: b.query.clone(), message: b.message.clone(), - }) - .collect(), - warnings: report - .warnings - .iter() - .map(|w| QueriesIssue { + }); + } + for w in &report.warnings { + warnings.push(QueriesIssue { query: w.query.clone(), message: w.message.clone(), - }) - .collect(), + }); + } + } + if let Some(graph_id) = graph { + if !matched_any { + bail!("graph `{graph_id}` is not applied in cluster `{cluster}`"); + } + } + + let has_breakages = !breakages.is_empty(); + let output = QueriesValidateOutput { + ok: !has_breakages, + breakages, + warnings, }; if json { @@ -973,8 +842,8 @@ pub(crate) async fn execute_queries_validate( if output.breakages.is_empty() { println!( "OK {} stored quer{} type-check against the schema", - registry.len(), - if registry.len() == 1 { "y" } else { "ies" } + total, + if total == 1 { "y" } else { "ies" } ); } for issue in &output.breakages { @@ -985,17 +854,22 @@ pub(crate) async fn execute_queries_validate( } } - if report.has_breakages() { + if has_breakages { io::stdout().flush()?; std::process::exit(1); } Ok(()) } -pub(crate) fn execute_queries_list(config_path: Option<&PathBuf>, json: bool) -> Result<()> { - let config = load_cli_config(config_path)?; - let selected = resolve_registry_selection_for_list(&config)?; - let registry = load_registry_or_report(&config, selected.as_deref())?; +/// `queries list --cluster ` (RFC-011): list the catalog's stored queries. +/// With `--graph`, scope to one graph. +pub(crate) async fn execute_queries_list( + cluster: &str, + graph: Option<&str>, + json: bool, +) -> Result<()> { + let snapshot = read_serving_snapshot_or_report(cluster).await?; + let registry = registry_from_serving_queries(&snapshot.queries, graph)?; let output = QueriesListOutput { queries: registry diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index a02f9aa..1628816 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -1,15 +1,11 @@ use std::ffi::OsString; use std::fs; use std::io::{self, Write}; -use std::path::Path; use std::path::PathBuf; -use std::sync::Arc; - use clap::{Arg, ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; -use color_eyre::eyre::{Result, WrapErr, bail}; +use color_eyre::eyre::{Result, bail}; use omnigraph::db::{Omnigraph, ReadTarget, SnapshotId}; use omnigraph::loader::LoadMode; -use omnigraph::storage::normalize_root_uri; use omnigraph_cluster::{ 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, @@ -26,9 +22,9 @@ use omnigraph_api_types::{ ChangeOutput, CommitOutput, ErrorOutput, IngestOutput, ReadOutput, SchemaApplyOutput, SnapshotTableOutput, }; -use omnigraph_server::queries::{QueryRegistry, check, format_check_breakages}; +use omnigraph_server::queries::{QueryRegistry, check}; use omnigraph_server::{ - OmnigraphConfig, PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, + PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, PolicyTestConfig, ReadOutputFormat, graph_resource_id_for_selection, load_config, }; use reqwest::Method; @@ -170,16 +166,13 @@ async fn main() -> Result<()> { } Command::Load { uri, - config, data, branch, from, mode, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve_with_policy( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -188,7 +181,7 @@ async fn main() -> Result<()> { cli.store.as_deref(), ) .await?; - let branch = resolve_branch(&config, branch, None, "main"); + let branch = resolve_branch(branch, None, "main"); if matches!(mode, CliLoadMode::Overwrite) { confirm_destructive("load --mode overwrite", client.uri(), cli.yes, json)?; } @@ -204,7 +197,6 @@ async fn main() -> Result<()> { } Command::Ingest { uri, - config, data, branch, from, @@ -216,9 +208,7 @@ async fn main() -> Result<()> { "warning: `omnigraph ingest` is deprecated and will be removed in a future release; \ use `omnigraph load --from --mode ` (ingest defaults: --from main --mode merge)" ); - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve_with_policy( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -227,8 +217,8 @@ async fn main() -> Result<()> { cli.store.as_deref(), ) .await?; - let branch = resolve_branch(&config, branch, None, "main"); - let from = resolve_branch(&config, from, None, "main"); + let branch = resolve_branch(branch, None, "main"); + let from = resolve_branch(from, None, "main"); echo_write_target(cli.quiet, "ingest", client.uri(), client.is_remote()); let payload = client .ingest(&branch, &from, &data.to_string_lossy(), mode) @@ -242,14 +232,11 @@ async fn main() -> Result<()> { Command::Branch { command } => match command { BranchCommand::Create { uri, - config, from, name, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve_with_policy( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -258,7 +245,7 @@ async fn main() -> Result<()> { cli.store.as_deref(), ) .await?; - let from = resolve_branch(&config, from, None, "main"); + let from = resolve_branch(from, None, "main"); echo_write_target(cli.quiet, "branch create", client.uri(), client.is_remote()); let payload = client.branch_create_from(&from, &name).await?; if json { @@ -269,12 +256,9 @@ async fn main() -> Result<()> { } BranchCommand::List { uri, - config, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -293,13 +277,10 @@ async fn main() -> Result<()> { } BranchCommand::Delete { uri, - config, name, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve_with_policy( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -319,14 +300,11 @@ async fn main() -> Result<()> { } BranchCommand::Merge { uri, - config, source, into, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve_with_policy( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -335,7 +313,7 @@ async fn main() -> Result<()> { cli.store.as_deref(), ) .await?; - let into = resolve_branch(&config, into, None, "main"); + let into = resolve_branch(into, None, "main"); echo_write_target(cli.quiet, "branch merge", client.uri(), client.is_remote()); let payload = client.branch_merge(&source, &into).await?; if json { @@ -353,13 +331,10 @@ async fn main() -> Result<()> { Command::Commit { command } => match command { CommitCommand::List { uri, - config, branch, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -376,13 +351,10 @@ async fn main() -> Result<()> { } CommitCommand::Show { uri, - config, commit_id, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -401,13 +373,19 @@ async fn main() -> Result<()> { Command::Schema { command } => match command { SchemaCommand::Plan { uri, - config, schema, json, allow_data_loss, } => { - let config = load_cli_config(config.as_ref())?; - let uri = resolve_local_uri(&config, uri, "schema plan")?; + let uri = resolve_maintenance_uri( + cli.profile.as_deref(), + cli.store.as_deref(), + cli.cluster.as_deref(), + cli.graph.as_deref(), + uri, + "schema plan", + ) + .await?; let schema_source = fs::read_to_string(&schema)?; let db = Omnigraph::open(&uri).await?; let plan = db @@ -430,14 +408,11 @@ async fn main() -> Result<()> { } SchemaCommand::Apply { uri, - config, schema, json, allow_data_loss, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve_with_policy( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -447,25 +422,14 @@ async fn main() -> Result<()> { ) .await?; let schema_source = fs::read_to_string(&schema)?; - // The stored-query registry check is an embedded-only concern - // (the remote arm ignores the validator — the server runs its - // own check); build it only for the local path so the remote - // path keeps its no-registry-load behavior. - let registry = if client.is_remote() { - None - } else { - let registry = load_registry_or_report(&config, client.selected())?; - (!registry.is_empty()).then_some(registry) - }; - let label = client.selected().unwrap_or(client.uri()).to_string(); + // The embedded (direct-store) arm carries no stored-query + // registry — the registry is cluster-owned (RFC-011), so a + // direct apply has nothing to validate against. The served arm + // runs the server's own catalog check. So the validator is a + // no-op here on both arms. echo_write_target(cli.quiet, "schema apply", client.uri(), client.is_remote()); let output = client - .apply_schema(&schema_source, allow_data_loss, |catalog| { - if let Some(registry) = registry.as_ref() { - validate_registry_for_catalog(registry, catalog, &label)?; - } - Ok(()) - }) + .apply_schema(&schema_source, allow_data_loss, |_catalog| Ok(())) .await?; if json { print_json(&output)?; @@ -475,12 +439,9 @@ async fn main() -> Result<()> { } SchemaCommand::Show { uri, - config, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -498,41 +459,50 @@ async fn main() -> Result<()> { }, Command::Lint { uri, - config, query, schema, json, } => { - let config = load_cli_config(config.as_ref())?; - let output = - execute_query_lint(&config, uri, schema.as_ref(), &query) - .await?; + // A graph target (when `--schema` is absent) resolves through the + // direct scope path (positional URI / --store / --profile / + // defaults.store). Offline (`--schema`) needs no graph, so leave + // the uri unresolved in that case. + let graph_uri = if schema.is_some() { + uri + } else { + Some( + resolve_maintenance_uri( + cli.profile.as_deref(), + cli.store.as_deref(), + cli.cluster.as_deref(), + cli.graph.as_deref(), + uri, + "lint", + ) + .await?, + ) + }; + let output = execute_query_lint(graph_uri, schema.as_ref(), &query).await?; finish_query_lint(&output, json)?; } - Command::Queries { command } => match command { - QueriesCommand::Validate { - uri, - config, - json, - } => { - execute_queries_validate(uri, config.as_ref(), json).await?; + Command::Queries { command } => { + let cluster = + require_cluster_scope(cli.cluster.as_deref(), cli.profile.as_deref(), "queries")?; + match command { + QueriesCommand::Validate { json } => { + execute_queries_validate(&cluster, cli.graph.as_deref(), json).await?; + } + QueriesCommand::List { json } => { + execute_queries_list(&cluster, cli.graph.as_deref(), json).await?; + } } - QueriesCommand::List { - config, - json, - } => { - execute_queries_list(config.as_ref(), json)?; - } - }, + } Command::Snapshot { uri, - config, branch, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -540,7 +510,7 @@ async fn main() -> Result<()> { cli.store.as_deref(), ) .await?; - let branch = resolve_branch(&config, branch, None, "main"); + let branch = resolve_branch(branch, None, "main"); let payload = client.snapshot(&branch).await?; if json { print_json(&payload)?; @@ -550,15 +520,12 @@ async fn main() -> Result<()> { } Command::Export { uri, - config, branch, jsonl, type_names, table_keys, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -566,7 +533,7 @@ async fn main() -> Result<()> { cli.store.as_deref(), ) .await?; - let branch = resolve_branch(&config, branch, None, "main"); + let branch = resolve_branch(branch, None, "main"); if jsonl { eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL"); } @@ -579,7 +546,6 @@ async fn main() -> Result<()> { } Command::Query { name, - config, query, query_string, params, @@ -588,9 +554,7 @@ async fn main() -> Result<()> { format, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), None, @@ -599,12 +563,12 @@ async fn main() -> Result<()> { ) .await?; let params_json = load_params_json(¶ms)?; - let target = resolve_read_target(&config, branch, snapshot, None)?; + let target = resolve_read_target(branch, snapshot, None)?; let output: ReadOutput = if query.is_some() || query_string.is_some() { // Ad-hoc lane: run the source; the positional `name` selects // within it when it holds more than one query. let query_source = - resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?; + resolve_query_source(query.as_ref(), query_string.as_deref(), None)?; client .query(target, &query_source, name.as_deref(), params_json.as_ref()) .await? @@ -624,21 +588,18 @@ async fn main() -> Result<()> { .invoke_named(&name, false, params_json.as_ref(), branch, snapshot) .await? }; - let format = resolve_read_format(&config, format, json, None); - print_read_output(&output, format, &config)?; + let format = resolve_read_format(format, json, None); + print_read_output(&output, format)?; } Command::Mutate { name, - config, query, query_string, params, branch, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve_with_policy( - &config, cli.server.as_deref(), cli.graph.as_deref(), None, @@ -648,11 +609,11 @@ async fn main() -> Result<()> { ) .await?; let params_json = load_params_json(¶ms)?; - let branch = resolve_branch(&config, branch, None, "main"); + let branch = resolve_branch(branch, None, "main"); let output: ChangeOutput = if query.is_some() || query_string.is_some() { // Ad-hoc lane: run the source; positional `name` selects within it. let query_source = - resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?; + resolve_query_source(query.as_ref(), query_string.as_deref(), None)?; client .mutate(&branch, &query_source, name.as_deref(), params_json.as_ref()) .await? @@ -677,12 +638,10 @@ async fn main() -> Result<()> { Command::Alias { name, args, - config, params, format, json, } => { - let config = load_cli_config(config.as_ref())?; let operator_config = crate::operator::load_operator_config()?; let Some(operator_alias) = operator_config.aliases.get(&name) else { let defined: Vec<&str> = @@ -695,59 +654,64 @@ async fn main() -> Result<()> { }; let output = execute_operator_alias( &http_client, - &config, &name, operator_alias, &args, load_params_json(¶ms)?, ) .await?; - let format = resolve_read_format(&config, format, json, operator_alias.format); - print_read_output(&output, format, &config)?; + let format = resolve_read_format(format, json, operator_alias.format); + print_read_output(&output, format)?; } - Command::Policy { command } => match command { - PolicyCommand::Validate { config } => { - let config = load_cli_config(config.as_ref())?; - let context = resolve_policy_context(&config)?; - let engine = resolve_policy_engine(&context)?; - println!( - "policy valid: {} [{} actors]", - context.policy_file.display(), - engine.known_actor_count() - ); - } - PolicyCommand::Test { config } => { - let config = load_cli_config(config.as_ref())?; - let context = resolve_policy_context(&config)?; - let engine = resolve_policy_engine(&context)?; - let tests_path = resolve_policy_tests_path(&context); - let tests = PolicyTestConfig::load(&tests_path)?; - engine.run_tests(&tests)?; - println!("policy tests passed: {} cases", tests.cases.len()); - } - PolicyCommand::Explain { - config, - actor, - action, - branch, - target_branch, - } => { - let config = load_cli_config(config.as_ref())?; - let context = resolve_policy_context(&config)?; - let engine = resolve_policy_engine(&context)?; - let request = PolicyRequest { + Command::Policy { command } => { + // Policy tooling sources the Cedar bundle(s) from the cluster's + // applied policies (RFC-011): --cluster , + the global --graph + // to pick a graph's bundle when several apply. + let cluster = + require_cluster_scope(cli.cluster.as_deref(), cli.profile.as_deref(), "policy")?; + let graph = cli.graph.as_deref(); + let graph_id = match graph { + Some(id) => graph_resource_id_for_selection(Some(id), ""), + None => graph_resource_id_for_selection(None, "default"), + }; + let policies = read_cluster_policies(&cluster).await?; + match command { + PolicyCommand::Validate {} => { + let bundle = select_cluster_policy(&cluster, &policies, graph)?; + let engine = PolicyEngine::load_graph_from_source(&bundle.source, &graph_id)?; + println!( + "policy valid: bundle '{}' [{} actors]", + bundle.name, + engine.known_actor_count() + ); + } + PolicyCommand::Test { tests } => { + let bundle = select_cluster_policy(&cluster, &policies, graph)?; + let engine = PolicyEngine::load_graph_from_source(&bundle.source, &graph_id)?; + let tests = PolicyTestConfig::load(&tests)?; + engine.run_tests(&tests)?; + println!("policy tests passed: {} cases", tests.cases.len()); + } + PolicyCommand::Explain { + actor, action, branch, target_branch, - }; - let decision = engine.authorize(&actor, &request)?; - print_policy_explain(&decision, &actor, &request); + } => { + let bundle = select_cluster_policy(&cluster, &policies, graph)?; + let engine = PolicyEngine::load_graph_from_source(&bundle.source, &graph_id)?; + let request = PolicyRequest { + action, + branch, + target_branch, + }; + let decision = engine.authorize(&actor, &request)?; + print_policy_explain(&decision, &actor, &request); + } } - }, - Command::Optimize { uri, config, json } => { - let config = load_cli_config(config.as_ref())?; + } + Command::Optimize { uri, json } => { let uri = resolve_maintenance_uri( - &config, cli.profile.as_deref(), cli.store.as_deref(), cli.cluster.as_deref(), @@ -798,14 +762,11 @@ async fn main() -> Result<()> { } Command::Repair { uri, - config, confirm, force, json, } => { - let config = load_cli_config(config.as_ref())?; let uri = resolve_maintenance_uri( - &config, cli.profile.as_deref(), cli.store.as_deref(), cli.cluster.as_deref(), @@ -890,15 +851,12 @@ async fn main() -> Result<()> { } Command::Cleanup { uri, - config, keep, older_than, confirm, json, } => { - let config = load_cli_config(config.as_ref())?; let uri = resolve_maintenance_uri( - &config, cli.profile.as_deref(), cli.store.as_deref(), cli.cluster.as_deref(), @@ -1036,12 +994,9 @@ async fn main() -> Result<()> { Command::Graphs { command } => match command { GraphsCommand::List { uri, - config, json, } => { - let config = load_cli_config(config.as_ref())?; let client = client::GraphClient::resolve( - &config, cli.server.as_deref(), cli.graph.as_deref(), uri, diff --git a/crates/omnigraph-cli/src/main_tests.rs b/crates/omnigraph-cli/src/main_tests.rs index 2e1db5c..4f93277 100644 --- a/crates/omnigraph-cli/src/main_tests.rs +++ b/crates/omnigraph-cli/src/main_tests.rs @@ -1,22 +1,16 @@ //! In-source test suite for the CLI binary (moved verbatim from //! main.rs; `use super::*` resolves through the #[path] declaration). - use std::fs; - use super::{ - DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file, - legacy_change_request_body, load_cli_config, load_env_file_into_process, - normalize_bearer_token, parse_env_assignment, resolve_cli_graph, resolve_policy_context, - resolve_remote_bearer_token, + DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, legacy_change_request_body, + normalize_bearer_token, resolve_remote_bearer_token, }; - use omnigraph_server::load_config; use reqwest::header::AUTHORIZATION; use serde_json::json; - use tempfile::tempdir; #[test] fn legacy_change_request_body_uses_legacy_field_names() { - // `execute_change_remote` hits `POST /change`, which old + // `mutate`'s remote arm hits `POST /change`, which old // `omnigraph-server` builds deserialize as `ChangeRequest` with // **required** `query_source` and optional `query_name` keys. // Newer servers accept both spellings via serde alias, but a @@ -96,120 +90,20 @@ } #[test] - fn parse_env_assignment_supports_plain_and_exported_values() { - assert_eq!( - parse_env_assignment("DEMO_TOKEN=demo-token"), - Some(("DEMO_TOKEN".to_string(), "demo-token".to_string())) - ); - assert_eq!( - parse_env_assignment("export DEMO_TOKEN=\"quoted-token\""), - Some(("DEMO_TOKEN".to_string(), "quoted-token".to_string())) - ); - assert_eq!(parse_env_assignment("# comment"), None); - assert_eq!(parse_env_assignment(" "), None); - } - - #[test] - fn bearer_token_from_env_file_reads_named_value() { - let temp = tempdir().unwrap(); - let env_file = temp.path().join(".env.omni"); - fs::write( - &env_file, - "FIRST=ignore\nexport DEMO_TOKEN=\" demo-token \"\n", - ) - .unwrap(); - - assert_eq!( - bearer_token_from_env_file(&env_file, "DEMO_TOKEN") - .unwrap() - .as_deref(), - Some("demo-token") - ); - assert_eq!( - bearer_token_from_env_file(&env_file, "MISSING").unwrap(), - None - ); - } - - #[test] - fn load_env_file_into_process_sets_missing_values_without_overriding_existing_ones() { - let temp = tempdir().unwrap(); - let env_file = temp.path().join(".env.omni"); - fs::write( - &env_file, - "AUTOLOAD_ONLY=from-file\nAUTOLOAD_PRESET=from-file\n", - ) - .unwrap(); - - let missing_key = "AUTOLOAD_ONLY"; - let preset_key = "AUTOLOAD_PRESET"; - let previous_missing = std::env::var_os(missing_key); - let previous_preset = std::env::var_os(preset_key); - - unsafe { - std::env::remove_var(missing_key); - std::env::set_var(preset_key, "from-env"); - } - - load_env_file_into_process(&env_file).unwrap(); - - assert_eq!(std::env::var(missing_key).unwrap(), "from-file"); - assert_eq!(std::env::var(preset_key).unwrap(), "from-env"); - - unsafe { - if let Some(value) = previous_missing { - std::env::set_var(missing_key, value); - } else { - std::env::remove_var(missing_key); - } - - if let Some(value) = previous_preset { - std::env::set_var(preset_key, value); - } else { - std::env::remove_var(preset_key); - } - } - } - - #[test] - fn resolve_remote_bearer_token_uses_scoped_env_file_with_global_fallback() { - let temp = tempdir().unwrap(); - fs::write( - temp.path().join("omnigraph.yaml"), - r#" -graphs: - demo: - uri: https://example.com - bearer_token_env: DEMO_TOKEN -auth: - env_file: .env.omni -cli: - graph: demo -"#, - ) - .unwrap(); - fs::write( - temp.path().join(".env.omni"), - "DEMO_TOKEN=scoped-token\nOMNIGRAPH_BEARER_TOKEN=global-token\n", - ) - .unwrap(); - + fn resolve_remote_bearer_token_falls_back_to_default_env() { + // RFC-011: with no operator server matching the URL, the only chain + // left is the default `OMNIGRAPH_BEARER_TOKEN` env (no omnigraph.yaml + // scoped chain). Hermetic: no operator config is read for a literal URL + // that matches no `servers:` entry. let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV); let previous_home = std::env::var_os("OMNIGRAPH_HOME"); unsafe { - std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV); - // Hermetic: the keyed hop (RFC-007 PR 2) must not pick up a real - // ~/.omnigraph on the developer's machine — and with no operator - // servers defined, the legacy chain below must behave - // byte-identically to pre-PR-2 (tested-as-untouched). - std::env::set_var("OMNIGRAPH_HOME", temp.path().join("no-operator-config")); + std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, "global-token"); + std::env::set_var("OMNIGRAPH_HOME", "/nonexistent/omnigraph-test-home"); } - let config_path = temp.path().join("omnigraph.yaml"); - let config = load_config(Some(&config_path)).unwrap(); - assert_eq!( - resolve_remote_bearer_token(&config, Some("https://override.example.com")) + resolve_remote_bearer_token(Some("https://override.example.com")) .unwrap() .as_deref(), Some("global-token") @@ -228,196 +122,3 @@ cli: } } } - - #[test] - fn load_cli_config_autoloads_env_file_into_process() { - let temp = tempdir().unwrap(); - fs::write( - temp.path().join("omnigraph.yaml"), - r#" -auth: - env_file: .env.omni -graphs: - demo: - uri: s3://bucket/prefix -"#, - ) - .unwrap(); - fs::write( - temp.path().join(".env.omni"), - "AUTOLOAD_FROM_CONFIG=loaded\n", - ) - .unwrap(); - - let key = "AUTOLOAD_FROM_CONFIG"; - let previous = std::env::var_os(key); - unsafe { - std::env::remove_var(key); - } - - let config_path = temp.path().join("omnigraph.yaml"); - let config = load_cli_config(Some(&config_path)).unwrap(); - - assert_eq!( - config.resolve_target_uri(None, Some("demo"), None).unwrap(), - "s3://bucket/prefix" - ); - assert_eq!(std::env::var(key).unwrap(), "loaded"); - - unsafe { - if let Some(value) = previous { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - } - - #[test] - fn graph_identity_resolve_policy_context_named_cli_graph_uses_graph_key_not_project_name_or_uri() - { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/local-policy-graph.omni - policy: - file: ./policy.yaml -cli: - graph: local -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let context = resolve_policy_context(&config).unwrap(); - assert_eq!(context.graph_id, "local"); - } - - #[test] - fn graph_identity_resolve_policy_context_server_graph_uses_graph_key_when_cli_graph_absent() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/local-policy-graph.omni - policy: - file: ./server-policy.yaml -server: - graph: local -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let context = resolve_policy_context(&config).unwrap(); - assert_eq!(context.graph_id, "local"); - assert!(context.policy_file.ends_with("server-policy.yaml")); - } - - #[test] - fn graph_identity_resolve_policy_context_anonymous_uses_top_level_default_identity() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/local-policy-graph.omni -policy: - file: ./top-policy.yaml -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let context = resolve_policy_context(&config).unwrap(); - assert_eq!(context.graph_id, "default"); - assert!(context.policy_file.ends_with("top-policy.yaml")); - } - - #[test] - fn graph_identity_resolve_cli_graph_named_target_uses_graph_key_not_project_name_or_uri() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - prod: - uri: s3://bucket/prod-graph/ - policy: - file: ./prod-policy.yaml -cli: - graph: prod -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - // `--target` is removed; the `cli.graph` default drives the same - // graph-key (not project name / URI) selection. - let graph = resolve_cli_graph(&config, None).unwrap(); - assert_eq!(graph.selected(), Some("prod")); - assert_eq!(graph.graph_id, "prod"); - assert_eq!(graph.uri, "s3://bucket/prod-graph/"); - } - - #[test] - fn graph_identity_resolve_cli_graph_positional_uri_uses_anonymous_normalized_uri() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/configured-graph.omni - policy: - file: ./policy.yaml -cli: - graph: local -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let local_graph_path = temp.path().join("explicit-graph.omni"); - let local_graph = resolve_cli_graph( - &config, - Some(format!("file://{}", local_graph_path.display())), - ) - .unwrap(); - assert_eq!(local_graph.selected(), None); - assert_eq!( - local_graph.graph_id, - local_graph_path.to_string_lossy().as_ref() - ); - assert_eq!(local_graph.policy_file, None); - - let s3_graph = resolve_cli_graph( - &config, - Some("s3://bucket/anonymous-graph/".to_string()), - ) - .unwrap(); - assert_eq!(s3_graph.selected(), None); - assert_eq!(s3_graph.graph_id, "s3://bucket/anonymous-graph"); - assert_eq!(s3_graph.policy_file, None); - } diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs index 446c6ca..a5f75e7 100644 --- a/crates/omnigraph-cli/src/output.rs +++ b/crates/omnigraph-cli/src/output.rs @@ -734,15 +734,10 @@ pub(crate) fn print_snapshot_human(branch: &str, manifest_version: u64, entries: pub(crate) fn print_read_output( output: &ReadOutput, format: ReadOutputFormat, - config: &OmnigraphConfig, ) -> Result<()> { println!( "{}", - render_read( - output, - format, - &resolve_table_render_options(config), - )? + render_read(output, format, &resolve_table_render_options())? ); Ok(()) } @@ -892,20 +887,11 @@ pub(crate) fn finish_logout( Ok(()) } -/// Table prefs cascade (RFC-007/008): legacy cli.table_* (window) > -/// operator defaults.table_* > built-in. -pub(crate) fn resolve_table_render_options(config: &OmnigraphConfig) -> ReadRenderOptions { +/// Table prefs cascade (RFC-011): operator defaults.table_* > built-in. +pub(crate) fn resolve_table_render_options() -> ReadRenderOptions { let operator = crate::operator::load_operator_config().unwrap_or_default(); ReadRenderOptions { - max_column_width: config - .cli - .table_max_column_width - .or(operator.defaults.table_max_column_width) - .unwrap_or(80), - cell_layout: config - .cli - .table_cell_layout - .or(operator.defaults.table_cell_layout) - .unwrap_or_default(), + max_column_width: operator.defaults.table_max_column_width.unwrap_or(80), + cell_layout: operator.defaults.table_cell_layout.unwrap_or_default(), } } diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index 1cfefc1..45f96e2 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -82,9 +82,7 @@ impl Capability { /// classifier) plus the one Data→Served refinement: `graphs` is remote-only. /// /// This reflects *current enforced behavior*, so messages stay truthful: -/// `queries list` is `Local` (reads config today) and `queries validate` is -/// `Direct` (opens a graph directly today). Both converge to the RFC end-state -/// (served / control) only when later slices re-route them. +/// `queries`/`policy` read a cluster's applied state (`Control`). pub(crate) fn command_capability(cmd: &Command) -> Capability { if let Command::Graphs { .. } = cmd { return Capability::Served; @@ -120,20 +118,18 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane { Command::Schema { command: SchemaCommand::Plan { .. }, } => Plane::Storage, - Command::Queries { - command: QueriesCommand::Validate { .. }, - } => Plane::Storage, - Command::Queries { - command: QueriesCommand::List { .. }, - } => Plane::Session, + // `queries` and `policy` tooling now source their inputs from a + // cluster's applied state (`--cluster`), so they live on the control + // plane (RFC-011 — omnigraph.yaml excised from the CLI). + Command::Queries { .. } => Plane::Control, + Command::Policy { .. } => Plane::Control, Command::Init { .. } | Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } | Command::Lint { .. } => Plane::Storage, Command::Cluster { .. } => Plane::Control, - Command::Policy { .. } - | Command::Embed(_) + Command::Embed(_) | Command::Login { .. } | Command::Logout { .. } | Command::Config { .. } @@ -188,7 +184,17 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str { pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool { matches!( cmd, - Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } + Command::Optimize { .. } + | Command::Repair { .. } + | Command::Cleanup { .. } + // `lint` can type-check a `.gq` against a cluster graph's schema + // (RFC-011): `--cluster --graph `. + | Command::Lint { .. } + // The policy/queries tooling addresses a cluster's applied state + // (RFC-011): `--cluster ` selects the cluster, `--graph ` + // picks a graph's bundle/registry within it. + | Command::Policy { .. } + | Command::Queries { .. } ) } @@ -284,7 +290,12 @@ mod tests { assert_eq!(cap(&["omnigraph", "schema", "plan", "--schema", "s.pg", "graph.omni"]), Capability::Direct); assert_eq!(cap(&["omnigraph", "cluster", "status", "--config", "."]), Capability::Control); assert_eq!(cap(&["omnigraph", "version"]), Capability::Local); - assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Local); + // `queries`/`policy` tooling reads cluster state now (control plane). + assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Control); + assert_eq!( + cap(&["omnigraph", "policy", "validate"]), + Capability::Control + ); } #[test] diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index 0b0a22b..bd2fbfc 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -683,51 +683,8 @@ fn cluster_apply_locked_exits_nonzero() { assert!(!temp.path().join("__cluster/resources").exists()); } -#[test] -fn cluster_apply_uses_cli_actor_from_local_config() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - fs::write( - temp.path().join("omnigraph.yaml"), - "cli:\n actor: act-local\n", - ) - .unwrap(); - // Phase 1: import once (setup, not under test). - let output = cli() - .current_dir(temp.path()) - .arg("cluster") - .arg("import") - .arg("--config") - .arg(temp.path()) - .output() - .unwrap(); - assert!(output.status.success(), "{output:?}"); - - // Phase 2: apply alone, capturing the echoed actor (idempotent re-runs). - let apply = |extra: &[&str]| { - let mut command = cli(); - command.current_dir(temp.path()); - for arg in extra { - command.arg(arg); - } - let output = command - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()) - .arg("--json") - .output() - .unwrap(); - let json: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); - json["actor"].clone() - }; - assert_eq!(apply(&[]), "act-local", "cli.actor is the no-flag default"); - assert_eq!(apply(&["--as", "andrew"]), "andrew", "--as overrides cli.actor"); -} - -/// RFC-007 PR 1: the operator layer joins the actor chain — -/// `--as` > legacy `cli.actor` (RFC-008 window) > `operator.actor` > none. +/// RFC-011: the actor chain is `--as` > `operator.actor` > none. The CLI no +/// longer reads omnigraph.yaml `cli.actor`. #[test] fn cluster_apply_uses_operator_actor_from_omnigraph_home() { let temp = tempdir().unwrap(); @@ -771,41 +728,31 @@ fn cluster_apply_uses_operator_actor_from_omnigraph_home() { json["actor"].clone() }; - // No --as, no omnigraph.yaml: the operator identity applies. + // No --as: the operator identity applies. assert_eq!( apply(&[]), "act-operator", - "operator.actor is the no-flag, no-legacy-config default" + "operator.actor is the no-flag default" ); - // --as still wins over everything. + // --as still wins over the operator layer. assert_eq!(apply(&["--as", "andrew"]), "andrew"); - - // A legacy cli.actor (RFC-008 window) outranks the operator layer. - fs::write( - temp.path().join("omnigraph.yaml"), - "cli:\n actor: act-legacy\n", - ) - .unwrap(); - assert_eq!( - apply(&[]), - "act-legacy", - "legacy cli.actor wins over operator.actor during the deprecation window" - ); } #[test] -fn cluster_approve_uses_cli_actor_fallback() { +fn cluster_approve_uses_operator_actor_fallback() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); + let operator_home = tempdir().unwrap(); fs::write( - temp.path().join("omnigraph.yaml"), - "cli:\n actor: act-local\n", + operator_home.path().join("config.yaml"), + "operator:\n actor: act-operator\n", ) .unwrap(); // Converge, then remove the graph so a gated delete is pending. for command in ["import", "apply"] { let output = cli() .current_dir(temp.path()) + .env("OMNIGRAPH_HOME", operator_home.path()) .arg("cluster") .arg(command) .arg("--config") @@ -818,6 +765,7 @@ fn cluster_approve_uses_cli_actor_fallback() { let output = cli() .current_dir(temp.path()) + .env("OMNIGRAPH_HOME", operator_home.path()) .arg("cluster") .arg("approve") .arg("graph.knowledge") @@ -829,14 +777,17 @@ fn cluster_approve_uses_cli_actor_fallback() { assert!(output.status.success(), "{output:?}"); let json: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); - assert_eq!(json["approved_by"], "act-local"); + assert_eq!(json["approved_by"], "act-operator"); - // With neither flag nor config: refused with the actionable message. + // With neither flag nor operator config: refused with the actionable + // message (an approval without an approver is meaningless). let bare = tempdir().unwrap(); write_cluster_config_fixture(bare.path()); + let bare_home = tempdir().unwrap(); let output = output_failure( cli() .current_dir(bare.path()) + .env("OMNIGRAPH_HOME", bare_home.path()) .arg("cluster") .arg("approve") .arg("graph.knowledge") @@ -845,11 +796,13 @@ fn cluster_approve_uses_cli_actor_fallback() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("--as"), "{stderr}"); - assert!(stderr.contains("cli.actor"), "{stderr}"); } #[test] -fn cluster_commands_ignore_malformed_local_config() { +fn cluster_commands_ignore_legacy_omnigraph_yaml() { + // RFC-011: the CLI never reads omnigraph.yaml for cluster commands — a + // present (even malformed) legacy file is inert. The actor falls back to + // `operator.actor`, then to none (no loud failure on absence). let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); fs::write(temp.path().join("omnigraph.yaml"), "{{{{ not yaml").unwrap(); @@ -873,14 +826,11 @@ fn cluster_commands_ignore_malformed_local_config() { "cluster {command} touched omnigraph.yaml" ); } - // import + apply with an explicit --as: the config is never loaded. - for (command, args) in [("import", vec![]), ("apply", vec!["--as", "andrew"])] { - let mut invocation = cli(); - invocation.current_dir(temp.path()); - for arg in &args { - invocation.arg(arg); - } - let output = invocation + // import + apply (no --as, no operator config): the legacy file is never + // loaded and the no-actor apply succeeds (actor defaults to none). + for command in ["import", "apply"] { + let output = cli() + .current_dir(temp.path()) .arg("cluster") .arg(command) .arg("--config") @@ -893,20 +843,6 @@ fn cluster_commands_ignore_malformed_local_config() { String::from_utf8_lossy(&output.stderr) ); } - // Only the no-flag actor lookup is allowed to fail, and loudly. - let output = output_failure( - cli() - .current_dir(temp.path()) - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("omnigraph.yaml") && stderr.contains("--as"), - "the actor-default config read must fail loudly and actionably: {stderr}" - ); } #[test] diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index ee7d5a9..cb80472 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -599,13 +599,15 @@ query list_people() { } #[test] -fn query_lint_can_resolve_graph_and_query_from_config() { +fn query_lint_can_resolve_graph_from_store_scope() { + // RFC-011: lint resolves its graph target through `--store` (the direct + // scope), not omnigraph.yaml's cli.graph; the .gq path is plain cwd-relative. let temp = tempdir().unwrap(); let graph = graph_path(temp.path()); - let config_path = temp.path().join("omnigraph.yaml"); init_graph(&graph); + let query_path = temp.path().join("queries.gq"); write_query_file( - &temp.path().join("queries.gq"), + &query_path, r#" query list_people() { match { $p: Person } @@ -613,16 +615,15 @@ query list_people() { } "#, ); - write_config(&config_path, &local_yaml_config(&graph)); let output = output_success( cli() .arg("query") .arg("lint") .arg("--query") - .arg("queries.gq") - .arg("--config") - .arg(&config_path) + .arg(&query_path) + .arg("--store") + .arg(&graph) .arg("--json"), ); let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); @@ -690,7 +691,9 @@ query list_people() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("lint requires --schema or a resolvable graph target") + stderr.contains("lint requires --schema ") + || stderr.contains("no graph addressed"), + "expected a schema-or-graph-target requirement; got: {stderr}" ); } @@ -987,43 +990,38 @@ fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() { ); } +// RFC-011: `policy validate|test|explain` source the Cedar bundle from a +// converged cluster's applied policies (`--cluster ` + `--graph `), +// not omnigraph.yaml's policy.file. + #[test] -fn policy_validate_accepts_valid_policy_file() { - let temp = tempdir().unwrap(); - let (config, _) = write_policy_config_fixture(temp.path()); +fn policy_validate_accepts_cluster_bundle() { + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML)); let output = output_success( cli() .arg("policy") .arg("validate") - .arg("--config") - .arg(&config), + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge"), ); let stdout = stdout_string(&output); assert!(stdout.contains("policy valid:")); - assert!(stdout.contains("policy.yaml")); assert!(stdout.contains("[2 actors]")); } #[test] -fn policy_validate_fails_for_invalid_policy_file() { - let temp = tempdir().unwrap(); - let config = temp.path().join("omnigraph.yaml"); - let policy = temp.path().join("policy.yaml"); - fs::write( - &config, - r#" -project: - name: policy-test-graph -policy: - file: ./policy.yaml -"#, - ) - .unwrap(); - fs::write( - &policy, - r#" +fn policy_validate_fails_for_invalid_cluster_bundle() { + // The cluster does not validate a policy bundle's internal rules, so an + // applied-but-malformed bundle reaches `policy validate`, which compiles it + // and surfaces the error (here: a duplicate rule id). + let cluster = converged_loaded_cluster( + "knowledge", + Some( + r#" version: 1 groups: team: [act-andrew] @@ -1039,26 +1037,42 @@ rules: actions: [export] branch_scope: any "#, - ) - .unwrap(); + ), + ); let output = output_failure( cli() .arg("policy") .arg("validate") - .arg("--config") - .arg(&config), + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge"), ); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("duplicate policy rule id")); + assert!( + stderr.contains("duplicate policy rule id"), + "expected a duplicate-rule error; got: {stderr}" + ); } #[test] -fn policy_test_runs_declarative_cases() { - let temp = tempdir().unwrap(); - let (config, _) = write_policy_config_fixture(temp.path()); +fn policy_test_runs_declarative_cases_against_cluster_bundle() { + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML)); + let tests = cluster.path().join("policy.tests.yaml"); + fs::write(&tests, POLICY_TESTS_YAML).unwrap(); - let output = output_success(cli().arg("policy").arg("test").arg("--config").arg(&config)); + let output = output_success( + cli() + .arg("policy") + .arg("test") + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge") + .arg("--tests") + .arg(&tests), + ); let stdout = stdout_string(&output); assert!(stdout.contains("policy tests passed: 2 cases")); @@ -1066,15 +1080,16 @@ fn policy_test_runs_declarative_cases() { #[test] fn policy_explain_reports_decision_and_matched_rule() { - let temp = tempdir().unwrap(); - let (config, _) = write_policy_config_fixture(temp.path()); + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML)); let allow = output_success( cli() .arg("policy") .arg("explain") - .arg("--config") - .arg(&config) + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge") .arg("--actor") .arg("act-andrew") .arg("--action") @@ -1090,8 +1105,10 @@ fn policy_explain_reports_decision_and_matched_rule() { cli() .arg("policy") .arg("explain") - .arg("--config") - .arg(&config) + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge") .arg("--actor") .arg("act-bruno") .arg("--action") @@ -1105,19 +1122,24 @@ fn policy_explain_reports_decision_and_matched_rule() { } #[test] -fn read_can_resolve_uri_from_config() { +fn read_resolves_uri_from_default_store_scope() { + // RFC-011: a zero-flag read resolves its graph from `defaults.store` in the + // operator config (the local-dev default scope) — no omnigraph.yaml. let temp = tempdir().unwrap(); let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); init_graph(&graph); load_fixture(&graph); - write_config(&config, &local_yaml_config(&graph)); + let home = tempdir().unwrap(); + std::fs::write( + home.path().join("config.yaml"), + format!("defaults:\n store: {}\n", graph.to_string_lossy()), + ) + .unwrap(); let output = output_success( cli() + .env("OMNIGRAPH_HOME", home.path()) .arg("read") - .arg("--config") - .arg(&config) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -1278,13 +1300,13 @@ query insert_person($name: String, $age: I32) { } #[test] -fn change_can_resolve_uri_and_branch_from_config() { +fn change_resolves_uri_and_default_branch_from_store_scope() { + // RFC-011: a mutate resolves its graph from `--store` and defaults the + // branch to main (no omnigraph.yaml cli.graph / cli.branch). let temp = tempdir().unwrap(); let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); init_graph(&graph); load_fixture(&graph); - write_config(&config, &local_yaml_config(&graph)); let mutation_file = temp.path().join("config-mutations.gq"); write_query_file( &mutation_file, @@ -1298,8 +1320,8 @@ query insert_person($name: String, $age: I32) { let output = output_success( cli() .arg("change") - .arg("--config") - .arg(&config) + .arg("--store") + .arg(&graph) .arg("--query") .arg(&mutation_file) .arg("--params") @@ -1896,19 +1918,17 @@ fn snapshot_json_returns_manifest_version_and_tables() { } #[test] -fn snapshot_can_resolve_uri_from_config() { +fn snapshot_resolves_uri_from_store_scope() { let temp = tempdir().unwrap(); let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); init_graph(&graph); load_fixture(&graph); - write_config(&config, &local_yaml_config(&graph)); let output = output_success( cli() .arg("snapshot") - .arg("--config") - .arg(&config) + .arg("--store") + .arg(&graph) .arg("--json"), ); let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); diff --git a/crates/omnigraph-cli/tests/cli_queries.rs b/crates/omnigraph-cli/tests/cli_queries.rs index 3bb9463..0b80f42 100644 --- a/crates/omnigraph-cli/tests/cli_queries.rs +++ b/crates/omnigraph-cli/tests/cli_queries.rs @@ -94,90 +94,91 @@ fn alias_unknown_name_errors_listing_defined() { ); } +// RFC-011: `queries validate`/`list` source the registry + schemas from a +// converged cluster's applied state (`--cluster `), not omnigraph.yaml. + +/// Build a converged single-graph cluster (id `knowledge`) with one stored +/// query. `query_block` is the YAML under the graph's `queries:` key. +fn converged_cluster_with_query(query_file: &str, query_src: &str, query_block: &str) -> tempfile::TempDir { + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap(); + write_query_file(&dir.join(query_file), query_src); + std::fs::write( + dir.join("cluster.yaml"), + format!( + "version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\ + graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n{query_block}" + ), + ) + .unwrap(); + output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir)); + output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir)); + temp +} + #[test] fn queries_validate_exits_zero_on_clean_registry() { - let graph = SystemGraph::loaded(); - graph.write_query( + let cluster = converged_cluster_with_query( "find_person.gq", "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &queries_test_config( - &graph.path().to_string_lossy(), - "find_person", - "find_person.gq", - ), + " find_person:\n file: ./find_person.gq\n", ); let output = output_success( cli() .arg("queries") .arg("validate") - .arg("--config") - .arg(&config), + .arg("--cluster") + .arg(cluster.path()), ); let stdout = stdout_string(&output); assert!(stdout.contains("OK"), "stdout:\n{stdout}"); } #[test] -fn queries_validate_exits_nonzero_on_type_broken_query() { - let graph = SystemGraph::loaded(); - // `Widget` is not in the fixture schema. - graph.write_query( - "ghost.gq", +fn cluster_import_rejects_a_type_broken_query() { + // In the cluster model a stored query is type-checked at the cluster + // boundary (import/apply), so a broken query can never reach the applied + // state `queries validate` reads — the gate is upstream. `Widget` is not in + // the fixture schema, so import must reject it, naming the query. + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap(); + write_query_file( + &dir.join("ghost.gq"), "query ghost() { match { $w: Widget } return { $w.name } }", ); - let config = graph.write_config( - "omnigraph.yaml", - &queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"), + std::fs::write( + dir.join("cluster.yaml"), + "version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\ + graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n ghost:\n file: ./ghost.gq\n", + ) + .unwrap(); + let output = output_failure(cli().arg("cluster").arg("import").arg("--config").arg(dir)); + let combined = format!( + "{}{}", + stdout_string(&output), + String::from_utf8_lossy(&output.stderr) ); - let output = output_failure( - cli() - .arg("queries") - .arg("validate") - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); assert!( - stdout.contains("ghost"), - "validation should name the broken query; stdout:\n{stdout}" + combined.contains("ghost"), + "cluster import must reject the broken query, naming it; got:\n{combined}" ); } #[test] fn queries_list_prints_registered_query() { - let graph = SystemGraph::loaded(); - graph.write_query( + let cluster = converged_cluster_with_query( "find_person.gq", "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - // Exposed with an explicit tool name so the list shows the MCP suffix. - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " find_person:\n", - " file: ./find_person.gq\n", - " mcp: {{ expose: true, tool_name: lookup_person }}\n", - "cli:\n", - " graph: local\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), + " find_person:\n file: ./find_person.gq\n", ); let output = output_success( cli() .arg("queries") .arg("list") - .arg("--config") - .arg(&config), + .arg("--cluster") + .arg(cluster.path()), ); let stdout = stdout_string(&output); assert!(stdout.contains("find_person"), "stdout:\n{stdout}"); @@ -185,242 +186,37 @@ fn queries_list_prints_registered_query() { stdout.contains("$name: String"), "list should show typed params; stdout:\n{stdout}" ); - assert!( - stdout.contains("[mcp: lookup_person]"), - "list should show the MCP tool name for exposed queries; stdout:\n{stdout}" - ); } #[test] -fn queries_list_requires_graph_selection_for_per_graph_only_registries() { - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " find_person:\n", - " file: ./find_person.gq\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), - ); - - let output = output_failure( - cli() - .arg("queries") - .arg("list") - .arg("--config") - .arg(&config), - ); +fn queries_validate_requires_a_cluster() { + // RFC-011: with no --cluster (and no cluster profile), the command errors + // loudly rather than reading any omnigraph.yaml. + let output = output_failure(cli().arg("queries").arg("validate")); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("local") && stderr.contains("set `cli.graph`"), - "error must name the graph and give a concrete selection hint; stderr:\n{stderr}" + stderr.contains("needs a cluster") || stderr.contains("--cluster"), + "queries validate must require a cluster; stderr:\n{stderr}" ); } #[test] -fn queries_list_without_graph_selection_lists_top_level_registry() { - let graph = SystemGraph::loaded(); - graph.write_query( - "top_find.gq", - "query top_find($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - concat!( - "queries:\n", - " top_find:\n", - " file: ./top_find.gq\n", - "policy: {}\n", - ), - ); - - let output = output_success( - cli() - .arg("queries") - .arg("list") - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - assert!(stdout.contains("top_find"), "stdout:\n{stdout}"); -} - -#[test] -fn queries_list_unknown_cli_graph_errors() { - // `queries list` opens no graph URI, so unknown-graph validation can't ride - // along on URI resolution the way it does for every other command. An - // unknown `cli.graph` selection must still error (naming the graph) instead - // of silently falling back to the top-level registry and showing the wrong - // (or empty) catalog. (`--target` was removed; `cli.graph` drives selection.) - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &format!( - "graphs:\n local:\n uri: '{}'\n queries:\n find_person:\n file: ./find_person.gq\ncli:\n graph: nonexistent\npolicy: {{}}\n", - graph.path().to_string_lossy().replace('\'', "''"), - ), - ); - let output = output_failure(cli().arg("queries").arg("list").arg("--config").arg(&config)); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("nonexistent"), - "error must name the unknown graph; stderr:\n{stderr}" - ); -} - -#[test] -fn queries_commands_reject_named_graph_with_populated_top_level_block() { - // A named graph (here via `cli.graph`) uses its own `graphs.` block, - // so a populated top-level `queries:` block would be silently ignored — a - // config the server REFUSES to boot. `queries validate`/`list` must reject - // it too (matching boot) instead of validating/listing the per-graph block - // and giving a false green. - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " find_person:\n", - " file: ./find_person.gq\n", - "cli:\n", - " graph: local\n", - "queries:\n", // populated top-level block: the coherence violation - " legacy:\n", - " file: ./legacy.gq\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), - ); - // Both resolve `local` from cli.graph (no positional URI), so both must - // error and name the graph + the ignored block — like server boot does. - for sub in ["validate", "list"] { - let output = output_failure(cli().arg("queries").arg(sub).arg("--config").arg(&config)); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("local") && stderr.contains("queries"), - "`queries {sub}` must reject a named graph with a populated top-level block; stderr:\n{stderr}" - ); - } -} - -#[test] -fn queries_validate_exits_nonzero_on_duplicate_tool_name() { - // Two exposed queries claiming one MCP tool name is a load-time - // collision — `queries validate` must fail (offline, before the engine - // opens) and name both queries plus the contested tool. - let graph = SystemGraph::loaded(); - graph.write_query( - "a.gq", - "query a() { match { $p: Person } return { $p.name } }", - ); - graph.write_query( - "b.gq", - "query b() { match { $p: Person } return { $p.name } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " a:\n", - " file: ./a.gq\n", - " mcp: {{ expose: true, tool_name: dup }}\n", - " b:\n", - " file: ./b.gq\n", - " mcp: {{ expose: true, tool_name: dup }}\n", - "cli:\n", - " graph: local\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), - ); - let output = output_failure( - cli() - .arg("queries") - .arg("validate") - .arg("--config") - .arg(&config), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("dup") && stderr.contains("'a'") && stderr.contains("'b'"), - "duplicate tool name should be reported naming both queries; stderr:\n{stderr}" - ); -} - -#[test] -fn queries_validate_positional_uri_ignores_default_graph() { - // A positional URI is anonymous → the schema AND the registry both come - // from top-level, even when `cli.graph` names a graph whose per-graph - // queries would fail. Pins that the URI and registry can't diverge. - let graph = SystemGraph::loaded(); - graph.write_query( - "clean.gq", - "query clean($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - // `Widget` is not in the fixture schema — the default graph's per-graph - // query would break validate if it were (wrongly) selected. - graph.write_query( - "broken.gq", - "query broken() { match { $w: Widget } return { $w.name } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - concat!( - "cli:\n graph: prod\n", - "graphs:\n", - " prod:\n", - " uri: /nonexistent-prod.omni\n", - " queries:\n", - " broken:\n", - " file: ./broken.gq\n", - "queries:\n", - " clean:\n", - " file: ./clean.gq\n", - "policy: {}\n", - ), - ); - // Positional URI = the real loaded graph; selection is anonymous, so the - // CLEAN top-level registry validates (not prod's broken one). +fn queries_validate_graph_filter_selects_one_graph() { + // A multi-graph cluster: validate scoped to `knowledge` type-checks only + // that graph's registry, ignoring `engineering`'s. + let temp = tempdir().unwrap(); + let dir = temp.path(); + write_multi_graph_cluster_fixture(dir); + output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir)); + output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir)); let output = output_success( cli() .arg("queries") .arg("validate") - .arg(graph.path()) - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - assert!( - stdout.contains("OK"), - "positional URI must validate the top-level registry, not the cli.graph default; stdout:\n{stdout}" + .arg("--cluster") + .arg(dir) + .arg("--graph") + .arg("knowledge"), ); + assert!(stdout_string(&output).contains("OK")); } diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index b81d6ff..8a9ee47 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -546,60 +546,22 @@ fn graphs_subcommand_help_lists_list_only() { #[test] fn graphs_list_against_local_uri_errors_with_remote_only_message() { + // RFC-011: `graphs list` is served-only; a `--store` (local) address has no + // enumeration endpoint, so it fails loudly pointing at a server / cluster. let output = output_failure( cli() .arg("graphs") .arg("list") - .arg("--uri") + .arg("--store") .arg("/tmp/local"), ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!( - stderr.contains("remote multi-graph server URL"), - "expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}" + stderr.contains("remote multi-graph server"), + "expected a remote-server rejection in stderr; got:\n{stderr}" ); } -/// RFC-008 stage 1: loading a legacy omnigraph.yaml emits the per-key -/// deprecation block (the migration map applied to THIS file), suppressible -/// via OMNIGRAPH_SUPPRESS_YAML_DEPRECATION. -#[test] -fn legacy_config_load_warns_per_key_and_suppression_silences() { - let temp = tempdir().unwrap(); - fs::write( - temp.path().join("omnigraph.yaml"), - "cli:\n actor: act-x\ngraphs:\n g:\n uri: /tmp/never-opened\n", - ) - .unwrap(); - - // `graphs list --json` loads the config and exits without touching the - // graph URI. - let output = cli() - .current_dir(temp.path()) - .arg("graphs") - .arg("list") - .arg("--json") - .output() - .unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("deprecated (RFC-008)") && stderr.contains("`cli.actor` -> `operator.actor`"), - "{stderr}" - ); - assert!(stderr.contains("config migrate"), "{stderr}"); - - let output = cli() - .current_dir(temp.path()) - .env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1") - .arg("graphs") - .arg("list") - .arg("--json") - .output() - .unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(!stderr.contains("deprecated (RFC-008)"), "{stderr}"); -} - /// RFC-008 stage 2: `config migrate` proposes the split read-only, applies /// it with --write (operator merge never clobbers; cluster.yaml emitted), /// and a second --write is idempotent. @@ -671,38 +633,3 @@ fn config_migrate_splits_legacy_config() { assert!(temp.path().join("cluster.yaml.proposed").exists()); } -/// RFC-008 stage 4: OMNIGRAPH_NO_LEGACY_CONFIG refuses a present legacy -/// file (pointing at config migrate) but changes nothing on migrated -/// setups with no file. -#[test] -fn strict_mode_refuses_legacy_file_but_not_its_absence() { - let temp = tempdir().unwrap(); - fs::write(temp.path().join("omnigraph.yaml"), "cli:\n actor: a\n").unwrap(); - let output = cli() - .current_dir(temp.path()) - .env("OMNIGRAPH_NO_LEGACY_CONFIG", "1") - .arg("graphs") - .arg("list") - .arg("--json") - .output() - .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("OMNIGRAPH_NO_LEGACY_CONFIG") && stderr.contains("config migrate"), - "{stderr}" - ); - - // Migrated setup (no file): strict mode is a no-op — a config-loading - // command that tolerates empty defaults succeeds. - let clean = tempdir().unwrap(); - let output = cli() - .current_dir(clean.path()) - .env("OMNIGRAPH_NO_LEGACY_CONFIG", "1") - .arg("queries") - .arg("list") - .arg("--json") - .output() - .unwrap(); - assert!(output.status.success(), "{output:?}"); -} diff --git a/crates/omnigraph-cli/tests/parity_matrix.rs b/crates/omnigraph-cli/tests/parity_matrix.rs index 6380d7a..e46f064 100644 --- a/crates/omnigraph-cli/tests/parity_matrix.rs +++ b/crates/omnigraph-cli/tests/parity_matrix.rs @@ -25,13 +25,12 @@ const KNOWN_DIVERGENCES: &[&str] = &[ // populated by the rows below as they are written ]; -/// One matched setup per row: twin graphs + the SAME Cedar bundle on both -/// arms (the local arm via --config top-level policy.file; the server via -/// its config). Returns everything a row needs. +/// One matched setup per row: twin graphs + the parity Cedar bundle on the +/// served arm. The local (`--store`) arm carries no policy (RFC-011); the +/// bundle is permissive for `act-parity`, so the arms still agree. struct Parity { _temp: TempDir, local: std::path::PathBuf, - local_cfg: std::path::PathBuf, server: TestServer, } @@ -40,7 +39,7 @@ fn parity() -> Parity { // RFC-011 cluster-only: the remote arm is served from a converged // cluster directory (one graph, id `parity`), seeded with the same // fixture data as the local twin. - let (local_cfg, cluster_dir) = parity_configs(temp.path(), &local, &remote); + let cluster_dir = parity_configs(temp.path(), &local, &remote); let server = spawn_server_with_cluster_env( &cluster_dir, &[( @@ -51,14 +50,13 @@ fn parity() -> Parity { Parity { _temp: temp, local, - local_cfg, server, } } impl Parity { fn run(&self, args: &[&str]) -> (std::process::Output, std::process::Output) { - run_both_with_config(&self.local, Some(&self.local_cfg), &self.server.base_url, args) + run_both(&self.local, &self.server.base_url, args) } } diff --git a/crates/omnigraph-cli/tests/support/mod.rs b/crates/omnigraph-cli/tests/support/mod.rs index 4c9a5ef..ff6a5d4 100644 --- a/crates/omnigraph-cli/tests/support/mod.rs +++ b/crates/omnigraph-cli/tests/support/mod.rs @@ -850,35 +850,25 @@ rules: /// server is cluster-only, so a graph selector is required). pub const PARITY_GRAPH_ID: &str = "parity"; -/// Build both arms' configuration (RFC-011 cluster-only server). +/// Build the remote arm's configuration (RFC-011 cluster-only server). /// -/// * Local arm: a `--config` file carrying the TOP-LEVEL `policy.file` -/// (single-graph embedded semantics), used as-is by `run_both_with_config`. -/// * Remote arm: a converged cluster directory whose single graph (id -/// `parity`) carries the SAME Cedar bundle (bound to the graph scope). -/// The cluster's derived graph root (`/graphs/parity.omni`) is -/// seeded with the SAME fixture data as the local twin so the two arms -/// compare like-for-like. +/// The remote arm is served from a converged cluster directory whose single +/// graph (id `parity`) carries the parity Cedar bundle (bound to the graph +/// scope). The cluster's derived graph root (`/graphs/parity.omni`) is +/// seeded with the SAME fixture data as the local twin so the two arms compare +/// like-for-like. The local (`--store`) arm carries no Cedar policy (RFC-011), +/// which is fine because the parity bundle is permissive for `act-parity`. /// /// `local_graph` is overwritten with a byte-for-byte copy of the cluster's /// seeded served graph so identity-bearing values that are NOT scrubbed /// (e.g. `graph_commit_id`, edge `id`s in export) match across the arms — /// the served graph is the source of truth and the local twin mirrors it. /// -/// Returns `(local_config_path, cluster_dir)`. The caller spawns the -/// server with `--cluster `. -pub fn parity_configs(root: &Path, local_graph: &Path, _remote_graph: &Path) -> (PathBuf, PathBuf) { +/// Returns the `cluster_dir`. The caller spawns the server with `--cluster`. +pub fn parity_configs(root: &Path, local_graph: &Path, _remote_graph: &Path) -> PathBuf { let policy = root.join("parity.policy.yaml"); fs::write(&policy, parity_policy_yaml()).unwrap(); - // Local arm config: top-level single-graph policy. - let local_cfg = root.join("local.omnigraph.yaml"); - fs::write( - &local_cfg, - format!("policy:\n file: {}\n", policy.display()), - ) - .unwrap(); - // Remote arm: a cluster directory the server boots from. One graph // (`parity`), schema = the shared fixture, policy bound to the graph. let cluster_dir = root.join("parity-cluster"); @@ -942,7 +932,7 @@ policies: } copy_dir(&served_root, local_graph); - (local_cfg, cluster_dir) + cluster_dir } /// Run one CLI invocation per arm with identical verb args: locally against @@ -953,21 +943,14 @@ pub fn run_both( local_graph: &Path, server_url: &str, args: &[&str], -) -> (std::process::Output, std::process::Output) { - run_both_with_config(local_graph, None, server_url, args) -} - -pub fn run_both_with_config( - local_graph: &Path, - local_config: Option<&Path>, - server_url: &str, - args: &[&str], ) -> (std::process::Output, std::process::Output) { // Address both arms with GLOBAL flags (`--store` / `--server`) appended after // the verb + its args, so the address is placed correctly regardless of // subcommand nesting (a positional graph only works for top-level verbs; // `schema show ` etc. need the global flag). Local = embedded store, - // remote = served. + // remote = served. RFC-011: a direct (`--store`) write carries no Cedar + // policy — the parity policy is permissive for `act-parity` on the served + // arm, so the two arms still agree. let mut local = cli(); local .args(args) @@ -975,9 +958,6 @@ pub fn run_both_with_config( .arg(local_graph) .arg("--as") .arg(PARITY_ACTOR); - if let Some(config) = local_config { - local.arg("--config").arg(config); - } let local_out = local.output().unwrap(); let mut remote = cli(); diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index 5804907..c548ac5 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -62,53 +62,6 @@ cases: expect: allow "#; -fn yaml_string(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - -fn local_policy_config(graph: &SystemGraph) -> String { - format!( - "\ -project: - name: policy-e2e-local -graphs: - local: - uri: {} - policy: - file: ./policy.yaml -cli: - graph: local - branch: main -query: - roots: - - . -", - yaml_string(&graph.path().to_string_lossy()) - ) -} - -fn local_policy_server_graph_config(graph: &SystemGraph) -> String { - format!( - "\ -project: - name: policy-e2e-local -graphs: - local: - uri: {} - policy: - file: ./policy.yaml -server: - graph: local -cli: - branch: main -query: - roots: - - . -", - yaml_string(&graph.path().to_string_lossy()) - ) -} - fn insert_person_query(graph: &SystemGraph, name: &str) -> std::path::PathBuf { graph.write_query( name, @@ -669,31 +622,9 @@ fn local_cli_s3_end_to_end_init_load_read_flow() { let temp = tempfile::tempdir().unwrap(); let query_root = temp.path(); - let config = query_root.join("omnigraph.yaml"); let query = query_root.join("test.gq"); fs::copy(fixture("test.gq"), &query).unwrap(); - write_config( - &config, - &format!( - "\ -graphs: - rustfs: - uri: '{}' -cli: - graph: rustfs - branch: main -query: - roots: - - . -policy: {{}} -", - graph_uri - ), - ); - // current_dir matters: `init` scaffolds an omnigraph.yaml into its cwd, - // and without this it pollutes the crate dir, breaking unrelated tests - // (anything resolving a graph target from the cwd config). output_success( cli() .current_dir(query_root) @@ -713,12 +644,14 @@ policy: {{}} .arg(&graph_uri), ); + // RFC-011: the graph is addressed by `--store `; the `.gq` path is + // resolved cwd-relative (no omnigraph.yaml `query.roots`). let read = parse_stdout_json(&output_success( cli() .current_dir(query_root) .arg("read") - .arg("--config") - .arg(&config) + .arg("--store") + .arg(&graph_uri) .arg("--query") .arg("test.gq") .arg("get_person") @@ -733,8 +666,8 @@ policy: {{}} cli() .current_dir(query_root) .arg("snapshot") - .arg("--config") - .arg(&config) + .arg("--store") + .arg(&graph_uri) .arg("--json"), )); assert!(snapshot["tables"].is_array()); @@ -810,36 +743,22 @@ fn local_cli_failed_change_keeps_target_state_unchanged() { } #[test] -fn local_cli_resolves_relative_query_against_config_base_dir() { +fn local_cli_resolves_relative_query_cwd_relative() { + // RFC-011: omnigraph.yaml `query.roots` search is gone — a `--query` + // path is resolved plainly relative to the process cwd. This pins that + // a bare relative `.gq` filename resolves against `.current_dir`, and + // that the file actually read is the cwd-local one (a same-named query + // elsewhere with different columns is never picked up). let graph = SystemGraph::loaded(); let root = graph.path().parent().unwrap(); - let config_dir = root.join("config"); - let query_dir = config_dir.join("queries"); - let ambient_dir = root.join("ambient"); - fs::create_dir_all(&query_dir).unwrap(); - fs::create_dir_all(&ambient_dir).unwrap(); + let cwd_dir = root.join("cwd"); + let other_dir = root.join("other"); + fs::create_dir_all(&cwd_dir).unwrap(); + fs::create_dir_all(&other_dir).unwrap(); - let config = config_dir.join("omnigraph.yaml"); - write_config( - &config, - &format!( - "\ -graphs: - local: - uri: '{}' -cli: - graph: local - branch: main -query: - roots: - - queries -policy: {{}} -", - graph.path().display() - ), - ); + // The query in the cwd projects (age, name). write_query_file( - &query_dir.join("local.gq"), + &cwd_dir.join("local.gq"), r#" query get_person($name: String) { match { @@ -849,8 +768,10 @@ query get_person($name: String) { } "#, ); + // A same-named query elsewhere projects only (name): if cwd-relative + // resolution regressed and picked this up, the columns assert fails. write_query_file( - &ambient_dir.join("local.gq"), + &other_dir.join("local.gq"), r#" query get_person($name: String) { match { @@ -863,10 +784,10 @@ query get_person($name: String) { let payload = parse_stdout_json(&output_success( cli() - .current_dir(&ambient_dir) + .current_dir(&cwd_dir) .arg("read") - .arg("--config") - .arg(&config) + .arg("--store") + .arg(graph.path()) .arg("--query") .arg("local.gq") .arg("get_person") @@ -1139,122 +1060,145 @@ query vector_search($q: String) { #[test] fn local_cli_policy_tooling_is_end_to_end() { - // Sanity check for the read-only policy CLI surfaces. These don't - // mutate the graph; they parse and evaluate the effective policy for - // named graph selections, including per-graph policy files. - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - let server_graph_config = graph.write_config( - "omnigraph-policy-server.yaml", - &local_policy_server_graph_config(&graph), + // RFC-011: the read-only policy CLI surfaces source the bundle from a + // cluster's applied policies (`--cluster ` + `--graph `), not + // from an omnigraph.yaml `graphs:` map. These don't mutate the graph; + // they parse and evaluate the effective bundle bound to the graph. + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + // `policy test` has no per-bundle tests file in the cluster model, so + // the cases are supplied explicitly via `--tests`. + let tests_file = cluster.path().join("policy.tests.yaml"); + fs::write(&tests_file, POLICY_E2E_TESTS_YAML).unwrap(); + + let validate = output_success( + cli() + .arg("policy") + .arg("validate") + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge"), ); - graph.write_config("policy.yaml", POLICY_E2E_YAML); - graph.write_config("policy.tests.yaml", POLICY_E2E_TESTS_YAML); + assert!(stdout_string(&validate).contains("policy valid:")); - for config in [&config, &server_graph_config] { - let validate = output_success( - cli() - .arg("policy") - .arg("validate") - .arg("--config") - .arg(config), - ); - assert!(stdout_string(&validate).contains("policy valid:")); + let tests = output_success( + cli() + .arg("policy") + .arg("test") + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge") + .arg("--tests") + .arg(&tests_file), + ); + assert!(stdout_string(&tests).contains("policy tests passed: 2 cases")); - let tests = output_success(cli().arg("policy").arg("test").arg("--config").arg(config)); - assert!(stdout_string(&tests).contains("policy tests passed: 2 cases")); - - let explain = output_success( - cli() - .arg("policy") - .arg("explain") - .arg("--config") - .arg(config) - .arg("--actor") - .arg("act-bruno") - .arg("--action") - .arg("change") - .arg("--branch") - .arg("main"), - ); - let explain_stdout = stdout_string(&explain); - assert!(explain_stdout.contains("decision: deny")); - assert!(explain_stdout.contains("branch: main")); - } + let explain = output_success( + cli() + .arg("policy") + .arg("explain") + .arg("--cluster") + .arg(cluster.path()) + .arg("--graph") + .arg("knowledge") + .arg("--actor") + .arg("act-bruno") + .arg("--action") + .arg("change") + .arg("--branch") + .arg("main"), + ); + let explain_stdout = stdout_string(&explain); + assert!(explain_stdout.contains("decision: deny")); + assert!(explain_stdout.contains("branch: main")); } +/// Token→actor map for the served-policy tests: the bearer tokens the +/// cluster server resolves to `act-bruno` / `act-ragnor`. +const POLICY_TOKENS_JSON: &str = r#"{"act-bruno":"bruno-tok","act-ragnor":"ragnor-tok"}"#; + #[test] fn local_cli_change_enforces_engine_layer_policy() { - // Asserts MR-722 PR #4: when the selected graph has a configured - // policy file, the CLI loads PolicyEngine into Omnigraph and every - // direct-engine write hits `enforce(action, scope, actor)` — identical - // to what the HTTP server gets, regardless of transport. + // RFC-011: a CLI direct-store write carries NO policy — policy lives in + // the cluster/server. So engine-layer policy on a direct write no longer + // exists; this test asserts the faithful migration: the SERVER enforces + // the bundle bound to the served graph, addressed via `--server --graph` + // with a bearer token that resolves to the actor. // // Three cases, each discriminating: // - // 1. Policy installed, no actor source (no `cli.actor` in config, - // no `--as` flag) → engine-layer footgun guard fires; CLI exits - // non-zero with a "no actor" message. Silent bypass is the bug - // PR #4 prevents. - // 2. Policy installed, `--as act-bruno`, change on main → Cedar - // denies (bruno can change unprotected branches; main is - // protected). CLI exits non-zero with a "denied" message. - // 3. Policy installed, `--as act-ragnor`, change on main → - // Cedar permits (admins-write rule). Write succeeds and the - // inserted row is readable. - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); - let mutation_file = insert_person_query(&graph, "system-local-policy-change.gq"); - - // Case 1: policy configured, no actor threaded → footgun guard. - let no_actor = output_failure( - cli() - .arg("change") - .arg("--config") - .arg(&config) - .arg("--query") - .arg(&mutation_file) - .arg("--params") - .arg(r#"{"name":"NoActorPerson","age":1}"#) - .arg("--json"), + // 1. No token → the server refuses (401, unauthenticated). The old + // embedded "no actor" footgun does not apply to the served path + // (the actor comes from the token), so this replaces it. + // 2. bruno token, change on protected main → Cedar denies (bruno can + // change unprotected branches; main is protected). Non-zero exit, + // "denied" surfaced from the server error body. + // 3. ragnor token, change on main → Cedar permits (admins-write). Write + // succeeds and the inserted row is readable. + if skip_system_e2e("local_cli_change_enforces_engine_layer_policy") { + return; + } + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], ); - let no_actor_stderr = String::from_utf8_lossy(&no_actor.stderr); + let insert = + "query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }"; + + // Case 1: no token → the server refuses before any policy check. + let no_token = cli() + .arg("change") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("-e") + .arg(insert) + .arg("--params") + .arg(r#"{"name":"NoTokenPerson","age":1}"#) + .arg("--json") + .output() + .unwrap(); assert!( - no_actor_stderr.contains("no actor"), - "expected 'no actor' footgun message, got stderr: {no_actor_stderr}" + !no_token.status.success(), + "unauthenticated served write must be refused: {no_token:?}" ); - // Case 2: `--as act-bruno` against protected main → denied. - let denied = output_failure( - cli() - .arg("--as") - .arg("act-bruno") - .arg("change") - .arg("--config") - .arg(&config) - .arg("--query") - .arg(&mutation_file) - .arg("--params") - .arg(r#"{"name":"BrunoOnMain","age":2}"#) - .arg("--json"), - ); + // Case 2: bruno token against protected main → denied by the server. + let denied = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("change") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("-e") + .arg(insert) + .arg("--params") + .arg(r#"{"name":"BrunoOnMain","age":2}"#) + .arg("--json") + .output() + .unwrap(); + assert!(!denied.status.success(), "bruno/main must be denied"); let denied_stderr = String::from_utf8_lossy(&denied.stderr); assert!( denied_stderr.contains("denied"), "expected 'denied' message for bruno/main, got stderr: {denied_stderr}" ); - // Case 3: `--as act-ragnor` against main → permitted by admins-write. + // Case 3: ragnor token against main → permitted by admins-write. let allowed = parse_stdout_json(&output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("change") - .arg("--config") - .arg(&config) - .arg("--query") - .arg(&mutation_file) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("-e") + .arg(insert) .arg("--params") .arg(r#"{"name":"RagnorOnMain","age":3}"#) .arg("--json"), @@ -1264,12 +1208,17 @@ fn local_cli_change_enforces_engine_layer_policy() { assert_eq!(allowed["actor_id"], "act-ragnor"); // Verify the row landed — proves the write actually committed, not - // just that enforce returned Ok and silently dropped the work. + // just that enforce returned Ok and silently dropped the work. The read + // uses the bruno token: POLICY_E2E_YAML grants `read` to the `team` + // group (bruno), while admins (ragnor) get write-only rules. let verify = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") .arg("read") - .arg("--store") - .arg(graph.path()) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -1282,27 +1231,30 @@ fn local_cli_change_enforces_engine_layer_policy() { } #[test] -fn local_cli_positional_uri_does_not_inherit_default_graph_policy() { +fn local_cli_direct_store_write_is_unpoliced_regardless_of_actor() { + // RFC-011: a direct (`--store`) write carries no Cedar policy at all — + // policy lives in the cluster/server. So a write that the SERVED path + // would deny (bruno changing protected main) succeeds on the direct + // path, regardless of the actor. This is the faithful replacement for + // the obsolete `..._positional_uri_does_not_inherit_default_graph_policy` + // premise: a positional/`--store` address has no policy to inherit. let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); - let mutation_file = insert_person_query(&graph, "system-local-policy-positional.gq"); + let mutation_file = insert_person_query(&graph, "system-local-policy-direct.gq"); let allowed = parse_stdout_json(&output_success( cli() .arg("--as") .arg("act-bruno") .arg("change") - .arg("--config") - .arg(&config) .arg("--store") .arg(graph.path()) .arg("--query") .arg(&mutation_file) .arg("--params") - .arg(r#"{"name":"PositionalUriBruno","age":4}"#) + .arg(r#"{"name":"DirectStoreBruno","age":4}"#) .arg("--json"), )); + assert_eq!(allowed["branch"], "main"); assert_eq!(allowed["affected_nodes"], 1); assert_eq!(allowed["actor_id"], "act-bruno"); } @@ -1320,28 +1272,44 @@ fn local_cli_positional_uri_does_not_inherit_default_graph_policy() { #[test] fn local_cli_load_enforces_engine_layer_policy() { - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); - let data = graph.write_jsonl( - "system-local-policy-load.jsonl", - r#"{"type":"Person","data":{"name":"LoadPolicy","age":11}}"#, + // RFC-011 served re-point: the server enforces the graph-bound bundle on + // a remote load. A load into protected main is a `change`: bruno + // (team-write-unprotected) is denied, ragnor (admins-write) is allowed. + if skip_system_e2e("local_cli_load_enforces_engine_layer_policy") { + return; + } + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], ); + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("policy-load.jsonl"); + fs::write( + &data, + r#"{"type":"Person","data":{"name":"LoadPolicy","age":11}}"#, + ) + .unwrap(); // act-bruno: change-on-protected is denied (team-write-unprotected only). - let denied = output_failure( - cli() - .arg("--as") - .arg("act-bruno") - .arg("load") - .arg("--mode") - .arg("overwrite") - .arg("--config") - .arg(&config) - .arg("--data") - .arg(&data) - .arg("--json"), - ); + let denied = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("load") + .arg("--mode") + .arg("overwrite") + // `--yes` clears the RFC-011 Decision 9 destructive-write confirmation + // so the policy check (not the confirmation refusal) is what denies. + .arg("--yes") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("--data") + .arg(&data) + .arg("--json") + .output() + .unwrap(); + assert!(!denied.status.success(), "bruno/main load must be denied"); let stderr = String::from_utf8_lossy(&denied.stderr); assert!( stderr.contains("denied"), @@ -1351,13 +1319,15 @@ fn local_cli_load_enforces_engine_layer_policy() { // act-ragnor: admins-write rule permits change anywhere. let allowed = parse_stdout_json(&output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("load") .arg("--mode") .arg("overwrite") - .arg("--config") - .arg(&config) + .arg("--yes") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("--data") .arg(&data) .arg("--json"), @@ -1368,47 +1338,55 @@ fn local_cli_load_enforces_engine_layer_policy() { #[test] fn local_cli_ingest_enforces_engine_layer_policy() { - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); - let data = graph.write_jsonl( - "system-local-policy-ingest.jsonl", + // RFC-011 served re-point: ingest into a new branch requires both + // BranchCreate and Change. Bruno has change-unprotected only (no + // branch-ops) — either gate denies. Ragnor has admins-write + + // admins-branch-ops — both fire as ingest creates the branch + loads. + if skip_system_e2e("local_cli_ingest_enforces_engine_layer_policy") { + return; + } + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], + ); + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("policy-ingest.jsonl"); + fs::write( + &data, r#"{"type":"Person","data":{"name":"IngestPolicy","age":12}}"#, - ); + ) + .unwrap(); - // act-bruno: ingest into a new branch requires both BranchCreate and - // Change. Bruno has change-unprotected only, and the implicit - // branch_create fires first when the target branch doesn't exist. - // Either gate is enough to deny — assert denial without pinning - // which one fires first. - let denied = output_failure( - cli() - .arg("--as") - .arg("act-bruno") - .arg("ingest") - .arg("--config") - .arg(&config) - .arg("--data") - .arg(&data) - .arg("--branch") - .arg("policy-ingest-feature") - .arg("--json"), - ); + let denied = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("ingest") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("--data") + .arg(&data) + .arg("--branch") + .arg("policy-ingest-feature") + .arg("--json") + .output() + .unwrap(); + assert!(!denied.status.success(), "bruno ingest must be denied"); let stderr = String::from_utf8_lossy(&denied.stderr); assert!( stderr.contains("denied"), "expected 'denied' for bruno ingest, got: {stderr}" ); - // act-ragnor: admins-write covers Change, admins-branch-ops covers - // BranchCreate. Both fire as ingest creates the branch + loads. let allowed = parse_stdout_json(&output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("ingest") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("--data") .arg(&data) .arg("--branch") @@ -1421,33 +1399,42 @@ fn local_cli_ingest_enforces_engine_layer_policy() { #[test] fn local_cli_schema_apply_enforces_engine_layer_policy() { - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); - - // Additive: add a nullable property; SDK-compatible with the fixture - // schema. Uses the schema-apply scope (TargetBranch("main")). + // RFC-011 served re-point: the server enforces schema_apply against the + // graph-bound bundle. Bruno has no schema_apply rule → denied; ragnor + // has admins-schema-apply → allowed. The schema is additive (a nullable + // property), SDK-compatible with the fixture. + if skip_system_e2e("local_cli_schema_apply_enforces_engine_layer_policy") { + return; + } + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], + ); let new_schema = std::fs::read_to_string(fixture("test.pg")) .unwrap() .replace( " age: I32?\n}", " age: I32?\n nickname: String?\n}", ); - let schema_path = graph.path().join("policy-additive.pg"); + let temp = tempfile::tempdir().unwrap(); + let schema_path = temp.path().join("policy-additive.pg"); std::fs::write(&schema_path, &new_schema).unwrap(); - let denied = output_failure( - cli() - .arg("--as") - .arg("act-bruno") - .arg("schema") - .arg("apply") - .arg("--config") - .arg(&config) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); + let denied = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("schema") + .arg("apply") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .output() + .unwrap(); + assert!(!denied.status.success(), "bruno schema apply must be denied"); let stderr = String::from_utf8_lossy(&denied.stderr); assert!( stderr.contains("denied"), @@ -1456,12 +1443,13 @@ fn local_cli_schema_apply_enforces_engine_layer_policy() { let allowed = parse_stdout_json(&output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("schema") .arg("apply") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("--schema") .arg(&schema_path) .arg("--json"), @@ -1471,46 +1459,69 @@ fn local_cli_schema_apply_enforces_engine_layer_policy() { #[test] fn local_cli_schema_apply_rejects_stored_query_breakage_before_publish() { - let graph = SystemGraph::loaded(); - graph.write_query( - "stored-find-person.gq", + // RFC-011: stored queries live in the cluster catalog, not omnigraph.yaml. + // The served `schema apply` runs the server's catalog check against the + // applied stored queries; renaming `age`→`years` breaks the bundled + // `find_person` (which projects `$p.age`), so the apply is rejected before + // publish — the schema stays unchanged. + if skip_system_e2e("local_cli_schema_apply_rejects_stored_query_breakage_before_publish") { + return; + } + // A graph-bound bundle that lets ragnor apply schema, plus a stored query + // `find_person` projecting $p.age (the catalog the server checks against). + let cluster = tempfile::tempdir().unwrap(); + let dir = cluster.path(); + fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap(); + fs::write( + dir.join("find-person.gq"), "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ) + .unwrap(); + fs::write(dir.join("graph.policy.yaml"), POLICY_E2E_YAML).unwrap(); + fs::write( + dir.join("cluster.yaml"), + "version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n knowledge:\n schema: ./graph.pg\n queries:\n find_person:\n file: ./find-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [knowledge]\n", + ) + .unwrap(); + output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir)); + output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir)); + output_success( + cli() + .arg("load") + .arg("--data") + .arg(fixture("test.jsonl")) + .arg("--mode") + .arg("overwrite") + .arg(dir.join("graphs").join("knowledge.omni")), ); - let config = graph.write_config( - "omnigraph-stored-query-schema.yaml", - &format!( - "\ -graphs: - local: - uri: {} - queries: - find_person: - file: ./stored-find-person.gq -cli: - graph: local - branch: main -query: - roots: - - . -policy: {{}} -", - yaml_string(&graph.path().to_string_lossy()) - ), + let server = spawn_server_with_cluster_env( + dir, + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], ); + let renamed_schema = std::fs::read_to_string(fixture("test.pg")) .unwrap() .replace("age: I32?", "years: I32? @rename_from(\"age\")"); - let schema_path = graph.write_file("stored-query-breaks.pg", &renamed_schema); + let temp = tempfile::tempdir().unwrap(); + let schema_path = temp.path().join("stored-query-breaks.pg"); + fs::write(&schema_path, &renamed_schema).unwrap(); - let rejected = output_failure( - cli() - .arg("schema") - .arg("apply") - .arg("--config") - .arg(&config) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), + let rejected = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") + .arg("schema") + .arg("apply") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .output() + .unwrap(); + assert!( + !rejected.status.success(), + "schema apply that breaks a stored query must be rejected" ); let stderr = String::from_utf8_lossy(&rejected.stderr); assert!( @@ -1518,8 +1529,17 @@ policy: {{}} "schema apply should reject the stored-query breakage before publish; stderr: {stderr}" ); + // The schema stayed unchanged (read it back via the served graph as the + // bruno reader, who holds `team-read`). let schema = stdout_string(&output_success( - cli().arg("schema").arg("show").arg("--config").arg(&config), + cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("schema") + .arg("show") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge"), )); assert!(schema.contains("age: I32?")); assert!(!schema.contains("years: I32?")); @@ -1527,22 +1547,31 @@ policy: {{}} #[test] fn local_cli_branch_create_enforces_engine_layer_policy() { - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); - - let denied = output_failure( - cli() - .arg("--as") - .arg("act-bruno") - .arg("branch") - .arg("create") - .arg("--config") - .arg(&config) - .arg("--from") - .arg("main") - .arg("bruno-feature"), + // RFC-011 served re-point: bruno has no branch-ops rule → denied; + // ragnor has admins-branch-ops → allowed. + if skip_system_e2e("local_cli_branch_create_enforces_engine_layer_policy") { + return; + } + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], ); + + let denied = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("branch") + .arg("create") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("--from") + .arg("main") + .arg("bruno-feature") + .output() + .unwrap(); + assert!(!denied.status.success(), "bruno branch create must be denied"); let stderr = String::from_utf8_lossy(&denied.stderr); assert!( stderr.contains("denied"), @@ -1551,12 +1580,13 @@ fn local_cli_branch_create_enforces_engine_layer_policy() { output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("branch") .arg("create") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("--from") .arg("main") .arg("ragnor-feature"), @@ -1565,34 +1595,47 @@ fn local_cli_branch_create_enforces_engine_layer_policy() { #[test] fn local_cli_branch_delete_enforces_engine_layer_policy() { - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); + // RFC-011 served re-point: bruno has no branch-ops rule → denied; + // ragnor has admins-branch-ops → allowed. + if skip_system_e2e("local_cli_branch_delete_enforces_engine_layer_policy") { + return; + } + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], + ); // Pre-create the branch as ragnor so there's something to delete. output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("branch") .arg("create") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("--from") .arg("main") .arg("doomed"), ); - let denied = output_failure( - cli() - .arg("--as") - .arg("act-bruno") - .arg("branch") - .arg("delete") - .arg("--config") - .arg(&config) - .arg("doomed"), - ); + // `--yes` clears the RFC-011 Decision 9 destructive-write confirmation so + // the policy check (not the confirmation refusal) is what denies. + let denied = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("branch") + .arg("delete") + .arg("--yes") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("doomed") + .output() + .unwrap(); + assert!(!denied.status.success(), "bruno branch delete must be denied"); let stderr = String::from_utf8_lossy(&denied.stderr); assert!( stderr.contains("denied"), @@ -1601,48 +1644,61 @@ fn local_cli_branch_delete_enforces_engine_layer_policy() { output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("branch") .arg("delete") - .arg("--config") - .arg(&config) + .arg("--yes") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("doomed"), ); } #[test] fn local_cli_branch_merge_enforces_engine_layer_policy() { - let graph = SystemGraph::loaded(); - let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph)); - graph.write_config("policy.yaml", POLICY_E2E_YAML); + // RFC-011 served re-point: merging into protected main needs + // branch_merge with target_branch_scope protected. bruno has no such + // rule → denied; ragnor has admins-promote → allowed. + if skip_system_e2e("local_cli_branch_merge_enforces_engine_layer_policy") { + return; + } + let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], + ); // Pre-create a feature branch as ragnor (admins-branch-ops covers it). output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("branch") .arg("create") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("--from") .arg("main") .arg("merge-feature"), ); - let denied = output_failure( - cli() - .arg("--as") - .arg("act-bruno") - .arg("branch") - .arg("merge") - .arg("--config") - .arg(&config) - .arg("merge-feature") - .arg("--into") - .arg("main"), - ); + let denied = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .arg("branch") + .arg("merge") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") + .arg("merge-feature") + .arg("--into") + .arg("main") + .output() + .unwrap(); + assert!(!denied.status.success(), "bruno branch merge must be denied"); let stderr = String::from_utf8_lossy(&denied.stderr); assert!( stderr.contains("denied"), @@ -1651,68 +1707,56 @@ fn local_cli_branch_merge_enforces_engine_layer_policy() { output_success( cli() - .arg("--as") - .arg("act-ragnor") + .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") .arg("branch") .arg("merge") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg("knowledge") .arg("merge-feature") .arg("--into") .arg("main"), ); } -// ─── MR-722 PR A: cli.actor config-only precedence ──────────────────────── +// ─── RFC-011: operator.actor cascade ────────────────────────────────────── // -// The change-writer test above uses `--as` directly. These two tests -// pin the precedence rule that `main.rs::resolve_cli_actor` implements: -// `--as` flag > `cli.actor` from `omnigraph.yaml` > None. +// The CLI actor chain is `--as` > `operator.actor` (in the operator config +// at $OMNIGRAPH_HOME/config.yaml) > none. These two tests pin that order on +// a direct (`--store`) write. RFC-011 makes direct-store writes unpoliced, +// so the assertion is on which `actor_id` the write records, not on a Cedar +// allow/deny — the actor still has to be resolved correctly and stamped onto +// the commit. -fn local_policy_config_with_actor(graph: &SystemGraph, actor: &str) -> String { - // Mirrors `local_policy_config` but adds `cli.actor` so the - // config-only precedence path is exercised. The `cli:` block - // already has `graph` and `branch`; appending `actor` here. - format!( - "\ -project: - name: policy-e2e-local -graphs: - local: - uri: {} - policy: - file: ./policy.yaml -cli: - graph: local - branch: main - actor: {} -query: - roots: - - . -", - yaml_string(&graph.path().to_string_lossy()), - actor, +/// An operator config (`$OMNIGRAPH_HOME/config.yaml`) carrying just +/// `operator.actor`. Pointing OMNIGRAPH_HOME at the holding dir makes the +/// CLI read it as the operator layer. +fn operator_home_with_actor(actor: &str) -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + fs::write( + home.path().join("config.yaml"), + format!("operator:\n actor: {actor}\n"), ) + .unwrap(); + home } #[test] fn local_cli_actor_from_config_used_when_no_flag() { - // cli.actor: act-ragnor in omnigraph.yaml, no --as flag → change - // permitted via admins-write rule. Proves the config-only path - // works; previously the only proof was structural. + // operator.actor: act-ragnor in the operator config, no --as flag → + // the write records act-ragnor. Proves the operator-layer actor source + // is consulted when `--as` is absent. let graph = SystemGraph::loaded(); - let config = graph.write_config( - "omnigraph-policy.yaml", - &local_policy_config_with_actor(&graph, "act-ragnor"), - ); - graph.write_config("policy.yaml", POLICY_E2E_YAML); + let home = operator_home_with_actor("act-ragnor"); let mutation_file = insert_person_query(&graph, "system-local-cli-actor.gq"); let allowed = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_HOME", home.path()) .arg("change") - .arg("--config") - .arg(&config) + .arg("--store") + .arg(graph.path()) .arg("--query") .arg(&mutation_file) .arg("--params") @@ -1725,35 +1769,30 @@ fn local_cli_actor_from_config_used_when_no_flag() { #[test] fn local_cli_actor_flag_overrides_config_actor() { - // cli.actor: act-ragnor in config + --as act-bruno on CLI → change - // denied. Flag wins per the precedence rule. Without this test, a - // future change that reverses precedence would ride through silently. + // operator.actor: act-ragnor in the config + --as act-bruno on the CLI → + // the write records act-bruno. The flag wins per the precedence rule. + // Without this test, a future change that reverses precedence would ride + // through silently. let graph = SystemGraph::loaded(); - let config = graph.write_config( - "omnigraph-policy.yaml", - &local_policy_config_with_actor(&graph, "act-ragnor"), - ); - graph.write_config("policy.yaml", POLICY_E2E_YAML); + let home = operator_home_with_actor("act-ragnor"); let mutation_file = insert_person_query(&graph, "system-local-cli-actor-override.gq"); - let denied = output_failure( + let overridden = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_HOME", home.path()) .arg("--as") .arg("act-bruno") .arg("change") - .arg("--config") - .arg(&config) + .arg("--store") + .arg(graph.path()) .arg("--query") .arg(&mutation_file) .arg("--params") .arg(r#"{"name":"OverrideEve","age":19}"#) .arg("--json"), - ); - let stderr = String::from_utf8_lossy(&denied.stderr); - assert!( - stderr.contains("denied"), - "expected 'denied' when --as overrides config to bruno, got: {stderr}" - ); + )); + assert_eq!(overridden["affected_nodes"], 1); + assert_eq!(overridden["actor_id"], "act-bruno"); } /// Phase 5 (RFC-005): "applied means serving" — converge a cluster with the diff --git a/crates/omnigraph-cli/tests/system_remote.rs b/crates/omnigraph-cli/tests/system_remote.rs index 32ae6d7..19f460e 100644 --- a/crates/omnigraph-cli/tests/system_remote.rs +++ b/crates/omnigraph-cli/tests/system_remote.rs @@ -8,6 +8,14 @@ use serde_json::json; use support::*; +/// Graph id every served test addresses (`--server --graph GRAPH_ID`). +/// RFC-011: the server is cluster-only, so a graph selector is always required +/// — even for a single-graph cluster. +const GRAPH_ID: &str = "knowledge"; + +/// Graph-bound Cedar bundle for the policy-flavored remote tests. `act-bruno` +/// (team) reads + writes unprotected branches; `act-ragnor` (admins) merges +/// into protected `main`. const REMOTE_POLICY_E2E_YAML: &str = r#" version: 1 groups: @@ -37,6 +45,8 @@ rules: target_branch_scope: protected "#; +/// Server-scoped bundle granting `act-admin` the `graph_list` action so +/// `GET /graphs` succeeds. const GRAPH_LIST_SERVER_POLICY_YAML: &str = r#" version: 1 groups: @@ -48,61 +58,24 @@ rules: actions: [graph_list] "#; -fn yaml_string(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - -fn remote_policy_server_config(graph: &SystemGraph) -> String { - format!( - "\ -project: - name: remote-policy-e2e -graphs: - local: - uri: {} - policy: - file: ./policy.yaml -server: - graph: local -", - yaml_string(&graph.path().to_string_lossy()) - ) -} - -fn remote_policy_client_config(url: &str) -> String { - format!( - "\ -graphs: - dev: - uri: {} - bearer_token_env: POLICY_TEST_TOKEN -cli: - graph: dev - branch: main -query: - roots: - - . -auth: - env_file: ./.env.omni -", - yaml_string(url) - ) -} - #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_server_and_cli_end_to_end_flow() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let mutation_file = graph.write_query( - "system-remote-change.gq", + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + // The served graph's storage root — used for embedded-side cross checks. + let served_root = cluster.path().join("graphs").join(format!("{GRAPH_ID}.omni")); + let temp = tempfile::tempdir().unwrap(); + let mutation_file = temp.path().join("system-remote-change.gq"); + fs::write( + &mutation_file, r#" query insert_person($name: String, $age: I32) { insert Person { name: $name, age: $age } } "#, - ); + ) + .unwrap(); let client = Client::new(); let health = client @@ -116,13 +89,15 @@ query insert_person($name: String, $age: I32) { assert_eq!(health["status"], "ok"); let local_snapshot = parse_stdout_json(&output_success( - cli().arg("snapshot").arg(graph.path()).arg("--json"), + cli().arg("snapshot").arg(&served_root).arg("--json"), )); let snapshot = parse_stdout_json(&output_success( cli() .arg("snapshot") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--json"), )); assert_eq!(snapshot["branch"], "main"); @@ -132,7 +107,7 @@ query insert_person($name: String, $age: I32) { cli() .arg("read") .arg("--store") - .arg(graph.path()) + .arg(&served_root) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -143,8 +118,10 @@ query insert_person($name: String, $age: I32) { let read_payload = parse_stdout_json(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -156,11 +133,15 @@ query insert_person($name: String, $age: I32) { assert_eq!(read_payload["row_count"], 1); assert_eq!(read_payload["rows"][0]["p.name"], "Alice"); + // Served write: no `--as` (the server resolves the actor; here the server + // is `--unauthenticated`, so the actor is the server default). let change_payload = parse_stdout_json(&output_success( cli() .arg("change") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&mutation_file) .arg("--params") @@ -171,7 +152,7 @@ query insert_person($name: String, $age: I32) { let query_source = fs::read_to_string(fixture("test.gq")).unwrap(); let http_read = client - .post(format!("{}/read", server.base_url)) + .post(format!("{}/graphs/{GRAPH_ID}/read", server.base_url)) .json(&json!({ "branch": "main", "query_source": query_source, @@ -191,7 +172,7 @@ query insert_person($name: String, $age: I32) { cli() .arg("read") .arg("--store") - .arg(graph.path()) + .arg(&served_root) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -202,15 +183,16 @@ query insert_person($name: String, $age: I32) { assert_eq!(local_verify["row_count"], 1); assert_eq!(local_verify["rows"][0]["p.name"], "Mina"); - // CLI `-e` over the HTTP transport (--config points at remote server). - // Confirms inline source survives the remote-execution path identically - // to file-based queries, and exercises `POST /query` end-to-end via the - // change-then-read round trip we just established. + // CLI inline source over the HTTP transport (--server). Confirms inline + // source survives the remote-execution path identically to file-based + // queries. let inline_remote_read = parse_stdout_json(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("-e") .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }") .arg("--params") @@ -223,8 +205,10 @@ query insert_person($name: String, $age: I32) { let inline_remote_change = parse_stdout_json(&output_success( cli() .arg("change") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query-string") .arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }") .arg("--params") @@ -233,10 +217,9 @@ query insert_person($name: String, $age: I32) { )); assert_eq!(inline_remote_change["affected_nodes"], 1); - // `POST /query` happy path directly: a hand-rolled HTTP body using the - // new clean field names. + // `POST /graphs/{id}/query` happy path directly. let http_query = client - .post(format!("{}/query", server.base_url)) + .post(format!("{}/graphs/{GRAPH_ID}/query", server.base_url)) .json(&json!({ "branch": "main", "query": "query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }", @@ -251,9 +234,9 @@ query insert_person($name: String, $age: I32) { assert_eq!(http_query["row_count"], 1); assert_eq!(http_query["rows"][0]["p.name"], "Inline"); - // `POST /query` rejects mutations with 400. + // `POST /graphs/{id}/query` rejects mutations with 400. let http_query_mutation = client - .post(format!("{}/query", server.base_url)) + .post(format!("{}/graphs/{GRAPH_ID}/query", server.base_url)) .json(&json!({ "branch": "main", "query": "query bad($name: String, $age: I32) { insert Person { name: $name, age: $age } }", @@ -262,32 +245,33 @@ query insert_person($name: String, $age: I32) { .send() .unwrap(); assert_eq!(http_query_mutation.status(), reqwest::StatusCode::BAD_REQUEST); - - // `run publish` / `run list` removed. Direct-to-target writes - // already landed via the change call above; the commit graph is now - // the audit surface (verified separately by `commit list`). } #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_schema_apply_via_cli_updates_graph() { - let graph = SystemGraph::initialized(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let next_schema = graph.write_file( - "next.pg", - &fs::read_to_string(fixture("test.pg")).unwrap().replace( + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + let served_root = cluster.path().join("graphs").join(format!("{GRAPH_ID}.omni")); + let temp = tempfile::tempdir().unwrap(); + let next_schema = temp.path().join("next.pg"); + fs::write( + &next_schema, + fs::read_to_string(fixture("test.pg")).unwrap().replace( " age: I32?\n}", " age: I32?\n nickname: String?\n}", ), - ); + ) + .unwrap(); let payload = parse_stdout_json(&output_success( cli() .arg("schema") .arg("apply") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--schema") .arg(&next_schema) .arg("--json"), @@ -296,7 +280,7 @@ fn remote_schema_apply_via_cli_updates_graph() { let db = tokio::runtime::Runtime::new() .unwrap() - .block_on(Omnigraph::open(graph.path().to_string_lossy().as_ref())) + .block_on(Omnigraph::open(served_root.to_string_lossy().as_ref())) .unwrap(); assert!( db.catalog().node_types["Person"] @@ -308,74 +292,95 @@ fn remote_schema_apply_via_cli_updates_graph() { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_schema_apply_rejects_unsupported_plan() { - let graph = SystemGraph::initialized(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let breaking_schema = graph.write_file( - "breaking.pg", - &fs::read_to_string(fixture("test.pg")) + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + let temp = tempfile::tempdir().unwrap(); + let breaking_schema = temp.path().join("breaking.pg"); + fs::write( + &breaking_schema, + fs::read_to_string(fixture("test.pg")) .unwrap() .replace("age: I32?", "age: I64?"), - ); + ) + .unwrap(); let output = output_failure( cli() .arg("schema") .arg("apply") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--schema") .arg(&breaking_schema), ); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("changing property type")); + assert!( + stderr.contains("changing property type"), + "expected unsupported-plan error, got: {stderr}" + ); } #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_schema_apply_rejects_when_non_main_branch_exists() { - let graph = SystemGraph::initialized(); + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + + // Create a non-main branch over the served path so the schema-apply + // single-branch precondition fails. output_success( cli() .arg("branch") .arg("create") + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--from") .arg("main") - .arg("--uri") - .arg(graph.path()) .arg("feature"), ); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let next_schema = graph.write_file( - "next.pg", - &fs::read_to_string(fixture("test.pg")).unwrap().replace( + + let temp = tempfile::tempdir().unwrap(); + let next_schema = temp.path().join("next.pg"); + fs::write( + &next_schema, + fs::read_to_string(fixture("test.pg")).unwrap().replace( " age: I32?\n}", " age: I32?\n nickname: String?\n}", ), - ); + ) + .unwrap(); let output = output_failure( cli() .arg("schema") .arg("apply") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--schema") .arg(&next_schema), ); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("schema apply requires a graph with only main")); + assert!( + stderr.contains("schema apply requires a graph with only main"), + "expected single-branch precondition error, got: {stderr}" + ); } #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_read_preserves_projection_order_in_json_and_csv() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let ordered_query = graph.write_query( - "ordered-remote.gq", + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + let temp = tempfile::tempdir().unwrap(); + let ordered_query = temp.path().join("ordered-remote.gq"); + fs::write( + &ordered_query, r#" query ordered_person($name: String) { match { @@ -384,13 +389,16 @@ query ordered_person($name: String) { return { $p.age, $p.name } } "#, - ); + ) + .unwrap(); let json_payload = parse_stdout_json(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&ordered_query) .arg("ordered_person") @@ -409,8 +417,10 @@ query ordered_person($name: String) { let csv = stdout_string(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&ordered_query) .arg("ordered_person") @@ -427,24 +437,28 @@ query ordered_person($name: String) { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_branch_create_list_merge_flow() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let mutation_file = graph.write_query( - "system-remote-branch-change.gq", + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + let temp = tempfile::tempdir().unwrap(); + let mutation_file = temp.path().join("system-remote-branch-change.gq"); + fs::write( + &mutation_file, r#" query insert_person($name: String, $age: I32) { insert Person { name: $name, age: $age } } "#, - ); + ) + .unwrap(); let initial = parse_stdout_json(&output_success( cli() .arg("branch") .arg("list") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--json"), )); assert_eq!(initial["branches"], json!(["main"])); @@ -453,8 +467,10 @@ query insert_person($name: String, $age: I32) { cli() .arg("branch") .arg("create") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--from") .arg("main") .arg("feature") @@ -467,8 +483,10 @@ query insert_person($name: String, $age: I32) { cli() .arg("branch") .arg("list") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--json"), )); assert_eq!(listed["branches"], json!(["feature", "main"])); @@ -476,8 +494,10 @@ query insert_person($name: String, $age: I32) { let changed = parse_stdout_json(&output_success( cli() .arg("change") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&mutation_file) .arg("--branch") @@ -493,8 +513,10 @@ query insert_person($name: String, $age: I32) { cli() .arg("branch") .arg("merge") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("feature") .arg("--into") .arg("main") @@ -507,8 +529,10 @@ query insert_person($name: String, $age: I32) { let verify = parse_stdout_json(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -523,16 +547,17 @@ query insert_person($name: String, $age: I32) { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_branch_delete_removes_branch() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); parse_stdout_json(&output_success( cli() .arg("branch") .arg("create") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--from") .arg("main") .arg("feature") @@ -543,8 +568,10 @@ fn remote_branch_delete_removes_branch() { cli() .arg("branch") .arg("delete") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("feature") // Served target is non-local → destructive-confirm gate (RFC-011 D9). .arg("--yes") @@ -556,8 +583,10 @@ fn remote_branch_delete_removes_branch() { cli() .arg("branch") .arg("list") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--json"), )); assert_eq!(listed["branches"], json!(["main"])); @@ -566,11 +595,12 @@ fn remote_branch_delete_removes_branch() { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_export_round_trips_full_branch_graph() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let mutation_file = graph.write_query( - "system-remote-export-change.gq", + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + let temp = tempfile::tempdir().unwrap(); + let mutation_file = temp.path().join("system-remote-export-change.gq"); + fs::write( + &mutation_file, r#" query insert_person($name: String, $age: I32) { insert Person { name: $name, age: $age } @@ -580,14 +610,17 @@ query add_friend($from: String, $to: String) { insert Knows { from: $from, to: $to } } "#, - ); + ) + .unwrap(); output_success( cli() .arg("branch") .arg("create") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--from") .arg("main") .arg("feature"), @@ -596,8 +629,10 @@ query add_friend($from: String, $to: String) { output_success( cli() .arg("change") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&mutation_file) .arg("insert_person") @@ -610,8 +645,10 @@ query add_friend($from: String, $to: String) { output_success( cli() .arg("change") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&mutation_file) .arg("add_friend") @@ -625,18 +662,17 @@ query add_friend($from: String, $to: String) { let exported = stdout_string(&output_success( cli() .arg("export") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--branch") .arg("feature") .arg("--jsonl"), )); - let export_path = graph.write_jsonl("system-remote-exported.jsonl", &exported); - let imported_graph = graph - .path() - .parent() - .unwrap() - .join("imported-remote-export.omni"); + let export_path = temp.path().join("system-remote-exported.jsonl"); + fs::write(&export_path, &exported).unwrap(); + let imported_graph = temp.path().join("imported-remote-export.omni"); output_success( cli() @@ -696,20 +732,24 @@ query add_friend($from: String, $to: String) { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_ingest_creates_review_branch_and_keeps_it_readable() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let ingest_data = graph.write_jsonl( - "system-remote-ingest.jsonl", + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + let temp = tempfile::tempdir().unwrap(); + let ingest_data = temp.path().join("system-remote-ingest.jsonl"); + fs::write( + &ingest_data, r#"{"type":"Person","data":{"name":"Zoe","age":33}} {"type":"Person","data":{"name":"Bob","age":26}}"#, - ); + ) + .unwrap(); let ingest_payload = parse_stdout_json(&output_success( cli() .arg("ingest") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--data") .arg(&ingest_data) .arg("--branch") @@ -726,8 +766,10 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() { let feature_snapshot = parse_stdout_json(&output_success( cli() .arg("snapshot") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--branch") .arg("feature-ingest") .arg("--json"), @@ -737,8 +779,10 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() { let zoe = parse_stdout_json(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -758,20 +802,24 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_load_round_trips_and_requires_from_for_new_branches() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); - let extra = graph.write_jsonl( - "system-remote-load.jsonl", + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); + let temp = tempfile::tempdir().unwrap(); + let extra = temp.path().join("system-remote-load.jsonl"); + fs::write( + &extra, r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#, - ); + ) + .unwrap(); // Missing branch without --from: refused remotely, nothing created. let failure = output_failure( cli() .arg("load") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--mode") .arg("merge") .arg("--data") @@ -788,8 +836,10 @@ fn remote_load_round_trips_and_requires_from_for_new_branches() { let payload = parse_stdout_json(&output_success( cli() .arg("load") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--mode") .arg("merge") .arg("--data") @@ -808,8 +858,10 @@ fn remote_load_round_trips_and_requires_from_for_new_branches() { let snapshot = parse_stdout_json(&output_success( cli() .arg("snapshot") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--branch") .arg("feature-load") .arg("--json"), @@ -820,32 +872,38 @@ fn remote_load_round_trips_and_requires_from_for_new_branches() { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_ingest_reuses_existing_branch_and_merges_updates() { - let graph = SystemGraph::loaded(); - let server = graph.spawn_server(); - let config = graph.write_config("omnigraph.yaml", &remote_yaml_config(&server.base_url)); + let cluster = converged_loaded_cluster(GRAPH_ID, None); + let server = spawn_server_with_cluster(cluster.path()); output_success( cli() .arg("branch") .arg("create") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--from") .arg("main") .arg("feature-ingest"), ); - let ingest_data = graph.write_jsonl( - "system-remote-ingest-merge.jsonl", + let temp = tempfile::tempdir().unwrap(); + let ingest_data = temp.path().join("system-remote-ingest-merge.jsonl"); + fs::write( + &ingest_data, r#"{"type":"Person","data":{"name":"Bob","age":26}} {"type":"Person","data":{"name":"Zoe","age":33}}"#, - ); + ) + .unwrap(); let ingest_payload = parse_stdout_json(&output_success( cli() .arg("ingest") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--data") .arg(&ingest_data) .arg("--branch") @@ -864,8 +922,10 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() { let bob = parse_stdout_json(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -881,8 +941,10 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() { let zoe = parse_stdout_json(&output_success( cli() .arg("read") - .arg("--config") - .arg(&config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -899,45 +961,51 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() { #[test] #[ignore = "requires loopback socket permissions in sandboxed runners"] fn remote_policy_enforces_branch_first_cli_workflow() { - let graph = SystemGraph::loaded(); - let server_config = - graph.write_config("server-policy.yaml", &remote_policy_server_config(&graph)); - graph.write_config("policy.yaml", REMOTE_POLICY_E2E_YAML); - let server = graph.spawn_server_with_config_env( - &server_config, + // Served policy enforcement: the cluster binds REMOTE_POLICY_E2E_YAML to the + // graph, and the server maps bearer tokens to actors. The actor is resolved + // from the token (no `--as` on served writes). + let cluster = converged_loaded_cluster(GRAPH_ID, Some(REMOTE_POLICY_E2E_YAML)); + let server = spawn_server_with_cluster_env( + cluster.path(), &[( "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", r#"{"act-bruno":"team-token","act-ragnor":"admin-token"}"#, )], ); - let client_config = graph.write_config( - "omnigraph-policy.yaml", - &remote_policy_client_config(&server.base_url), - ); - graph.write_config(".env.omni", "POLICY_TEST_TOKEN=team-token\n"); - let mutation_file = graph.write_query( - "system-remote-policy-change.gq", + let temp = tempfile::tempdir().unwrap(); + let mutation_file = temp.path().join("system-remote-policy-change.gq"); + fs::write( + &mutation_file, r#" query insert_person($name: String, $age: I32) { insert Person { name: $name, age: $age } } "#, - ); + ) + .unwrap(); + // Reads are granted to the team group (bruno). let snapshot = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "team-token") .arg("snapshot") - .arg("--config") - .arg(&client_config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--json"), )); assert_eq!(snapshot["branch"], "main"); + // bruno cannot change protected main (team-write-unprotected only). let denied_main_change = output_failure( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "team-token") .arg("change") - .arg("--config") - .arg(&client_config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&mutation_file) .arg("--params") @@ -945,14 +1013,23 @@ query insert_person($name: String, $age: I32) { .arg("--json"), ); let denied_main_stderr = String::from_utf8(denied_main_change.stderr).unwrap(); - assert!(denied_main_stderr.contains("policy denied action 'change' on branch 'main'")); + assert!( + denied_main_stderr.contains("denied") + && denied_main_stderr.contains("change") + && denied_main_stderr.contains("main"), + "expected change-on-main denial, got: {denied_main_stderr}" + ); + // bruno can create an unprotected branch. let created = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "team-token") .arg("branch") .arg("create") - .arg("--config") - .arg(&client_config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--from") .arg("main") .arg("feature") @@ -960,11 +1037,15 @@ query insert_person($name: String, $age: I32) { )); assert_eq!(created["name"], "feature"); + // bruno can change the unprotected branch; actor resolves from the token. let changed = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "team-token") .arg("change") - .arg("--config") - .arg(&client_config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(&mutation_file) .arg("--branch") @@ -975,28 +1056,39 @@ query insert_person($name: String, $age: I32) { )); assert_eq!(changed["branch"], "feature"); assert_eq!(changed["affected_nodes"], 1); + assert_eq!(changed["actor_id"], "act-bruno"); + // bruno cannot merge into protected main (admins-promote only). let denied_merge = output_failure( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "team-token") .arg("branch") .arg("merge") - .arg("--config") - .arg(&client_config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("feature") .arg("--into") .arg("main") .arg("--json"), ); let denied_merge_stderr = String::from_utf8(denied_merge.stderr).unwrap(); - assert!(denied_merge_stderr.contains("policy denied action 'branch_merge'")); + assert!( + denied_merge_stderr.contains("denied") && denied_merge_stderr.contains("branch_merge"), + "expected branch_merge denial, got: {denied_merge_stderr}" + ); + // ragnor (admins) can promote into protected main. let merged = parse_stdout_json(&output_success( cli() - .env("POLICY_TEST_TOKEN", "admin-token") + .env("OMNIGRAPH_BEARER_TOKEN", "admin-token") .arg("branch") .arg("merge") - .arg("--config") - .arg(&client_config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("feature") .arg("--into") .arg("main") @@ -1006,9 +1098,12 @@ query insert_person($name: String, $age: I32) { let verify = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "team-token") .arg("read") - .arg("--config") - .arg(&client_config) + .arg("--server") + .arg(&server.base_url) + .arg("--graph") + .arg(GRAPH_ID) .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -1022,13 +1117,16 @@ query insert_person($name: String, $age: I32) { // ─── MR-668 PR 8 — omnigraph graphs list end-to-end ──────────────────────── -/// Multi-graph server + CLI `omnigraph graphs list` end-to-end. +/// Multi-graph server + CLI `omnigraph graphs list` end-to-end (RFC-011 +/// cluster-only serving). /// /// Steps: -/// 1. Init a graph `alpha` on disk and write an `omnigraph.yaml` -/// whose `graphs:` map references it. -/// 2. Spawn the server with `--config `. -/// 3. `omnigraph graphs list` — expect to see `alpha`. +/// 1. Build a converged cluster serving one graph `alpha` with a +/// server-scoped policy granting `act-admin` the `graph_list` action. +/// 2. Spawn the server with `--cluster` + a bearer-token map. +/// 3. `omnigraph graphs list --server ` (admin token) — expect `alpha`. +/// 4. Addressing the server via `--server ` with NO `--graph` errors and +/// lists the candidate graphs (RFC-011 D7). /// /// Ignored by default — spawning servers needs loopback socket /// permissions some sandboxes lack. @@ -1036,86 +1134,33 @@ query insert_person($name: String, $age: I32) { #[ignore = "requires loopback socket permissions in sandboxed runners"] fn graphs_list_against_multi_graph_server() { let cfg_dir = tempfile::tempdir().unwrap(); - let schema_path = fixture("test.pg"); - - // Init `alpha` on disk. - let alpha_uri = cfg_dir.path().join("alpha.omni"); - tokio::runtime::Runtime::new().unwrap().block_on(async { - Omnigraph::init( - alpha_uri.to_str().unwrap(), - &fs::read_to_string(&schema_path).unwrap(), - ) - .await - .unwrap(); - }); - + let dir = cfg_dir.path(); + fs::copy(fixture("test.pg"), dir.join("alpha.pg")).unwrap(); + fs::write(dir.join("server.policy.yaml"), GRAPH_LIST_SERVER_POLICY_YAML).unwrap(); fs::write( - cfg_dir.path().join("server-policy.yaml"), - GRAPH_LIST_SERVER_POLICY_YAML, + dir.join("cluster.yaml"), + "version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n alpha:\n schema: ./alpha.pg\npolicies:\n server:\n file: ./server.policy.yaml\n applies_to: [cluster]\n", ) .unwrap(); + output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir)); + output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir)); - // Server config with `graphs:` map and no `server.graph` selector - // — multi mode (rule 4 of the inference matrix). `GET /graphs` is a - // server-scoped action, so the success path needs an explicit server - // policy and bearer token. - let server_config_path = cfg_dir.path().join("omnigraph.yaml"); - fs::write( - &server_config_path, - format!( - "\ -server: - policy: - file: ./server-policy.yaml -graphs: - alpha: - uri: {} -", - yaml_string(&alpha_uri.to_string_lossy()) - ), - ) - .unwrap(); - - let server = spawn_server_with_config_env( - &server_config_path, + let server = spawn_server_with_cluster_env( + dir, &[( "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", r#"{"act-admin":"admin-token"}"#, )], ); - // Client config — the CLI's `--target dev` resolves to `server.base_url`. - let client_config_path = cfg_dir.path().join("client.yaml"); - fs::write( - &client_config_path, - format!( - "\ -graphs: - dev: - uri: {} - bearer_token_env: GRAPH_LIST_TOKEN -cli: - graph: dev -auth: - env_file: ./.env.omni -", - yaml_string(&server.base_url) - ), - ) - .unwrap(); - fs::write( - cfg_dir.path().join(".env.omni"), - "GRAPH_LIST_TOKEN=admin-token\n", - ) - .unwrap(); - // `graphs list` lists `alpha`. let payload = parse_stdout_json(&output_success( cli() + .env("OMNIGRAPH_BEARER_TOKEN", "admin-token") .arg("graphs") .arg("list") - .arg("--config") - .arg(&client_config_path) + .arg("--server") + .arg(&server.base_url) .arg("--json"), )); let ids: Vec<&str> = payload["graphs"]