From 05cf8abe7b8c8785926796c4b5deebf2c29aecc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:31:22 +0000 Subject: [PATCH 01/11] refactor(cli): single owner for registry addressing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract GraphClient::registry_client(server) — the bare server base URL (never /graphs/) plus the keyed bearer-token chain — and rewire the RFC-011 D7 multi-graph probe through it, so GET /graphs has exactly one addressing owner. Behavior-identical: the probe's None-base early return was unreachable (resolve_server_flag with a Some server never returns None), and unknown-server errors still propagate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/src/client.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 151aeff3..6d03553c 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -78,15 +78,7 @@ async fn require_graph_for_multi_graph_server( let (Some(server), None) = (scope.server.as_deref(), scope.graph.as_deref()) else { return Ok(()); }; - let Some(base) = resolve_server_flag(Some(server), None)? else { - return Ok(()); - }; - let token = resolve_remote_bearer_token(Some(&base))?; - let probe = GraphClient::Remote { - http: build_http_client()?, - base_url: base, - token, - }; + let probe = GraphClient::registry_client(server)?; if let Ok(resp) = probe.list_graphs().await { if !resp.graphs.is_empty() { let ids: Vec<&str> = resp.graphs.iter().map(|g| g.graph_id.as_str()).collect(); @@ -116,6 +108,22 @@ fn reject_positional_remote(via_server: bool, uri: &str) -> Result<()> { } impl GraphClient { + /// The single owner of registry (`GET /graphs`) addressing: the bare base + /// URL of `server` (a config name or literal URL) — never `/graphs/` + /// — with the keyed bearer-token chain. Synchronous: pure config + /// resolution, no I/O. Used by the RFC-011 D7 multi-graph probe and the + /// `graphs list` registry factory. + fn registry_client(server: &str) -> Result { + let base = resolve_server_flag(Some(server), None)? + .expect("server name is present"); + let token = resolve_remote_bearer_token(Some(&base))?; + Ok(GraphClient::Remote { + http: build_http_client()?, + base_url: base, + token, + }) + } + /// Resolve the addressing (positional URI / `--target` / `--server`) /// and credential once, then pick the variant by URI scheme — the /// single branch point that replaces every per-command `is_remote` From bdf4228050192b67f556e4c357045dbfec809f11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:39:53 +0000 Subject: [PATCH 02/11] test(cli): pin scope-flag applicability matrix (red) Red half of the red-green pair for the declarative ScopeFlag x Capability addressing guard. Predicted failures against current code: - graphs_list_rejects_store_scope (rewrites the old remote-only-message test): today the flag flows through and fails late in the client with "requires a remote multi-graph server" instead of the guard rejection. - graphs_list_rejects_graph_selector: today --graph passes the guard and corrupts the URL to /graphs//graphs, failing with a connection error to the wrong route. - graphs_list_rejects_as_actor: today --as is silently ignored and the command fails with "no graph addressed". - optimize_with_as_flag_errors: today --as is silently ignored on the Direct maintenance verb. - queries_list_with_store_flag_errors: today --store is silently ignored and the command fails asking for a cluster. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/tests/cli_data.rs | 16 ++++++ crates/omnigraph-cli/tests/cli_queries.rs | 22 ++++++++ .../omnigraph-cli/tests/cli_schema_config.rs | 56 +++++++++++++++++-- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index 1a4b9c94..4c7b1150 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -171,6 +171,22 @@ fn optimize_with_server_flag_errors_wrong_plane() { ); } +#[test] +fn optimize_with_as_flag_errors() { + // `--as` attributes an actor on a direct-engine or cluster write; the + // Direct maintenance verbs record no actor, so the flag is rejected + // loudly (was: silently ignored). + let output = output_failure(cli().arg("optimize").arg("--as").arg("act-op")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("`optimize` is a direct (storage-native) command") + && stderr.contains( + "--as sets the actor for a direct-engine or cluster write and does not apply" + ), + "expected the addressing-guard --as rejection; got: {stderr}" + ); +} + #[test] fn wrong_address_guard_message_has_no_trailing_space() { // The remediation tail is empty for served-addressing capabilities, so a diff --git a/crates/omnigraph-cli/tests/cli_queries.rs b/crates/omnigraph-cli/tests/cli_queries.rs index b51018ed..744e81ab 100644 --- a/crates/omnigraph-cli/tests/cli_queries.rs +++ b/crates/omnigraph-cli/tests/cli_queries.rs @@ -114,6 +114,28 @@ fn alias_rejects_global_scope_flags_that_the_binding_owns() { } } +#[test] +fn queries_list_with_store_flag_errors() { + // `queries list` reads a cluster's applied state; a single-graph `--store` + // address can never apply. Rejected loudly at the addressing guard (was: + // silently ignored, then failed later asking for a cluster). + let output = output_failure( + cli() + .arg("--store") + .arg("file:///tmp/graph.omni") + .arg("queries") + .arg("list"), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("`queries list` is a cluster control command") + && stderr.contains( + "--store addresses a single graph's storage directly and does not apply" + ), + "expected the addressing-guard store rejection; got: {stderr}" + ); +} + #[test] fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() { let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list")); diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index b0270e31..aecefd3e 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -551,9 +551,11 @@ 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. +fn graphs_list_rejects_store_scope() { + // `graphs list` is a served-registry command: it enumerates a server's + // graphs, so a `--store` (local) address can never apply. The addressing + // guard rejects it up front instead of failing late in the client with an + // engine-limitation message. let output = output_failure( cli() .arg("graphs") @@ -563,7 +565,51 @@ fn graphs_list_against_local_uri_errors_with_remote_only_message() { ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!( - stderr.contains("remote multi-graph server"), - "expected a remote-server rejection in stderr; got:\n{stderr}" + stderr.contains("`graphs list` is a served command") + && stderr.contains( + "--store addresses a single graph's storage directly and does not apply" + ) + && stderr.contains("Address the server with --server or --profile ."), + "expected the addressing-guard store rejection in stderr; got:\n{stderr}" + ); +} + +#[test] +fn graphs_list_rejects_graph_selector() { + // `graphs list` IS the enumeration — selecting a graph is meaningless, and + // historically `--graph` corrupted the URL to `/graphs//graphs` (404). + // The guard rejects it before any network I/O. + let output = output_failure( + cli() + .arg("graphs") + .arg("list") + .arg("--server") + .arg("http://127.0.0.1:9") + .arg("--graph") + .arg("atlas"), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("`graphs list` is a served command") + && stderr.contains( + "--graph selects a graph within a server or cluster scope and does not apply" + ) + && stderr.contains("Address the server with --server or --profile ."), + "expected the addressing-guard graph rejection in stderr; got:\n{stderr}" + ); +} + +#[test] +fn graphs_list_rejects_as_actor() { + // The registry read carries no actor; `--as` is for direct-engine and + // cluster writes. Rejected loudly instead of silently ignored. + let output = output_failure(cli().arg("graphs").arg("list").arg("--as").arg("act-op")); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("`graphs list` is a served command") + && stderr.contains( + "--as sets the actor for a direct-engine or cluster write and does not apply" + ), + "expected the addressing-guard --as rejection in stderr; got:\n{stderr}" ); } From 0eefb287800abc98f77d1f6f707c2e05005f1b97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:54:04 +0000 Subject: [PATCH 03/11] fix(cli): declarative ScopeFlag x Capability addressing guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace guard_addressing's three ad-hoc flag checks with one exhaustive ScopeFlag enum and a flag_applies matrix keyed by capability (--cluster keeps its per-command accepts_cluster_addressing refinement). Adding a flag or capability now forces a matrix row at compile time, the same can't-silently-drift construction as the exhaustive command_plane match. Deliberate tightenings (previously silently ignored or failed late with an unrelated message): - served (graphs list) now rejects --graph (it used to corrupt the registry URL to /graphs//graphs), --store, and --as at the guard; - direct maintenance verbs reject --as (they record no actor); - control/local verbs reject --store; local verbs reject --graph. Load-bearing behavior preserved: --store/--as stay consumed on the data (any) capability — embedded reads accept-and-ignore --as, and the served-write --as rejection keeps its own downstream message. All pinned guard message texts are byte-identical; served gains its own remediation tail. command_label now descends into GraphsCommand ("graphs list"). accepts_server_addressing is folded into the matrix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/src/planes.rs | 225 ++++++++++++++++++++--------- docs/user/cli/reference.md | 6 +- 2 files changed, 163 insertions(+), 68 deletions(-) diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index b5990764..fe27ac02 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -12,7 +12,7 @@ use color_eyre::Result; use color_eyre::eyre::bail; -use crate::cli::{Cli, Command, QueriesCommand, SchemaCommand}; +use crate::cli::{Cli, Command, GraphsCommand, QueriesCommand, SchemaCommand}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Plane { @@ -71,10 +71,100 @@ impl Capability { } } - /// `--server`/`--graph` are served-graph addressing: they apply only to the - /// capabilities that reach a graph through a server. - fn accepts_server_addressing(self) -> bool { - matches!(self, Capability::Any | Capability::Served) +} + +/// The global scope-addressing flags, exhaustively. Adding a flag forces a +/// row in `flag_applies` and in the guard's reporting — the same +/// can't-silently-drift construction as the exhaustive `command_plane` match. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScopeFlag { + Server, + Cluster, + Graph, + Store, + As, + Profile, +} + +impl ScopeFlag { + /// Guard evaluation order: first offending flag wins, preserving the + /// pre-matrix reporting precedence server → cluster → graph. + pub(crate) const ALL: [ScopeFlag; 6] = [ + ScopeFlag::Server, + ScopeFlag::Cluster, + ScopeFlag::Graph, + ScopeFlag::Store, + ScopeFlag::As, + ScopeFlag::Profile, + ]; + + fn flag_name(self) -> &'static str { + match self { + ScopeFlag::Server => "--server", + ScopeFlag::Cluster => "--cluster", + ScopeFlag::Graph => "--graph", + ScopeFlag::Store => "--store", + ScopeFlag::As => "--as", + ScopeFlag::Profile => "--profile", + } + } + + /// What the flag means, for the wrong-address error ("{clause} and does + /// not apply"). `Profile` is never rejected (it is a scope default that + /// applies everywhere), so its clause is unreachable in practice. + fn rejection_clause(self) -> &'static str { + match self { + ScopeFlag::Server => "--server addresses a served graph", + ScopeFlag::Cluster => "--cluster addresses a cluster-scoped command", + ScopeFlag::Graph => "--graph selects a graph within a server or cluster scope", + ScopeFlag::Store => "--store addresses a single graph's storage directly", + ScopeFlag::As => "--as sets the actor for a direct-engine or cluster write", + ScopeFlag::Profile => "--profile selects a scope bundle", + } + } + + fn is_set(self, cli: &Cli) -> bool { + match self { + ScopeFlag::Server => cli.server.is_some(), + ScopeFlag::Cluster => cli.cluster.is_some(), + ScopeFlag::Graph => cli.graph.is_some(), + ScopeFlag::Store => cli.store.is_some(), + ScopeFlag::As => cli.as_actor.is_some(), + ScopeFlag::Profile => cli.profile.is_some(), + } + } +} + +/// Which scope flags a verb can consume: one declarative matrix keyed by +/// capability, plus the one per-command refinement (`accepts_cluster_addressing` +/// — cluster addressing is per-command, not per-capability: optimize/repair/ +/// cleanup/lint accept it while the equally-`direct` init/schema-plan do not). +fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool { + use Capability::*; + let cluster_ok = accepts_cluster_addressing(cmd); + match flag { + // Served-graph addressing; `served` is the registry scope (the bare + // server), which still needs a server to talk to. + ScopeFlag::Server => matches!(capability, Any | Served), + ScopeFlag::Cluster => cluster_ok, + // The one graph selector across scopes: a served graph (`any`), or a + // cluster graph on the verbs that take cluster addressing. `served` + // rejects it — `graphs list` IS the enumeration, and a selected graph + // would corrupt the registry URL. + ScopeFlag::Graph => match capability { + Any => true, + Direct | Control => cluster_ok, + Served | Local => false, + }, + ScopeFlag::Store => matches!(capability, Any | Direct), + // The actor rides direct-engine (`any` via --store) and cluster + // writes; served writes resolve the actor from the bearer token + // (rejected downstream with its own message), and `direct` + // maintenance verbs record no actor. + ScopeFlag::As => matches!(capability, Any | Control), + // A profile is a scope-default bundle; rejecting the flag while the + // $OMNIGRAPH_PROFILE env stays honored would be inconsistent. + ScopeFlag::Profile => true, } } @@ -170,7 +260,9 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str { Command::Repair { .. } => "repair", Command::Cleanup { .. } => "cleanup", Command::Cluster { .. } => "cluster", - Command::Graphs { .. } => "graphs", + Command::Graphs { command } => match command { + GraphsCommand::List { .. } => "graphs list", + }, } } @@ -210,25 +302,20 @@ pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool { /// RFC-010 Slice 1, generalized for RFC-011 cluster addressing. pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> { if let Command::Alias { .. } = &cli.command { - let mut flags = Vec::new(); - if cli.server.is_some() { - flags.push("--server"); - } - if cli.graph.is_some() { - flags.push("--graph"); - } - if cli.store.is_some() { - flags.push("--store"); - } - if cli.cluster.is_some() { - flags.push("--cluster"); - } - if cli.profile.is_some() { - flags.push("--profile"); - } - if cli.as_actor.is_some() { - flags.push("--as"); - } + // The binding owns all addressing. The listing keeps its historical + // flag order (error text is observable contract). + let flags: Vec<&str> = [ + ScopeFlag::Server, + ScopeFlag::Graph, + ScopeFlag::Store, + ScopeFlag::Cluster, + ScopeFlag::Profile, + ScopeFlag::As, + ] + .into_iter() + .filter(|flag| flag.is_set(cli)) + .map(ScopeFlag::flag_name) + .collect(); if !flags.is_empty() { bail!( "`alias` uses the server, graph, and stored query declared in \ @@ -238,43 +325,25 @@ pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> { ); } } - if cli.server.is_none() && cli.cluster.is_none() && cli.graph.is_none() { - return Ok(()); - } let capability = command_capability(&cli.command); let label = command_label(&cli.command); - let cluster_ok = accepts_cluster_addressing(&cli.command); - - if cli.server.is_some() && !capability.accepts_server_addressing() { - bail!( - "`{label}` is a {} command; --server addresses a served graph and does not apply.{}", - capability.describe(), - remediation(capability, &cli.command), - ); - } - if cli.cluster.is_some() && !cluster_ok { - bail!( - "`{label}` is a {} command; --cluster addresses a cluster-scoped command \ - and does not apply.{}", - capability.describe(), - remediation(capability, &cli.command), - ); - } - if cli.graph.is_some() && !(capability.accepts_server_addressing() || cluster_ok) { - bail!( - "`{label}` is a {} command; --graph selects a graph within a server or cluster \ - scope and does not apply.{}", - capability.describe(), - remediation(capability, &cli.command), - ); + for flag in ScopeFlag::ALL { + if flag.is_set(cli) && !flag_applies(flag, capability, &cli.command) { + bail!( + "`{label}` is a {} command; {} and does not apply.{}", + capability.describe(), + flag.rejection_clause(), + remediation(capability, &cli.command), + ); + } } Ok(()) } /// The "what to do instead" tail for a wrong-address error, by capability. /// Includes its own leading space when non-empty so the caller appends it -/// directly — an empty tail (the served-addressing capabilities, which only -/// reach this fn for a misplaced `--cluster`/`--graph`) leaves no trailing space. +/// directly — an empty tail (`any`, which only reaches this fn for a +/// misplaced `--cluster`) leaves no trailing space. fn remediation(capability: Capability, cmd: &Command) -> &'static str { match capability { Capability::Direct => match cmd { @@ -294,7 +363,8 @@ fn remediation(capability: Capability, cmd: &Command) -> &'static str { _ => " It operates on a cluster.", }, Capability::Local => " It does not address a graph.", - Capability::Any | Capability::Served => "", + Capability::Served => " Address the server with --server or --profile .", + Capability::Any => "", } } @@ -305,16 +375,41 @@ mod tests { use super::*; #[test] - fn server_addressing_allowed_exactly_on_any_and_served() { - // The behavior-preservation contract: `--server`/`--graph` apply to the - // served-graph capabilities (`any`, `served`) and nothing else. This is - // the old "Data plane only" allow set, re-expressed — graphs (the one - // Data→Served verb) was already allowed. - assert!(Capability::Any.accepts_server_addressing()); - assert!(Capability::Served.accepts_server_addressing()); - assert!(!Capability::Direct.accepts_server_addressing()); - assert!(!Capability::Control.accepts_server_addressing()); - assert!(!Capability::Local.accepts_server_addressing()); + fn scope_flag_matrix_matches_capabilities() { + // The full flag × capability contract in one place. Rows cover every + // capability, both cluster_ok refinements of `direct` (optimize vs + // init) and of `control` (queries vs cluster). `served` is the + // registry scope: server addressing only — --graph/--store/--as are + // rejected (--graph used to corrupt the registry URL to + // /graphs//graphs). + let parse = |args: &[&str]| Cli::try_parse_from(args).unwrap().command; + // (command, [server, cluster, graph, store, as, profile]) + let rows = [ + (parse(&["omnigraph", "query", "q"]), [true, false, true, true, true, true]), + (parse(&["omnigraph", "graphs", "list"]), [true, false, false, false, false, true]), + (parse(&["omnigraph", "optimize", "g.omni"]), [false, true, true, true, false, true]), + ( + parse(&["omnigraph", "init", "--schema", "s.pg", "g.omni"]), + [false, false, false, true, false, true], + ), + (parse(&["omnigraph", "queries", "list"]), [false, true, true, false, true, true]), + ( + parse(&["omnigraph", "cluster", "status", "--config", "."]), + [false, false, false, false, true, true], + ), + (parse(&["omnigraph", "version"]), [false, false, false, false, false, true]), + ]; + for (cmd, expected) in &rows { + let capability = command_capability(cmd); + for (flag, want) in ScopeFlag::ALL.into_iter().zip(*expected) { + assert_eq!( + flag_applies(flag, capability, cmd), + want, + "{flag:?} on `{}` ({capability:?})", + command_label(cmd), + ); + } + } } #[test] diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index e3f47d8a..988344e1 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -35,14 +35,14 @@ Top-level command families and subcommands. Graph-targeting commands accept a po Every command declares the **capability** it needs — what it requires to reach a graph — which determines the addressing flags that apply: - **`any`** — `query`, `mutate`, `load`, `ingest`, `branch *`, `snapshot`, `export`, `commit *`, `schema show`, `schema apply`. Run against a graph **served (via a server) or embedded (direct against a store)**: accept a positional `file://`/`s3://` URI, `--server ` (+ `--graph ` for multi-graph servers), `--store `, or `--profile `. A remote server is addressed with `--server` — a positional `http(s)://` URI does **not** dispatch to one. -- **`served`** — `graphs list`. Requires a server (accepts `--server` / `--profile`). -- **`direct`** — `init`, `optimize`, `repair`, `cleanup`, `schema plan`, `lint`. Need **direct storage access** (`file://` / `s3://`), never through a server. They accept a positional `URI`, but **not** `--server`, and a remote (`http(s)://`) URI is rejected. `optimize` / `repair` / `cleanup` additionally accept **`--cluster --graph `** (`--cluster` is a cluster directory or storage-root URI, named via `clusters:` in `~/.omnigraph/config.yaml` or a literal root), which resolves the graph's storage URI from the served cluster state (so you needn't know the `/graphs/.omni` layout). `--graph` is the one graph selector across all scopes — on these three verbs it picks the cluster graph; on the other `direct` verbs it does not apply. +- **`served`** — `graphs list`. Requires a server, and addresses the server's graph *registry* (the bare server URL), not a graph within it: only `--server` / `--profile` apply, and `--graph`, `--store`, and `--as` are rejected loudly. +- **`direct`** — `init`, `optimize`, `repair`, `cleanup`, `schema plan`, `lint`. Need **direct storage access** (`file://` / `s3://`), never through a server. They accept a positional `URI`, but **not** `--server`, and a remote (`http(s)://`) URI is rejected. `optimize` / `repair` / `cleanup` additionally accept **`--cluster --graph `** (`--cluster` is a cluster directory or storage-root URI, named via `clusters:` in `~/.omnigraph/config.yaml` or a literal root), which resolves the graph's storage URI from the served cluster state (so you needn't know the `/graphs/.omni` layout). `--graph` is the one graph selector across all scopes — on these three verbs it picks the cluster graph; on the other `direct` verbs it does not apply. `--as` does not apply to any `direct` verb — maintenance records no actor. - **`control`** — `cluster *` via `--config `; `policy *` and `queries *` via `--cluster ` or a cluster profile. - **`local`** — `alias`, `embed`, `login`, `logout`, `profile`, `version`. Address no explicit graph scope. These restrictions are enforced and reported, not silent: -- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, or `--graph` where no multi-graph scope applies, e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` +- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, `--graph` where no multi-graph scope applies, `--store` outside the graph-addressed (`any`/`direct`) verbs, or `--as` outside the verbs that record an actor (`any`/`control`), e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` - A `direct` verb pointed at a remote URI fails loudly, e.g.: ``optimize is a direct (storage-native) command and needs direct storage access; the resolved target is a remote server (https://…). Pass the graph's file:// or s3:// URI.`` - A data verb pointed at a positional `http(s)://` URI fails loudly: ``a remote graph must be addressed with --server — a positional (or --uri) http(s):// URL no longer dispatches to a server.`` - `init` into an **established cluster's** storage layout (`/graphs/.omni` where `` holds `__cluster/state.json`) is refused — graphs in a cluster are created by `cluster apply` (which records ledger / recovery / approvals), not `init`. From 591cf55eda6e8b75d61510e178ad277d8e3fe10c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:55:13 +0000 Subject: [PATCH 04/11] test(cli): pin graphs-list served-registry resolution (red) Red half of the red-green pair for the served-registry resolution path. Predicted failures against current code: - graphs_list_without_scope_needs_server: today the graph-shaped "no graph addressed" advice points at flags graphs list cannot consume. - graphs_list_rejects_store_bound_profile: today the store scope flows through to the embedded arm and fails late with "requires a remote multi-graph server". - graphs_list_rejects_cluster_bound_profile: today the hardcoded Any capability yields the misleading "not valid for graph data commands" error. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- .../omnigraph-cli/tests/cli_schema_config.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index aecefd3e..7d52fee5 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -599,6 +599,82 @@ fn graphs_list_rejects_graph_selector() { ); } +/// Operator home for the graphs-list registry-resolution tests: a store-bound +/// and a cluster-bound profile, neither of which can carry a served-registry +/// command. +fn registry_profile_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + std::fs::write( + home.path().join("config.yaml"), + "servers:\n prod:\n url: https://graph.example.com\n\ + clusters:\n brain:\n root: s3://acme/clusters/brain\n\ + profiles:\n\ + \x20 localdev:\n store: file:///data/dev.omni\n\ + \x20 brain-admin:\n cluster: brain\n", + ) + .unwrap(); + home +} + +#[test] +fn graphs_list_without_scope_needs_server() { + // No addressing and no operator defaults: the served-registry resolver + // asks for a server scope — not the graph-shaped "no graph addressed" + // advice, which points at flags graphs list cannot consume. + let output = output_failure(cli().arg("graphs").arg("list")); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("`graphs list` needs a server scope") + && stderr.contains("--server or --profile "), + "expected the needs-a-server-scope error in stderr; got:\n{stderr}" + ); +} + +#[test] +fn graphs_list_rejects_store_bound_profile() { + // A store-bound profile resolves a single graph's storage; the registry + // is server-scoped, so resolution fails with scope-shaped advice instead + // of the late embedded-arm engine-limitation message. + let home = registry_profile_home(); + let output = output_failure( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .arg("graphs") + .arg("list") + .arg("--profile") + .arg("localdev"), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("requires a server") + && stderr.contains("profile 'localdev' resolves a store scope"), + "expected the served store-scope rejection in stderr; got:\n{stderr}" + ); +} + +#[test] +fn graphs_list_rejects_cluster_bound_profile() { + // A cluster-bound profile is control-plane addressing; the rejection + // points at `cluster status` for cluster-graph enumeration instead of the + // misleading graph-data-command error. + let home = registry_profile_home(); + let output = output_failure( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .arg("graphs") + .arg("list") + .arg("--profile") + .arg("brain-admin"), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("requires a server") + && stderr.contains("profile 'brain-admin' resolves a cluster scope") + && stderr.contains("cluster status"), + "expected the served cluster-scope rejection in stderr; got:\n{stderr}" + ); +} + #[test] fn graphs_list_rejects_as_actor() { // The registry read carries no actor; `--as` is for direct-engine and From db5b637b128d30fecaa85c0dcfcb89b8a4553015 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:57:58 +0000 Subject: [PATCH 05/11] fix(cli): graphs list resolves through the served registry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enumeration verb no longer routes through the graph-addressed GraphClient::resolve() preamble, whose RFC-011 D7 require-graph probe demanded --graph before list_graphs() could run — and whose URL builder then corrupted the target to /graphs//graphs when --graph was supplied. GraphClient::resolve_registry resolves a server scope (--server / --profile / defaults.server) straight to the bare base URL via the shared registry_client; it is synchronous by design, so the async D7 probe structurally cannot fire on this path, and a scope's default_graph is deliberately ignored (the registry is server-scoped; rejecting a config default would make graphs list unusable in any profile that sets one). scope_from_binding gains Served arms: a store-bound scope fails with scope-shaped advice and a cluster-bound scope points at `omnigraph cluster status` instead of the misleading graph-data error. The dead `--uri` option on `graphs list` (rejected at runtime by reject_positional_remote since RFC-011, contradicting its help text) is removed, and the unreachable embedded arm of list_graphs becomes a defensive internal-invariant bail. Turns the commit-4 red tests green; the ignored e2e graphs_list_against_multi_graph_server step 3 is the end-to-end owner. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/src/cli.rs | 14 +++--- crates/omnigraph-cli/src/client.rs | 77 ++++++++++++++++++++++++++--- crates/omnigraph-cli/src/main.rs | 16 +++--- crates/omnigraph-cli/src/scope.rs | 79 ++++++++++++++++++++++++++++++ docs/user/cli/reference.md | 5 +- 5 files changed, 164 insertions(+), 27 deletions(-) diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 4819ac98..dc11c128 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -16,7 +16,7 @@ pub(crate) const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN"; COMMANDS BY CAPABILITY:\n \ any — run against a graph, served (--server / --profile) or embedded (--store / a \ URI): query, mutate, load, branch, snapshot, export, commit, schema show/apply.\n \ -served — require a server: graphs.\n \ +served — require a server (registry scope, --server/--profile only): graphs.\n \ direct — direct storage access; reject --server (init, optimize, repair, cleanup, \ schema plan, lint).\n \ control — manage or inspect a cluster (cluster via --config; policy & queries via \ @@ -463,17 +463,15 @@ pub(crate) enum ClusterCommand { /// Operations on the graph registry of a multi-graph server (MR-668). /// -/// All operations target a remote multi-graph server URL (http:// or -/// https://). Local-URI invocations return a clear error. To add or -/// remove graphs, operators edit `omnigraph.yaml` directly and restart -/// the server — runtime mutation is not exposed in v0.6.0. +/// Registry scope (RFC-011): these address the server itself, not a graph +/// within it — `--server ` / `--profile ` apply, while +/// `--graph`, `--store`, and `--as` are rejected by the addressing guard. +/// To add or remove graphs, operators run `cluster apply` and restart the +/// server — runtime mutation is not exposed. #[derive(Debug, Subcommand)] pub(crate) enum GraphsCommand { /// List every graph registered with the multi-graph server. List { - /// Remote server URL (e.g. `https://server.example.com`). - #[arg(long)] - uri: Option, #[arg(long)] json: bool, }, diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 6d03553c..dce803eb 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -124,6 +124,48 @@ impl GraphClient { }) } + /// Served-REGISTRY factory (RFC-011): resolve a server scope (`--server` + /// / `--profile` / `defaults.server`) to the bare server base URL for + /// `graphs list`. Synchronous by design: the RFC-011 D7 multi-graph probe + /// (`require_graph_for_multi_graph_server`) is async, so it structurally + /// cannot run on this path — `graphs list` IS the enumeration the probe + /// performs. There is no graph selection and no `/graphs/` append; a + /// scope's `default_graph` is deliberately ignored (rejecting a config + /// default would make `graphs list` unusable in any profile that sets + /// one, and the registry is server-scoped either way). An explicit + /// `--graph` never reaches here — the addressing guard rejects it. + pub(crate) fn resolve_registry( + server: Option<&str>, + profile: Option<&str>, + ) -> Result { + let scope = crate::scope::resolve_scope( + &crate::operator::load_operator_config()?, + crate::planes::Capability::Served, + crate::scope::ScopeFlags { + profile, + store: None, + server, + cluster: None, + graph: None, + uri: None, + }, + )?; + let Some(server) = scope.server.as_deref() else { + bail!( + "`graphs list` needs a server scope — pass --server or \ + --profile , or set `defaults.server` in ~/.omnigraph/config.yaml" + ); + }; + let client = Self::registry_client(server)?; + if !is_remote_uri(client.uri()) { + bail!( + "a server scope resolves to an http(s):// URL; `{}` is not one", + client.uri() + ); + } + Ok(client) + } + /// Resolve the addressing (positional URI / `--target` / `--server`) /// and credential once, then pick the variant by URI scheme — the /// single branch point that replaces every per-command `is_remote` @@ -821,11 +863,11 @@ 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. Routing it - /// through the enum still buys the shared `resolve()` addressing/token - /// preamble. + /// `graphs list` — enumerate the graphs a multi-graph server serves + /// (`GET /graphs`). Reached only through registry-addressed clients + /// (`resolve_registry` / the D7 probe's `registry_client`), which always + /// build the Remote variant — the Embedded arm is unreachable by + /// construction and kept as a defensive internal-invariant bail. pub(crate) async fn list_graphs(&self) -> Result { match self { GraphClient::Remote { @@ -843,10 +885,29 @@ impl GraphClient { .await } GraphClient::Embedded { .. } => bail!( - "`omnigraph graphs list` requires a remote multi-graph server \ - (--server ). To enumerate the graphs in a cluster, run \ - `omnigraph cluster status --config `." + "internal error: `graphs list` reached an embedded client — registry \ + addressing always resolves a server" ), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_registry_is_sync_and_yields_the_bare_base_url() { + // Structural proof the RFC-011 D7 multi-graph probe cannot fire on the + // graphs-list path: resolve_registry is synchronous (called here with + // no tokio runtime), while the probe is async and performs GET + // /graphs. Also pins the URL-corruption fix: the bare base URL with + // the trailing slash trimmed and no `/graphs/` segment. A literal + // `://` --server value bypasses the operator server registry, so a + // developer's real config cannot change the outcome. + let client = + GraphClient::resolve_registry(Some("http://server.invalid:9/"), None).unwrap(); + assert_eq!(client.uri(), "http://server.invalid:9"); + assert!(client.is_remote()); + } +} diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 2006c16e..e15e4f96 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -1125,18 +1125,14 @@ async fn main() -> Result<()> { } }, Command::Graphs { command } => match command { - GraphsCommand::List { - uri, - json, - } => { - let client = client::GraphClient::resolve( + GraphsCommand::List { json } => { + // Registry scope (RFC-011): the bare server base URL, resolved + // synchronously — the async D7 require-graph probe cannot run + // here, and no `/graphs/` is ever appended. + let client = client::GraphClient::resolve_registry( cli.server.as_deref(), - cli.graph.as_deref(), - uri, cli.profile.as_deref(), - cli.store.as_deref(), - ) - .await?; + )?; let payload = client.list_graphs().await?; if json { print_json(&payload)?; diff --git a/crates/omnigraph-cli/src/scope.rs b/crates/omnigraph-cli/src/scope.rs index 257907dc..09b7ccb3 100644 --- a/crates/omnigraph-cli/src/scope.rs +++ b/crates/omnigraph-cli/src/scope.rs @@ -191,6 +191,14 @@ fn scope_from_binding( for ad-hoc direct access" ); } + if capability == Capability::Served { + bail!( + "this command requires a server, but {source} resolves a cluster \ + scope; address the server with --server or --profile \ + (to enumerate a cluster's graphs: `omnigraph cluster status \ + --config `)" + ); + } // A cluster value is a config name (resolved against `clusters:`) // or a literal root: an `s3://`/`file://` URI or a local cluster // directory. Only a configured name is rewritten; anything else is @@ -212,6 +220,12 @@ fn scope_from_binding( }) } ScopeBinding::Store(uri) => { + if capability == Capability::Served { + bail!( + "this command requires a server, but {source} resolves a store scope; \ + address the server with --server or --profile " + ); + } if graph.is_some() { bail!( "--graph does not apply to a store scope ({source}): a store is already \ @@ -504,6 +518,71 @@ mod tests { assert!(err.contains("not valid for graph data commands"), "{err}"); } + #[test] + fn store_scope_on_served_verb_errors() { + // The served registry (`graphs list`) is server-scoped: a store-bound + // profile fails with scope-shaped advice, not a late embedded-arm error. + let op = cfg("profiles:\n localdev:\n store: file:///data/dev.omni\n"); + let err = resolve_scope( + &op, + Capability::Served, + ScopeFlags { + profile: Some("localdev"), + ..flags() + }, + ) + .unwrap_err() + .to_string(); + assert!( + err.contains("requires a server") && err.contains("resolves a store scope"), + "{err}" + ); + } + + #[test] + fn cluster_scope_on_served_verb_points_at_cluster_status() { + let op = cfg( + "clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n", + ); + let err = resolve_scope( + &op, + Capability::Served, + ScopeFlags { + profile: Some("admin"), + ..flags() + }, + ) + .unwrap_err() + .to_string(); + assert!( + err.contains("requires a server") + && err.contains("resolves a cluster scope") + && err.contains("cluster status"), + "{err}" + ); + } + + #[test] + fn server_scope_on_served_verb_resolves_with_default_graph_carried() { + // A served-registry caller resolves the server binding like any served + // verb; the profile's default_graph rides along in the scope (the + // registry factory deliberately ignores it — pinned in client.rs). + let op = cfg( + "servers:\n prod:\n url: https://x\nprofiles:\n staging:\n server: prod\n default_graph: kb\n", + ); + let scope = resolve_scope( + &op, + Capability::Served, + ScopeFlags { + profile: Some("staging"), + ..flags() + }, + ) + .unwrap(); + assert_eq!(scope.server.as_deref(), Some("prod")); + assert_eq!(scope.graph.as_deref(), Some("kb")); + } + #[test] fn unknown_profile_is_a_loud_error() { let op = OperatorConfig::default(); diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 988344e1..b20c6e70 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -27,6 +27,7 @@ Top-level command families and subcommands. Graph-targeting commands accept a po | `embed` | offline JSONL embedding pipeline | | `policy validate \| test \| explain` | Cedar tooling against a cluster's applied policies (`--cluster `; `--graph ` picks a graph's bundle when several apply). `test` takes `--tests `; `explain` takes `--actor`/`--action`/`--branch`/`--target-branch` | | `queries list \| validate` | inspect a cluster's applied stored-query registry (`--cluster `; `--graph ` to scope one graph). `list` prints each query's kind (read/mutation), name, typed params, and `[mcp: …]` exposure; a query's `@description`/`@instruction` are shown as indented `description:` / `instruction:` lines when declared (omitted otherwise). `--json` emits `{name, mcp_expose, tool_name, mutation, params}` plus `description`/`instruction` **only when present** — matching the HTTP `GET /queries` catalog ([server.md](../operations/server.md)). `validate` type-checks the registry and exits non-zero on a broken query | +| `graphs list` | enumerate the graphs a multi-graph server serves (`GET /graphs`). Registry scope: addresses the bare server URL via `--server ` / `--profile ` only — `--graph`/`--store`/`--as` are rejected, and a scope's `default_graph` is ignored | | `profile list \| show []` | read-only inspection of `~/.omnigraph/config.yaml` profiles. `list` shows each profile's binding (server/cluster/store) + default graph and marks the `$OMNIGRAPH_PROFILE`-active one; JSON keeps `binding` and adds `scope_kind`, `target`, `valid`, and `error`; `show` resolves one profile's scope (endpoint + default graph), defaulting to the active profile, else the flat operator defaults | | `version` / `-v` | print `omnigraph 0.7.x` | @@ -125,7 +126,9 @@ sticky "current" mode. Inspect what is defined with `omnigraph profile list` and `GET /graphs` lists the graphs and you must pass `--graph ` (the CLI lists the candidates if you omit it). It falls back to the bare URL only when `/graphs` is unavailable: policy-gated, unreachable, or a - non-`omnigraph` endpoint. + non-`omnigraph` endpoint. `graphs list` itself is exempt — it *is* the + enumeration, so it always addresses the bare server URL (a scope's + `default_graph` is ignored there). `--target`, `--cluster-graph`, and the positional-`http(s)://`→remote dispatch have been **removed** (`--graph` is now the one graph selector across server and From 5431bd3c909948c6dd5ba8105ff55799cab7b602 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:59:31 +0000 Subject: [PATCH 06/11] refactor(cli): thread command capability into GraphClient resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve()/resolve_with_policy() take the verb's declared capability (planes::command_capability) instead of hardcoding Capability::Any, so scope resolution and the addressing guard share one classification. Behavior-neutral: every current caller is a data-plane (Any) verb — registry-scoped `graphs list` already resolves via resolve_registry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/src/client.rs | 15 +++++++++++---- crates/omnigraph-cli/src/main.rs | 23 ++++++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index dce803eb..b887e2fd 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -173,6 +173,7 @@ 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( + capability: crate::planes::Capability, server: Option<&str>, graph: Option<&str>, uri: Option, @@ -181,10 +182,14 @@ impl GraphClient { ) -> Result { // RFC-011: a scope (profile / --store / operator defaults) may stand in // for omitted addressing. The explicit branch passes server/graph/uri - // straight through, so existing invocations are unchanged. + // straight through, so existing invocations are unchanged. The caller + // threads its verb's declared capability (planes::command_capability) + // so scope resolution and the addressing guard share one + // classification; every current caller is a data-plane (`Any`) verb — + // registry-scoped `graphs list` uses `resolve_registry` instead. let scope = crate::scope::resolve_scope( &crate::operator::load_operator_config()?, - crate::planes::Capability::Any, + capability, crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri }, )?; require_graph_for_multi_graph_server(&scope).await?; @@ -216,6 +221,7 @@ impl GraphClient { /// resolution order matches the write arms exactly: server flag → /// bearer token → graph. pub(crate) async fn resolve_with_policy( + capability: crate::planes::Capability, server: Option<&str>, graph: Option<&str>, uri: Option, @@ -224,10 +230,11 @@ impl GraphClient { store: Option<&str>, ) -> Result { // RFC-011 scope translation (see `resolve`); explicit addressing passes - // through unchanged. + // through unchanged, and the caller threads its verb's declared + // capability. let scope = crate::scope::resolve_scope( &crate::operator::load_operator_config()?, - crate::planes::Capability::Any, + capability, crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri }, )?; require_graph_for_multi_graph_server(&scope).await?; diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index e15e4f96..10baf455 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -66,10 +66,13 @@ async fn main() -> Result<()> { Cli::from_arg_matches(&matches)? }; let http_client = build_http_client()?; - // RFC-010 Slice 1: reject data-plane addressing flags (--server/--graph) on - // a verb that doesn't live on the data plane, from one declared table — - // before any per-command dispatch. + // RFC-010 Slice 1: reject scope-addressing flags a verb can't consume, + // from one declared flag × capability matrix — before any per-command + // dispatch. planes::guard_addressing(&cli)?; + // The verb's declared capability, threaded into scope resolution so the + // resolver and the guard share one classification (planes.rs). + let capability = planes::command_capability(&cli.command); match cli.command { Command::Login { name, token, json } => { let token = match token { @@ -257,6 +260,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve_with_policy( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -293,6 +297,7 @@ async fn main() -> Result<()> { use `omnigraph load --from --mode ` (ingest defaults: --from main --mode merge)" ); let client = client::GraphClient::resolve_with_policy( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -321,6 +326,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve_with_policy( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -343,6 +349,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -365,6 +372,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve_with_policy( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -390,6 +398,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve_with_policy( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -445,6 +454,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -465,6 +475,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -523,6 +534,7 @@ async fn main() -> Result<()> { allow_data_loss, } => { let client = client::GraphClient::resolve_with_policy( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -570,6 +582,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -631,6 +644,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -659,6 +673,7 @@ async fn main() -> Result<()> { table_keys, } => { let client = client::GraphClient::resolve( + capability, cli.server.as_deref(), cli.graph.as_deref(), uri, @@ -688,6 +703,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve( + capability, cli.server.as_deref(), cli.graph.as_deref(), None, @@ -733,6 +749,7 @@ async fn main() -> Result<()> { json, } => { let client = client::GraphClient::resolve_with_policy( + capability, cli.server.as_deref(), cli.graph.as_deref(), None, From 111cc844d16c6d5519900ad06df7c6f60d2d3d13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 22:31:10 +0000 Subject: [PATCH 07/11] test(cli): pin per-verb store/actor flag consumption (red) Red half of the review-follow-up pair for PR #377: the capability-level matrix still admitted two per-verb silent drops. - init_with_store_flag_errors_instead_of_ignoring_it: `init` addresses its target positionally and never reads --store; today the guard admits the flag and init succeeds on the positional URI while silently ignoring the store address. - queries_list_with_as_flag_errors: only `cluster apply`/`approve` read the actor among control verbs; today `queries list --as` silently drops the identity and fails later asking for a cluster. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/tests/cli_queries.rs | 22 ++++++++++++++ .../omnigraph-cli/tests/cli_schema_config.rs | 30 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/omnigraph-cli/tests/cli_queries.rs b/crates/omnigraph-cli/tests/cli_queries.rs index 744e81ab..44cae59d 100644 --- a/crates/omnigraph-cli/tests/cli_queries.rs +++ b/crates/omnigraph-cli/tests/cli_queries.rs @@ -136,6 +136,28 @@ fn queries_list_with_store_flag_errors() { ); } +#[test] +fn queries_list_with_as_flag_errors() { + // Read-only control verbs (`queries`, `policy`, `cluster status`, …) never + // read the actor; only `cluster apply`/`cluster approve` do. `--as` on a + // non-attributing control verb must be a loud guard error, not a silently + // dropped identity (PR #377 review follow-up). + let output = output_failure( + cli() + .arg("--as") + .arg("act-alice") + .arg("queries") + .arg("list"), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("`queries list` is a cluster control command") + && stderr.contains("--as") + && stderr.contains("does not apply"), + "expected the addressing-guard --as rejection; got: {stderr}" + ); +} + #[test] fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() { let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list")); diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index 7d52fee5..5b9c1382 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -550,6 +550,36 @@ fn graphs_subcommand_help_lists_list_only() { ); } +#[test] +fn init_with_store_flag_errors_instead_of_ignoring_it() { + // `init` takes its target as a required positional URI and never reads + // `--store`; passing both must be a loud guard error, not a silently + // ignored second address (PR #377 review follow-up). + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema = fixture("test.pg"); + let output = output_failure( + cli() + .arg("init") + .arg("--schema") + .arg(&schema) + .arg("--store") + .arg("file:///elsewhere/graph.omni") + .arg(&graph), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("`init` is a direct (storage-native) command") + && stderr.contains("--store") + && stderr.contains("does not apply"), + "expected the addressing-guard store rejection on init; got:\n{stderr}" + ); + assert!( + !graph.exists(), + "init must not run when the addressing is rejected" + ); +} + #[test] fn graphs_list_rejects_store_scope() { // `graphs list` is a served-registry command: it enumerates a server's From 663422cde2f339f254d3c5c489a8d449861d10fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 22:32:50 +0000 Subject: [PATCH 08/11] fix(cli): refine store/actor flag consumption per command Green half of the review-follow-up pair. The capability rows for --store and --as gain the same per-command refinement shape as accepts_cluster_addressing: - --store on `direct` excludes `init`, which addresses its target with a required positional URI and never reads the flag; the five maintenance verbs keep consuming it via resolve_maintenance_uri. - --as on `control` is limited to `cluster apply`/`cluster approve`, the only control verbs that attribute an actor; read-only control verbs (status/plan/validate, policy, queries) now reject it loudly. Closes both PR #377 review findings: the guard no longer admits the per-verb silent drops it exists to prevent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/src/planes.rs | 40 +++++++++++++++++++++++++----- docs/user/cli/reference.md | 2 +- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index fe27ac02..a96c8ba0 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -12,7 +12,7 @@ use color_eyre::Result; use color_eyre::eyre::bail; -use crate::cli::{Cli, Command, GraphsCommand, QueriesCommand, SchemaCommand}; +use crate::cli::{Cli, ClusterCommand, Command, GraphsCommand, QueriesCommand, SchemaCommand}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Plane { @@ -156,12 +156,32 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool Direct | Control => cluster_ok, Served | Local => false, }, - ScopeFlag::Store => matches!(capability, Any | Direct), + // `direct` refines per command: the maintenance verbs (optimize/ + // repair/cleanup/schema plan/lint) resolve their target through + // `resolve_maintenance_uri`, which consumes --store; `init` addresses + // its target with a required positional URI and never reads it. + ScopeFlag::Store => match capability { + Any => true, + Direct => !matches!(cmd, Command::Init { .. }), + Served | Control | Local => false, + }, // The actor rides direct-engine (`any` via --store) and cluster // writes; served writes resolve the actor from the bearer token // (rejected downstream with its own message), and `direct` - // maintenance verbs record no actor. - ScopeFlag::As => matches!(capability, Any | Control), + // maintenance verbs record no actor. `control` refines per command: + // only `cluster apply`/`cluster approve` attribute an actor — the + // read-only control verbs (status/plan/validate, policy, queries) + // never read it. + ScopeFlag::As => match capability { + Any => true, + Control => matches!( + cmd, + Command::Cluster { + command: ClusterCommand::Apply { .. } | ClusterCommand::Approve { .. }, + } + ), + Served | Direct | Local => false, + }, // A profile is a scope-default bundle; rejecting the flag while the // $OMNIGRAPH_PROFILE env stays honored would be inconsistent. ScopeFlag::Profile => true, @@ -388,13 +408,21 @@ mod tests { (parse(&["omnigraph", "query", "q"]), [true, false, true, true, true, true]), (parse(&["omnigraph", "graphs", "list"]), [true, false, false, false, false, true]), (parse(&["omnigraph", "optimize", "g.omni"]), [false, true, true, true, false, true]), + // `init` addresses its target positionally — --store is rejected, + // not silently ignored (unlike the other direct verbs). ( parse(&["omnigraph", "init", "--schema", "s.pg", "g.omni"]), - [false, false, false, true, false, true], + [false, false, false, false, false, true], ), - (parse(&["omnigraph", "queries", "list"]), [false, true, true, false, true, true]), + // Read-only control verbs never read the actor; only + // `cluster apply`/`approve` do. + (parse(&["omnigraph", "queries", "list"]), [false, true, true, false, false, true]), ( parse(&["omnigraph", "cluster", "status", "--config", "."]), + [false, false, false, false, false, true], + ), + ( + parse(&["omnigraph", "cluster", "apply", "--config", "."]), [false, false, false, false, true, true], ), (parse(&["omnigraph", "version"]), [false, false, false, false, false, true]), diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index b20c6e70..0fcda51d 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -43,7 +43,7 @@ Every command declares the **capability** it needs — what it requires to reach These restrictions are enforced and reported, not silent: -- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, `--graph` where no multi-graph scope applies, `--store` outside the graph-addressed (`any`/`direct`) verbs, or `--as` outside the verbs that record an actor (`any`/`control`), e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` +- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, `--graph` where no multi-graph scope applies, `--store` outside the verbs that consume it (`any`, and the `direct` maintenance verbs — `init` addresses its target positionally, so it rejects `--store`), or `--as` outside the verbs that record an actor (`any`, and `cluster apply`/`cluster approve`), e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` - A `direct` verb pointed at a remote URI fails loudly, e.g.: ``optimize is a direct (storage-native) command and needs direct storage access; the resolved target is a remote server (https://…). Pass the graph's file:// or s3:// URI.`` - A data verb pointed at a positional `http(s)://` URI fails loudly: ``a remote graph must be addressed with --server — a positional (or --uri) http(s):// URL no longer dispatches to a server.`` - `init` into an **established cluster's** storage layout (`/graphs/.omni` where `` holds `__cluster/state.json`) is refused — graphs in a cluster are created by `cluster apply` (which records ledger / recovery / approvals), not `init`. From 362a6fbfd08de3830790c0b08a69e4dab8d2d891 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 00:23:02 +0000 Subject: [PATCH 09/11] chore: remove internal tracker references from user-facing text Audience-neutrality pass over rendered surfaces (docs rule: prefer stable public identifiers over organization-specific labels): - CLI --help text (clap doc comments): drop RFC-011/MR-668/MR-981 parentheticals from flag and command help. - omnigraph-server boot error: "removed in RFC-011" -> "has been removed". - OpenAPI document: drop MR-668 from the graphs-list summary and RFC-011/RFC-022 from schema descriptions (openapi.json regenerated). - Engine duplicate-key error: drop the "see MR-957" tail; the in-source test now pins the precondition wording instead of the tracker id. IETF citations (RFC 9745/8288) and code comments are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-api-types/src/lib.rs | 4 ++-- crates/omnigraph-cli/src/cli.rs | 21 ++++++++++----------- crates/omnigraph-server/src/handlers.rs | 2 +- crates/omnigraph-server/src/settings.rs | 2 +- crates/omnigraph/src/table_store.rs | 7 +++---- openapi.json | 6 +++--- 6 files changed, 20 insertions(+), 22 deletions(-) diff --git a/crates/omnigraph-api-types/src/lib.rs b/crates/omnigraph-api-types/src/lib.rs index 60b82917..bba7ec8c 100644 --- a/crates/omnigraph-api-types/src/lib.rs +++ b/crates/omnigraph-api-types/src/lib.rs @@ -344,7 +344,7 @@ pub struct InvokeStoredQueryRequest { /// mutation). Mutually exclusive with `branch`. #[serde(default)] pub snapshot: Option, - /// The kind the caller expects (RFC-011 Decision 3): `Some(false)` for + /// The kind the caller expects: `Some(false)` for /// `omnigraph query `, `Some(true)` for `omnigraph mutate `. /// When set and it disagrees with the stored query's actual kind, the /// server rejects the call (400) so the verb asserts the kind. `None` @@ -593,7 +593,7 @@ pub struct ManifestConflictOutput { pub actual: u64, } -/// Structured authority mismatch for an RFC-022 prepared write. Values are +/// Structured authority mismatch for a prepared write. Values are /// strings because members include optional graph commit ids and future /// authority tokens, not only numeric table versions. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index dc11c128..d1f88da4 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -44,7 +44,7 @@ pub(crate) struct Cli { #[arg(long, global = true, value_name = "GRAPH_ID")] pub(crate) graph: Option, - /// Select a named scope bundle (RFC-011) from `profiles:` in + /// Select a named scope bundle from `profiles:` in /// ~/.omnigraph/config.yaml: fills in this command's omitted addressing /// (server/cluster/store + default graph). Falls back to /// $OMNIGRAPH_PROFILE. Config data, not state — every command resolves @@ -52,13 +52,13 @@ pub(crate) struct Cli { #[arg(long, global = true, value_name = "NAME")] pub(crate) profile: Option, - /// Address a single graph's storage directly (RFC-011): a `file://` / + /// Address a single graph's storage directly: a `file://` / /// `s3://` store URI. Explicit, ad-hoc direct access — bypasses any /// server. Exclusive with a positional URI / `--server`. #[arg(long, global = true, value_name = "URI")] pub(crate) store: Option, - /// Address a cluster-managed graph's storage for maintenance (RFC-011): + /// Address a cluster-managed graph's storage for maintenance: /// a cluster directory or storage-root URI — named via `clusters:` in /// ~/.omnigraph/config.yaml, or a literal `file://`/`s3://` root. Pair /// with `--graph ` to select the graph. Used by optimize / repair / @@ -67,14 +67,14 @@ pub(crate) struct Cli { pub(crate) cluster: Option, /// Skip the confirmation prompt for a destructive write (`cleanup`, - /// overwrite `load`, `branch delete`) against a non-local scope (RFC-011 - /// Decision 9). Without it, a non-local destructive write prompts on a TTY + /// overwrite `load`, `branch delete`) against a non-local scope. + /// Without it, a non-local destructive write prompts on a TTY /// and refuses (errors) when there is no TTY or `--json` is set. #[arg(long, global = true)] pub(crate) yes: bool, /// Suppress the one-line resolved-write-target diagnostic that write - /// commands echo to stderr (RFC-011 Decision 9). + /// commands echo to stderr. #[arg(long, global = true)] pub(crate) quiet: bool, @@ -137,7 +137,7 @@ pub(crate) enum Command { #[arg(long)] json: bool, }, - /// Invoke an operator alias (RFC-011 Decision 4). + /// Invoke an operator alias. /// /// An alias is a personal binding under `aliases:` in /// ~/.omnigraph/config.yaml — name → (server, graph, stored-query name, @@ -230,7 +230,7 @@ pub(crate) enum Command { #[command(subcommand)] command: SchemaCommand, }, - /// Manage graphs on a multi-graph server (MR-668) + /// Manage graphs on a multi-graph server Graphs { #[command(subcommand)] command: GraphsCommand, @@ -246,7 +246,7 @@ pub(crate) enum Command { /// Overwrite existing schema artifacts at the URI. Without /// this flag, init refuses to touch a URI that already holds /// `_schema.pg`, `_schema.ir.json`, or `__schema_state.json` - /// — closes the re-init footgun (MR-668 follow-up). With the + /// — closes the re-init footgun. With the /// flag, the operator opts in to destructive semantics. #[arg(long)] force: bool, @@ -300,8 +300,7 @@ pub(crate) enum Command { /// shim that prints a one-line stderr warning and rewrites to /// `omnigraph lint`. Aliases are deliberately *not* exposed via /// clap's `visible_alias` because that would advertise two - /// equivalent canonical names, which agents emit interchangeably - /// (see MR-981). + /// equivalent canonical names, which agents emit interchangeably. Lint { /// Graph URI uri: Option, diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 3b607666..9ba24e92 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -40,7 +40,7 @@ pub(crate) async fn server_health() -> Json { ), security(("bearer_token" = [])), )] -/// List every graph currently registered with this server (MR-668). +/// List every graph currently registered with this server. /// /// Multi-graph mode only. In single mode, the route returns 405 — there's /// no registry to enumerate. Cedar-gated by the server-level policy via diff --git a/crates/omnigraph-server/src/settings.rs b/crates/omnigraph-server/src/settings.rs index ae282050..c2e9947f 100644 --- a/crates/omnigraph-server/src/settings.rs +++ b/crates/omnigraph-server/src/settings.rs @@ -217,7 +217,7 @@ pub async fn load_server_settings( "omnigraph-server boots from a cluster: pass --cluster \ (the cluster's applied revision is the deployment artifact). The legacy \ single-graph boot (positional , --target, --config omnigraph.yaml) \ - was removed in RFC-011." + has been removed." ); }; load_cluster_settings( diff --git a/crates/omnigraph/src/table_store.rs b/crates/omnigraph/src/table_store.rs index ba3f05be..b3e042a6 100644 --- a/crates/omnigraph/src/table_store.rs +++ b/crates/omnigraph/src/table_store.rs @@ -4619,8 +4619,7 @@ fn check_batch_unique_by_keys( if !seen.insert(v) { return Err(OmniError::manifest(format!( "{}: duplicate source row for key '{}' (column '{}'); \ - callers must hand in a batch unique by `key_columns` \ - — see MR-957", + callers must hand in a batch unique by `key_columns`", context, v, key_col_name ))); } @@ -4656,8 +4655,8 @@ mod tests { "unexpected error: {msg}" ); assert!( - msg.contains("MR-957"), - "error should reference MR-957: {msg}" + msg.contains("unique by `key_columns`"), + "error should state the unique-batch precondition: {msg}" ); } diff --git a/openapi.json b/openapi.json index ae6eb7cf..ef9f30b9 100644 --- a/openapi.json +++ b/openapi.json @@ -15,7 +15,7 @@ "tags": [ "management" ], - "summary": "List every graph currently registered with this server (MR-668).", + "summary": "List every graph currently registered with this server.", "description": "Multi-graph mode only. In single mode, the route returns 405 — there's\nno registry to enumerate. Cedar-gated by the server-level policy via\nthe `graph_list` action against `Omnigraph::Server::\"root\"`.\n\nOrder: alphabetical by `graph_id` (server-sorted so clients see\ndeterministic output across requests).", "operationId": "listGraphs", "responses": { @@ -2323,7 +2323,7 @@ "boolean", "null" ], - "description": "The kind the caller expects (RFC-011 Decision 3): `Some(false)` for\n`omnigraph query `, `Some(true)` for `omnigraph mutate `.\nWhen set and it disagrees with the stored query's actual kind, the\nserver rejects the call (400) so the verb asserts the kind. `None`\n(the default) skips the check — preserving older clients and aliases." + "description": "The kind the caller expects: `Some(false)` for\n`omnigraph query `, `Some(true)` for `omnigraph mutate `.\nWhen set and it disagrees with the stored query's actual kind, the\nserver rejects the call (400) so the verb asserts the kind. `None`\n(the default) skips the check — preserving older clients and aliases." }, "params": { "description": "JSON object whose keys match the stored query's declared parameters." @@ -2655,7 +2655,7 @@ }, "ReadSetConflictOutput": { "type": "object", - "description": "Structured authority mismatch for an RFC-022 prepared write. Values are\nstrings because members include optional graph commit ids and future\nauthority tokens, not only numeric table versions.", + "description": "Structured authority mismatch for a prepared write. Values are\nstrings because members include optional graph commit ids and future\nauthority tokens, not only numeric table versions.", "required": [ "member" ], From a7c16f8c167dcd6affaeb701d09eaac378b30413 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 00:26:38 +0000 Subject: [PATCH 10/11] test(cli): pin per-verb profile flag consumption (red) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init never resolves a scope, so an explicit --profile (which may carry a store binding) is silently discarded today — the same two-address ambiguity the init --store rejection closed. Red half of the pair; predicted symptom: init succeeds on the positional URI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- .../omnigraph-cli/tests/cli_schema_config.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index 5b9c1382..e7147269 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -580,6 +580,38 @@ fn init_with_store_flag_errors_instead_of_ignoring_it() { ); } +#[test] +fn init_with_profile_flag_errors_instead_of_ignoring_it() { + // `init` never resolves a scope, so an explicit `--profile` (which may + // carry a store binding) would be silently discarded — the same + // two-address ambiguity as `init --store` (PR #377 review follow-up). + // The ambient $OMNIGRAPH_PROFILE default remains ignored, matching the + // explicit-flag-vs-config-default rule on the registry path. + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema = fixture("test.pg"); + let output = output_failure( + cli() + .arg("init") + .arg("--schema") + .arg(&schema) + .arg("--profile") + .arg("localdev") + .arg(&graph), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("`init` is a direct (storage-native) command") + && stderr.contains("--profile") + && stderr.contains("does not apply"), + "expected the addressing-guard profile rejection on init; got:\n{stderr}" + ); + assert!( + !graph.exists(), + "init must not run when the addressing is rejected" + ); +} + #[test] fn graphs_list_rejects_store_scope() { // `graphs list` is a served-registry command: it enumerates a server's From 7d3a09557ec07ce7fb49f01e17a065c618418ca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 00:28:07 +0000 Subject: [PATCH 11/11] fix(cli): reject --profile on verbs that never resolve a scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green half of the pair. The Profile row joins the per-command refinement pattern: a profile is consumed wherever scope resolution runs (data/served resolvers, resolve_maintenance_uri, require_cluster_scope), so `init` (positional target only), the `cluster` family (--config), and local verbs now reject an explicit --profile instead of silently discarding it — including any store binding it carries. The ambient $OMNIGRAPH_PROFILE default remains ignored by those verbs (config default vs explicit intent, the same rule as default_graph on the registry path). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fAXpLbhodrJMydMytJZEB --- crates/omnigraph-cli/src/planes.rs | 37 ++++++++++++++++++++---------- docs/user/cli/reference.md | 2 +- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index a96c8ba0..a0f0a1c8 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -110,8 +110,7 @@ impl ScopeFlag { } /// What the flag means, for the wrong-address error ("{clause} and does - /// not apply"). `Profile` is never rejected (it is a scope default that - /// applies everywhere), so its clause is unreachable in practice. + /// not apply"). fn rejection_clause(self) -> &'static str { match self { ScopeFlag::Server => "--server addresses a served graph", @@ -182,9 +181,21 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool ), Served | Direct | Local => false, }, - // A profile is a scope-default bundle; rejecting the flag while the - // $OMNIGRAPH_PROFILE env stays honored would be inconsistent. - ScopeFlag::Profile => true, + // A profile is consumed wherever scope resolution runs: the data/ + // served resolvers, the maintenance-URI resolver, and the policy/ + // queries cluster-scope resolver. `init` (positional target only), + // the `cluster` family (`--config`), and local verbs never resolve a + // scope, so an explicit --profile there would be silently discarded — + // including any store binding it carries. The ambient + // $OMNIGRAPH_PROFILE default remains ignored by those verbs (config + // default vs explicit intent — the same rule as `default_graph` on + // the registry path). + ScopeFlag::Profile => match capability { + Any | Served => true, + Direct => !matches!(cmd, Command::Init { .. }), + Control => !matches!(cmd, Command::Cluster { .. }), + Local => false, + }, } } @@ -408,24 +419,26 @@ mod tests { (parse(&["omnigraph", "query", "q"]), [true, false, true, true, true, true]), (parse(&["omnigraph", "graphs", "list"]), [true, false, false, false, false, true]), (parse(&["omnigraph", "optimize", "g.omni"]), [false, true, true, true, false, true]), - // `init` addresses its target positionally — --store is rejected, - // not silently ignored (unlike the other direct verbs). + // `init` addresses its target positionally and never resolves a + // scope — --store and --profile are rejected, not silently + // ignored (unlike the other direct verbs). ( parse(&["omnigraph", "init", "--schema", "s.pg", "g.omni"]), - [false, false, false, false, false, true], + [false, false, false, false, false, false], ), // Read-only control verbs never read the actor; only - // `cluster apply`/`approve` do. + // `cluster apply`/`approve` do. The `cluster` family addresses + // its config with --config and never resolves a profile scope. (parse(&["omnigraph", "queries", "list"]), [false, true, true, false, false, true]), ( parse(&["omnigraph", "cluster", "status", "--config", "."]), - [false, false, false, false, false, true], + [false, false, false, false, false, false], ), ( parse(&["omnigraph", "cluster", "apply", "--config", "."]), - [false, false, false, false, true, true], + [false, false, false, false, true, false], ), - (parse(&["omnigraph", "version"]), [false, false, false, false, false, true]), + (parse(&["omnigraph", "version"]), [false, false, false, false, false, false]), ]; for (cmd, expected) in &rows { let capability = command_capability(cmd); diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 0fcda51d..4c69d42e 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -43,7 +43,7 @@ Every command declares the **capability** it needs — what it requires to reach These restrictions are enforced and reported, not silent: -- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, `--graph` where no multi-graph scope applies, `--store` outside the verbs that consume it (`any`, and the `direct` maintenance verbs — `init` addresses its target positionally, so it rejects `--store`), or `--as` outside the verbs that record an actor (`any`, and `cluster apply`/`cluster approve`), e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` +- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, `--graph` where no multi-graph scope applies, `--store` outside the verbs that consume it (`any`, and the `direct` maintenance verbs — `init` addresses its target positionally, so it rejects `--store`), `--as` outside the verbs that record an actor (`any`, and `cluster apply`/`cluster approve`), or `--profile` on verbs that never resolve a scope (`init`, the `cluster` family, and local verbs — the ambient `$OMNIGRAPH_PROFILE` default is simply ignored there), e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` - A `direct` verb pointed at a remote URI fails loudly, e.g.: ``optimize is a direct (storage-native) command and needs direct storage access; the resolved target is a remote server (https://…). Pass the graph's file:// or s3:// URI.`` - A data verb pointed at a positional `http(s)://` URI fails loudly: ``a remote graph must be addressed with --server — a positional (or --uri) http(s):// URL no longer dispatches to a server.`` - `init` into an **established cluster's** storage layout (`/graphs/.omni` where `` holds `__cluster/state.json`) is refused — graphs in a cluster are created by `cluster apply` (which records ledger / recovery / approvals), not `init`.