From a4d08a41847ecceb1aaed632983ac94457e384cc Mon Sep 17 00:00:00 2001 From: Andrew Altshuler Date: Mon, 15 Jun 2026 02:37:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20RFC-011=20Slice=20A=20=E2=80=94=20?= =?UTF-8?q?additive=20scope/profile=20addressing=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): RFC-011 Slice A — operator-config scope structs (profiles/clusters/defaults) Additive operator-config surface for the RFC-011 scope model. No behavior change yet — these structs are parsed but not consumed until the scope resolver lands. - OperatorConfig gains `profiles:` (name → OperatorProfile) and `clusters:` (name → OperatorCluster { root }) — the latter the only place a storage root appears in operator config (RFC-011 storage-root rule). - OperatorDefaults gains `server` and `default_graph` (the flat-default scope). - OperatorProfile binds one of {server, cluster, store} + default_graph; `binding()` validates exactly-one on use and returns a ScopeBinding. - Accessors profile()/cluster_root()/default_server()/default_graph(); unknown-key warnings extended to the new blocks (forward-compat preserved — old configs still load, new keys are no longer "unknown"). Tests: parse profiles/clusters/scope-defaults, binding rejects zero/multiple entities, unknown keys in a profile warn. Co-Authored-By: Claude Opus 4.8 * feat(cli): RFC-011 Slice A — scope resolver + --profile/--store, wired (additive) Translate the new scope inputs into the existing addressing tuple, in front of the unchanged resolvers. Purely additive: an explicit address (--uri/--target/--server/--store) passes straight through, so every existing invocation is byte-for-byte unchanged. - scope.rs: resolve_scope() with the RFC-011 precedence (explicit > --profile / OMNIGRAPH_PROFILE > flat defaults.server), producing the effective (server, graph, uri, target) for data verbs and (cluster, cluster_graph) for maintenance. Plane×scope capability check (server scope rejected on a maintenance verb; cluster scope rejected on a data verb; store rejects --graph) fires only on the new paths. 9 unit tests. - cli.rs: global --profile and --store . (--graph keeps requires=server for now; profile/default graph comes from default_graph — profile+--graph override is deferred to the --cluster-graph rework.) - client.rs: the two GraphClient factories call resolve_scope (Plane::Data) up front; the explicit branch reproduces today's behavior exactly. - main.rs: the 15 data call sites forward --profile/--store; the 3 maintenance verbs consult the scope (Plane::Storage) only when no explicit per-command address is given, so cluster-binding profiles and --store reach optimize/repair/cleanup. Verified: the full omnigraph-cli suite (221 tests) stays green untouched. Co-Authored-By: Claude Opus 4.8 * test+docs(cli): RFC-011 Slice A — end-to-end scope test + reference docs - cli_data.rs: prove --store and a --profile store binding drive a read identically to the legacy positional URI (the additive-coexistence contract), end to end against a local graph (no server needed). - cli/reference.md: document profiles/clusters/defaults.server/default_graph, the --profile/--store flags, and a "Scopes & profiles" section; note the model coexists with legacy addressing (nothing removed yet). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- crates/omnigraph-cli/src/cli.rs | 14 ++ crates/omnigraph-cli/src/client.rs | 31 +++ crates/omnigraph-cli/src/main.rs | 161 ++++++++++--- crates/omnigraph-cli/src/operator.rs | 192 +++++++++++++++ crates/omnigraph-cli/src/scope.rs | 321 +++++++++++++++++++++++++ crates/omnigraph-cli/tests/cli_data.rs | 54 +++++ docs/user/cli/reference.md | 32 ++- 7 files changed, 777 insertions(+), 28 deletions(-) create mode 100644 crates/omnigraph-cli/src/scope.rs diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 28010d2..53f6026 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -41,6 +41,20 @@ pub(crate) struct Cli { #[arg(long, global = true, value_name = "GRAPH_ID", requires = "server")] pub(crate) graph: Option, + /// Select a named scope bundle (RFC-011) 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 + /// scope fresh. + #[arg(long, global = true, value_name = "NAME")] + pub(crate) profile: Option, + + /// Address a single graph's storage directly (RFC-011): a `file://` / + /// `s3://` store URI. Explicit, ad-hoc direct access — bypasses any + /// server. Exclusive with a positional URI / `--target` / `--server`. + #[arg(long, global = true, value_name = "URI")] + pub(crate) store: Option, + #[command(subcommand)] pub(crate) command: Command, } diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 4faaa11..ca09f88 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -79,7 +79,23 @@ impl GraphClient { graph: Option<&str>, uri: Option, target: Option<&str>, + profile: Option<&str>, + store: Option<&str>, ) -> Result { + // RFC-011: a scope (profile / --store / operator defaults) may stand in + // for omitted addressing. The explicit branch passes server/graph/uri/ + // target straight through, so existing invocations are unchanged. + let scope = crate::scope::resolve_scope( + &crate::operator::load_operator_config()?, + crate::planes::Plane::Data, + crate::scope::ScopeFlags { profile, store, server, graph, uri, target }, + )?; + let (server, graph, uri, target) = ( + scope.server.as_deref(), + scope.graph.as_deref(), + scope.uri, + scope.target.as_deref(), + ); let uri = apply_server_flag(server, graph, uri, target)?; let token = resolve_remote_bearer_token(config, uri.as_deref(), target)?; let uri = crate::helpers::resolve_uri(config, uri, target)?; @@ -111,7 +127,22 @@ impl GraphClient { uri: Option, target: Option<&str>, cli_as: Option<&str>, + profile: Option<&str>, + store: Option<&str>, ) -> Result { + // RFC-011 scope translation (see `resolve`); explicit addressing passes + // through unchanged. + let scope = crate::scope::resolve_scope( + &crate::operator::load_operator_config()?, + crate::planes::Plane::Data, + crate::scope::ScopeFlags { profile, store, server, graph, uri, target }, + )?; + let (server, graph, uri, target) = ( + scope.server.as_deref(), + scope.graph.as_deref(), + scope.uri, + scope.target.as_deref(), + ); let uri = apply_server_flag(server, graph, uri, target)?; let token = resolve_remote_bearer_token(config, uri.as_deref(), target)?; let resolved = resolve_cli_graph(config, uri, target)?; diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index c3a67d4..e7cf9bd 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -49,6 +49,7 @@ mod cli; mod client; mod helpers; mod output; +mod scope; mod planes; use cli::*; use helpers::*; @@ -185,6 +186,8 @@ async fn main() -> Result<()> { uri, target.as_deref(), cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let branch = resolve_branch(&config, branch, None, "main"); let payload = client @@ -219,6 +222,8 @@ async fn main() -> Result<()> { uri, target.as_deref(), cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let branch = resolve_branch(&config, branch, None, "main"); let from = resolve_branch(&config, from, None, "main"); @@ -248,6 +253,8 @@ async fn main() -> Result<()> { uri, target.as_deref(), cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let from = resolve_branch(&config, from, None, "main"); let payload = client.branch_create_from(&from, &name).await?; @@ -270,6 +277,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let payload = client.branch_list().await?; if json { @@ -295,6 +304,8 @@ async fn main() -> Result<()> { uri, target.as_deref(), cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let payload = client.branch_delete(&name).await?; if json { @@ -319,6 +330,8 @@ async fn main() -> Result<()> { uri, target.as_deref(), cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let into = resolve_branch(&config, into, None, "main"); let payload = client.branch_merge(&source, &into).await?; @@ -349,6 +362,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let payload = client.list_commits(branch.as_deref()).await?; if json { @@ -371,6 +386,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let commit = client.get_commit(&commit_id).await?; if json { @@ -427,6 +444,8 @@ async fn main() -> Result<()> { uri, target.as_deref(), cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let schema_source = fs::read_to_string(&schema)?; // The stored-query registry check is an embedded-only concern @@ -467,6 +486,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let output = client.schema_source().await?; if json { @@ -521,6 +542,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let branch = resolve_branch(&config, branch, None, "main"); let payload = client.snapshot(&branch).await?; @@ -546,6 +569,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let branch = resolve_branch(&config, branch, None, "main"); if jsonl { @@ -636,6 +661,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target_name, + cli.profile.as_deref(), + cli.store.as_deref(), )?; let query_source = resolve_query_source( &config, @@ -714,6 +741,8 @@ async fn main() -> Result<()> { uri, target_name, cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let query_source = resolve_query_source( &config, @@ -798,15 +827,41 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; - let uri = resolve_storage_uri( - &config, - uri, - target.as_deref(), - cluster.as_deref(), - cluster_graph.as_deref(), - "optimize", - ) - .await?; + let uri = if uri.is_some() || target.is_some() || cluster.is_some() { + resolve_storage_uri( + &config, + uri, + target.as_deref(), + cluster.as_deref(), + cluster_graph.as_deref(), + "optimize", + ) + .await? + } else { + // RFC-011: no explicit per-command address — consult the scope + // (a --profile cluster binding, --store, or operator defaults). + let scope = scope::resolve_scope( + &operator::load_operator_config()?, + planes::Plane::Storage, + scope::ScopeFlags { + profile: cli.profile.as_deref(), + store: cli.store.as_deref(), + server: None, + graph: cli.graph.as_deref(), + uri: None, + target: None, + }, + )?; + resolve_storage_uri( + &config, + scope.uri, + scope.target.as_deref(), + scope.cluster.as_deref(), + scope.cluster_graph.as_deref(), + "optimize", + ) + .await? + }; let db = Omnigraph::open(&uri).await?; let stats = db.optimize().await?; if json { @@ -850,15 +905,40 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; - let uri = resolve_storage_uri( - &config, - uri, - target.as_deref(), - cluster.as_deref(), - cluster_graph.as_deref(), - "repair", - ) - .await?; + let uri = if uri.is_some() || target.is_some() || cluster.is_some() { + resolve_storage_uri( + &config, + uri, + target.as_deref(), + cluster.as_deref(), + cluster_graph.as_deref(), + "repair", + ) + .await? + } else { + // RFC-011: no explicit per-command address — consult the scope. + let scope = scope::resolve_scope( + &operator::load_operator_config()?, + planes::Plane::Storage, + scope::ScopeFlags { + profile: cli.profile.as_deref(), + store: cli.store.as_deref(), + server: None, + graph: cli.graph.as_deref(), + uri: None, + target: None, + }, + )?; + resolve_storage_uri( + &config, + scope.uri, + scope.target.as_deref(), + scope.cluster.as_deref(), + scope.cluster_graph.as_deref(), + "repair", + ) + .await? + }; let db = Omnigraph::open(&uri).await?; let stats = db .repair(omnigraph::db::RepairOptions { confirm, force }) @@ -944,15 +1024,40 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; - let uri = resolve_storage_uri( - &config, - uri, - target.as_deref(), - cluster.as_deref(), - cluster_graph.as_deref(), - "cleanup", - ) - .await?; + let uri = if uri.is_some() || target.is_some() || cluster.is_some() { + resolve_storage_uri( + &config, + uri, + target.as_deref(), + cluster.as_deref(), + cluster_graph.as_deref(), + "cleanup", + ) + .await? + } else { + // RFC-011: no explicit per-command address — consult the scope. + let scope = scope::resolve_scope( + &operator::load_operator_config()?, + planes::Plane::Storage, + scope::ScopeFlags { + profile: cli.profile.as_deref(), + store: cli.store.as_deref(), + server: None, + graph: cli.graph.as_deref(), + uri: None, + target: None, + }, + )?; + resolve_storage_uri( + &config, + scope.uri, + scope.target.as_deref(), + scope.cluster.as_deref(), + scope.cluster_graph.as_deref(), + "cleanup", + ) + .await? + }; let older_than_dur = older_than.as_deref().map(parse_duration_arg).transpose()?; @@ -1088,6 +1193,8 @@ async fn main() -> Result<()> { cli.graph.as_deref(), uri, target.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), )?; let payload = client.list_graphs().await?; if json { diff --git a/crates/omnigraph-cli/src/operator.rs b/crates/omnigraph-cli/src/operator.rs index fb8658d..e48af50 100644 --- a/crates/omnigraph-cli/src/operator.rs +++ b/crates/omnigraph-cli/src/operator.rs @@ -41,6 +41,17 @@ pub(crate) struct OperatorConfig { /// Personal alias bindings (RFC-007 PR 3); see OperatorAlias. #[serde(default)] pub(crate) aliases: BTreeMap, + /// Named scope bundles (RFC-011): each binds exactly one of + /// {server, cluster, store} plus an optional default graph. Config data, + /// not state — selecting one (`--profile`/`OMNIGRAPH_PROFILE`) fills in a + /// command's omitted addressing; it never puts you "in" a mode. + #[serde(default)] + pub(crate) profiles: BTreeMap, + /// Managed-cluster storage roots (RFC-011): name → root URI. The ONLY + /// place a storage root appears in operator config — admin-only and + /// opt-in; a normal operator's file has none. + #[serde(default)] + pub(crate) clusters: BTreeMap, /// Everything this CLI version doesn't know. Warned once at load, /// otherwise ignored (forward compatibility within the operator layer). #[serde(flatten)] @@ -95,10 +106,58 @@ pub(crate) struct OperatorDefaults { /// during the RFC-008 window). pub(crate) table_max_column_width: Option, pub(crate) table_cell_layout: Option, + /// Default server scope (RFC-011): the everyday addressing when no + /// `--profile` / primitive / legacy address is given. Names an entry + /// under `servers:`. + pub(crate) server: Option, + /// Default graph selected within a server/cluster scope when no + /// `--graph` is passed (RFC-011). + pub(crate) default_graph: Option, #[serde(flatten)] unknown: serde_yaml::Mapping, } +/// A named scope bundle (RFC-011): exactly one of {server, cluster, store} +/// plus an optional default graph. Validated on use (`binding()`), not at +/// parse time, so an unknown CLI's profile still loads. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct OperatorProfile { + /// Names an entry under `servers:` — a served scope. + pub(crate) server: Option, + /// Names an entry under `clusters:` — a privileged direct cluster scope. + pub(crate) cluster: Option, + /// A single graph's storage URI — a direct store scope. + pub(crate) store: Option, + /// Default graph within a server/cluster scope (ignored for a store, + /// which is already one graph). + pub(crate) default_graph: Option, + #[serde(flatten)] + unknown: serde_yaml::Mapping, +} + +/// A managed-cluster storage root (RFC-011). +#[derive(Debug, Default, Deserialize)] +pub(crate) struct OperatorCluster { + /// The cluster's storage-root URI (`file://` / `s3://`). + pub(crate) root: String, + #[serde(flatten)] + unknown: serde_yaml::Mapping, +} + +/// The one entity a profile (or flat default) binds. Exactly one variant — +/// the scope resolver consumes this; "exactly one of server/cluster/store" +/// is enforced when producing it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ScopeBinding { + /// Served scope: a server name (resolved against `servers:`) or a literal URL. + Server(String), + /// Direct cluster scope: a cluster name (resolved against `clusters:`) or a + /// literal root URI. + Cluster(String), + /// Direct store scope: a single graph's storage URI. + Store(String), +} + impl OperatorConfig { pub(crate) fn actor(&self) -> Option<&str> { self.operator.actor.as_deref() @@ -127,6 +186,57 @@ impl OperatorConfig { } best.map(|(name, _)| name) } + + /// A named profile, if defined (RFC-011). + pub(crate) fn profile(&self, name: &str) -> Option<&OperatorProfile> { + self.profiles.get(name) + } + + /// The storage root of a named cluster, if defined (RFC-011). + pub(crate) fn cluster_root(&self, name: &str) -> Option<&str> { + self.clusters.get(name).map(|c| c.root.as_str()) + } + + /// The flat-default server scope name, if set (RFC-011). + pub(crate) fn default_server(&self) -> Option<&str> { + self.defaults.server.as_deref() + } + + /// The flat-default graph within a server/cluster scope, if set (RFC-011). + pub(crate) fn default_graph(&self) -> Option<&str> { + self.defaults.default_graph.as_deref() + } +} + +impl OperatorProfile { + /// The single entity this profile binds, or a loud error if it binds zero + /// or more than one of {server, cluster, store} (Decision 6: a scope binds + /// exactly one entity). Validated here, on use, rather than at parse time. + pub(crate) fn binding(&self, profile_name: &str) -> Result { + let set: Vec<&str> = [ + self.server.as_ref().map(|_| "server"), + self.cluster.as_ref().map(|_| "cluster"), + self.store.as_ref().map(|_| "store"), + ] + .into_iter() + .flatten() + .collect(); + match set.as_slice() { + ["server"] => Ok(ScopeBinding::Server(self.server.clone().unwrap())), + ["cluster"] => Ok(ScopeBinding::Cluster(self.cluster.clone().unwrap())), + ["store"] => Ok(ScopeBinding::Store(self.store.clone().unwrap())), + [] => Err(eyre!( + "profile '{profile_name}' binds no scope; set exactly one of \ + `server`, `cluster`, or `store`" + )), + many => Err(eyre!( + "profile '{profile_name}' binds {} scopes ({}); a profile must \ + bind exactly one of `server`, `cluster`, or `store`", + many.len(), + many.join(", ") + )), + } + } } /// The operator dir: `$OMNIGRAPH_HOME` if set (tilde-expanded), else @@ -196,6 +306,12 @@ impl OperatorConfig { for (name, alias) in &self.aliases { collect(&alias.unknown, &format!("aliases.{name}.")); } + for (name, profile) in &self.profiles { + collect(&profile.unknown, &format!("profiles.{name}.")); + } + for (name, cluster) in &self.clusters { + collect(&cluster.unknown, &format!("clusters.{name}.")); + } warnings } } @@ -464,6 +580,82 @@ mod tests { assert_eq!(config.servers["prod"].url, "https://example.com"); } + #[test] + fn parses_profiles_clusters_and_scope_defaults() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + let yaml = "\ +defaults: + server: prod + default_graph: knowledge +servers: + prod: + url: https://example.com +clusters: + brain: + root: s3://acme/clusters/brain +profiles: + staging: + server: staging + default_graph: knowledge + brain-admin: + cluster: brain + default_graph: knowledge +"; + fs::write(&path, yaml).unwrap(); + let config = load_operator_config_at(&path).unwrap(); + assert_eq!(config.default_server(), Some("prod")); + assert_eq!(config.default_graph(), Some("knowledge")); + assert_eq!(config.cluster_root("brain"), Some("s3://acme/clusters/brain")); + assert_eq!( + config.profile("staging").unwrap().binding("staging").unwrap(), + ScopeBinding::Server("staging".into()) + ); + assert_eq!( + config + .profile("brain-admin") + .unwrap() + .binding("brain-admin") + .unwrap(), + ScopeBinding::Cluster("brain".into()) + ); + // No unknown-key warnings for the new blocks. + assert!(config.unknown_key_warnings().is_empty(), "{:?}", config.unknown_key_warnings()); + } + + #[test] + fn profile_binding_rejects_zero_or_multiple_entities() { + let none = OperatorProfile::default(); + let err = none.binding("p").unwrap_err().to_string(); + assert!(err.contains("binds no scope"), "{err}"); + + let two = OperatorProfile { + server: Some("prod".into()), + store: Some("graph.omni".into()), + ..Default::default() + }; + let err = two.binding("p").unwrap_err().to_string(); + assert!(err.contains("binds 2 scopes"), "{err}"); + assert!(err.contains("server") && err.contains("store"), "{err}"); + } + + #[test] + fn unknown_keys_in_a_profile_warn() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + fs::write( + &path, + "profiles:\n p:\n server: prod\n flavour: spicy\n", + ) + .unwrap(); + let config = load_operator_config_at(&path).unwrap(); + let warnings = config.unknown_key_warnings(); + assert!( + warnings.iter().any(|w| w.contains("`profiles.p.flavour`")), + "{warnings:?}" + ); + } + #[test] fn malformed_yaml_is_a_loud_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/omnigraph-cli/src/scope.rs b/crates/omnigraph-cli/src/scope.rs new file mode 100644 index 0000000..19ac48d --- /dev/null +++ b/crates/omnigraph-cli/src/scope.rs @@ -0,0 +1,321 @@ +//! RFC-011 Slice A scope resolution. +//! +//! Translates the new scope inputs (`--profile` / `--store` / operator-config +//! `profiles`/`clusters`/`defaults`) into the SAME effective addressing tuple +//! the existing `GraphClient` factories (`client.rs`) and the maintenance +//! resolver (`helpers::resolve_storage_uri`) already consume. This is a +//! translation layer that sits *in front* of those resolvers — it is purely +//! additive: an explicit legacy address (`--uri`/`--target`/`--server`/ +//! `--store`) wins and reproduces today's behavior exactly, so existing +//! invocations are unaffected. +//! +//! The access path (served vs direct) is never chosen here; it falls out of the +//! scope's binding × the verb's plane. The plane→scope capability check rejects +//! mismatches (e.g. a server scope on a maintenance verb) only on the *new* +//! resolution paths. + +use std::env; + +use color_eyre::Result; +use color_eyre::eyre::{bail, eyre}; + +use crate::operator::{OperatorConfig, ScopeBinding}; +use crate::planes::Plane; + +pub(crate) const PROFILE_ENV: &str = "OMNIGRAPH_PROFILE"; + +/// The effective addressing a command should use, in the terms the existing +/// resolvers consume. Data/served verbs read `server`/`graph`/`uri`/`target`; +/// maintenance verbs read `cluster`/`cluster_graph`. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct ResolvedScope { + pub(crate) server: Option, + pub(crate) graph: Option, + pub(crate) uri: Option, + pub(crate) target: Option, + pub(crate) cluster: Option, + pub(crate) cluster_graph: Option, +} + +/// The raw addressing inputs for one command: the global scope flags plus the +/// command's own positional/`--target` address. +pub(crate) struct ScopeFlags<'a> { + pub(crate) profile: Option<&'a str>, + pub(crate) store: Option<&'a str>, + pub(crate) server: Option<&'a str>, + pub(crate) graph: Option<&'a str>, + pub(crate) uri: Option, + pub(crate) target: Option<&'a str>, +} + +/// Resolve the scope for a command on `plane`. Precedence (RFC-011): +/// 1. explicit legacy/primitive address (`uri`/`target`/`--server`/`--store`) → passthrough; +/// 2. `--profile` / `OMNIGRAPH_PROFILE`; +/// 3. flat `defaults.server` + `defaults.default_graph`; +/// 4. nothing — downstream behaves as today. +pub(crate) fn resolve_scope( + op: &OperatorConfig, + plane: Plane, + flags: ScopeFlags<'_>, +) -> Result { + // 1. Any explicit address wins; reproduce today's behavior untouched. + // `--store` is an explicit store URI — fold it into `uri`. + if flags.uri.is_some() || flags.target.is_some() || flags.server.is_some() || flags.store.is_some() + { + return Ok(ResolvedScope { + server: flags.server.map(str::to_string), + graph: flags.graph.map(str::to_string), + uri: flags.store.map(str::to_string).or(flags.uri), + target: flags.target.map(str::to_string), + ..Default::default() + }); + } + + // 2. A named profile (flag, else env). + let profile_name = flags + .profile + .map(str::to_string) + .or_else(|| env::var(PROFILE_ENV).ok().filter(|s| !s.is_empty())); + if let Some(name) = profile_name { + let profile = op.profile(&name).ok_or_else(|| { + eyre!("unknown profile '{name}' (not defined under `profiles:` in operator config)") + })?; + let binding = profile.binding(&name)?; + let graph = flags + .graph + .map(str::to_string) + .or_else(|| profile.default_graph.clone()); + return scope_from_binding(op, plane, binding, graph, &format!("profile '{name}'")); + } + + // 3. Flat default server scope. + if let Some(server) = op.default_server() { + let graph = flags + .graph + .map(str::to_string) + .or_else(|| op.default_graph().map(str::to_string)); + return scope_from_binding( + op, + plane, + ScopeBinding::Server(server.to_string()), + graph, + "operator defaults", + ); + } + + // 4. Nothing resolved — leave the tuple empty; downstream falls through to + // today's behavior (legacy `cli.graph` default or a no-address error). + Ok(ResolvedScope::default()) +} + +/// Map a resolved binding to the effective tuple, enforcing scope × plane +/// capability (RFC-011): a server scope is served (data only); a cluster scope +/// is privileged direct (maintenance/control only); a store scope is direct +/// (either). +fn scope_from_binding( + op: &OperatorConfig, + plane: Plane, + binding: ScopeBinding, + graph: Option, + source: &str, +) -> Result { + match binding { + ScopeBinding::Server(server) => { + if plane == Plane::Storage { + bail!( + "this command needs direct storage access, but {source} resolves a \ + server scope; name storage explicitly with --store (or a \ + --cluster/--cluster-graph for a managed graph)" + ); + } + Ok(ResolvedScope { + server: Some(server), + graph, + ..Default::default() + }) + } + ScopeBinding::Cluster(cluster) => { + if plane == Plane::Data { + bail!( + "{source} resolves a cluster scope, which is maintenance-only; run \ + data commands through a server, or use --store for ad-hoc \ + direct access" + ); + } + // A cluster binding is a config name (resolved against `clusters:`) + // or a literal root URI. + let root = if let Some(root) = op.cluster_root(&cluster) { + root.to_string() + } else if cluster.contains("://") { + cluster + } else { + bail!( + "unknown cluster '{cluster}' ({source}); define it under `clusters:` \ + in operator config, or use a literal root URI" + ); + }; + Ok(ResolvedScope { + cluster: Some(root), + cluster_graph: graph, + ..Default::default() + }) + } + ScopeBinding::Store(uri) => { + if graph.is_some() { + bail!( + "--graph does not apply to a store scope ({source}): a store is already \ + a single graph" + ); + } + Ok(ResolvedScope { + uri: Some(uri), + ..Default::default() + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(yaml: &str) -> OperatorConfig { + serde_yaml::from_str(yaml).unwrap() + } + + fn flags<'a>() -> ScopeFlags<'a> { + ScopeFlags { + profile: None, + store: None, + server: None, + graph: None, + uri: None, + target: None, + } + } + + #[test] + fn explicit_legacy_address_wins_unchanged() { + let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n"); + // A positional URI given → profile/defaults are ignored entirely. + let scope = resolve_scope( + &op, + Plane::Data, + ScopeFlags { + uri: Some("graph.omni".into()), + ..flags() + }, + ) + .unwrap(); + assert_eq!(scope.uri.as_deref(), Some("graph.omni")); + assert_eq!(scope.server, None); + } + + #[test] + fn store_flag_folds_into_uri_and_rejects_graph() { + let op = OperatorConfig::default(); + let scope = resolve_scope( + &op, + Plane::Data, + ScopeFlags { + store: Some("s3://b/g.omni"), + ..flags() + }, + ) + .unwrap(); + assert_eq!(scope.uri.as_deref(), Some("s3://b/g.omni")); + } + + #[test] + fn flat_default_server_drives_data_verbs() { + let op = cfg("defaults:\n server: prod\n default_graph: knowledge\nservers:\n prod:\n url: https://x\n"); + let scope = resolve_scope(&op, Plane::Data, flags()).unwrap(); + assert_eq!(scope.server.as_deref(), Some("prod")); + assert_eq!(scope.graph.as_deref(), Some("knowledge")); + } + + #[test] + fn profile_server_scope_with_graph_override() { + let op = cfg( + "servers:\n staging:\n url: https://s\nprofiles:\n staging:\n server: staging\n default_graph: knowledge\n", + ); + let scope = resolve_scope( + &op, + Plane::Data, + ScopeFlags { + profile: Some("staging"), + graph: Some("archive"), + ..flags() + }, + ) + .unwrap(); + assert_eq!(scope.server.as_deref(), Some("staging")); + assert_eq!(scope.graph.as_deref(), Some("archive")); // flag beats profile default + } + + #[test] + fn profile_cluster_scope_resolves_root_for_maintenance() { + let op = cfg( + "clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n default_graph: knowledge\n", + ); + let scope = resolve_scope( + &op, + Plane::Storage, + ScopeFlags { + profile: Some("admin"), + ..flags() + }, + ) + .unwrap(); + assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain")); + assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge")); + } + + #[test] + fn server_scope_on_maintenance_verb_errors() { + let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n"); + let err = resolve_scope(&op, Plane::Storage, flags()).unwrap_err().to_string(); + assert!(err.contains("direct storage access"), "{err}"); + } + + #[test] + fn cluster_scope_on_data_verb_errors() { + let op = cfg( + "clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n", + ); + let err = resolve_scope( + &op, + Plane::Data, + ScopeFlags { + profile: Some("admin"), + ..flags() + }, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("maintenance-only"), "{err}"); + } + + #[test] + fn unknown_profile_is_a_loud_error() { + let op = OperatorConfig::default(); + let err = resolve_scope( + &op, + Plane::Data, + ScopeFlags { + profile: Some("nope"), + ..flags() + }, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown profile 'nope'"), "{err}"); + } + + #[test] + fn no_address_resolves_empty_for_legacy_fallthrough() { + let op = OperatorConfig::default(); + let scope = resolve_scope(&op, Plane::Data, flags()).unwrap(); + assert_eq!(scope, ResolvedScope::default()); + } +} diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index 8d1f80a..99a3038 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -3,6 +3,7 @@ use std::fs; +use assert_cmd::Command; use serde_json::Value; use tempfile::tempdir; @@ -801,6 +802,59 @@ fn read_json_outputs_rows_for_named_query() { assert_eq!(payload["rows"][0]["p.name"], "Alice"); } +#[test] +fn read_via_store_flag_and_profile_match_positional_uri() { + // RFC-011 Slice A: the new scope addressing (--store, and a --profile that + // binds a store) drives a read identically to the legacy positional URI — + // the scope layer is additive, not a behavior change. + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + let queries = fixture("test.gq"); + + let read_rows = |cmd: &mut Command| -> Value { + let output = output_success( + cmd.arg("--query") + .arg(&queries) + .arg("--name") + .arg("get_person") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--json"), + ); + serde_json::from_slice(&output.stdout).unwrap() + }; + + // Baseline: positional URI. + let baseline = read_rows(cli().arg("query").arg(&graph)); + assert_eq!(baseline["rows"][0]["p.name"], "Alice"); + + // --store names the same graph directly. + let via_store = read_rows(cli().arg("query").arg("--store").arg(&graph)); + assert_eq!(via_store["rows"], baseline["rows"]); + + // A profile binding that store, selected with --profile (no positional). + let home = temp.path().join("op-home"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write( + home.join("config.yaml"), + format!( + "profiles:\n local:\n store: '{}'\n", + graph.to_string_lossy() + ), + ) + .unwrap(); + let via_profile = read_rows( + cli() + .env("OMNIGRAPH_HOME", &home) + .arg("query") + .arg("--profile") + .arg("local"), + ); + assert_eq!(via_profile["rows"], baseline["rows"]); +} + #[test] fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() { let temp = tempdir().unwrap(); diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 77feaf1..44d5ad4 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -2,7 +2,7 @@ A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` schema. For a quick-start guide, see [cli.md](index.md). -Top-level command families and subcommands. Graph-targeting commands accept a positional `URI`, `--uri`, a `--target ` resolved against `omnigraph.yaml`, or `--server ` (an operator-defined server from `~/.omnigraph/config.yaml`, optionally with `--graph ` for multi-graph servers; exclusive with the other forms); `cluster` commands use `--config `. +Top-level command families and subcommands. Graph-targeting commands accept a positional `URI`, `--uri`, a `--target ` resolved against `omnigraph.yaml`, `--server ` (an operator-defined server from `~/.omnigraph/config.yaml`, optionally with `--graph ` for multi-graph servers; exclusive with the other forms), `--store ` (a single graph's storage directly), or `--profile ` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config `. ## Top-level commands @@ -71,12 +71,42 @@ servers: # operator-owned endpoints; names key the credentials url: https://graph.example.com # no tokens in this file, ever defaults: output: table # read format default, below --json/--format/alias/legacy + server: prod # the everyday scope when no address is given (RFC-011) + default_graph: knowledge # graph selected in a server/cluster scope +clusters: # admin-only: managed-cluster storage roots (RFC-011). + brain: # the ONLY place a storage root lives in this file. + root: s3://acme/clusters/brain +profiles: # named scope bundles (RFC-011); pick with --profile + staging: { server: staging, default_graph: knowledge } # a served scope + brain-admin: { cluster: brain, default_graph: knowledge } # a direct cluster scope ``` Absent file = empty layer. Unknown keys warn and load (a file written for a newer CLI works on an older one). `$OMNIGRAPH_CONFIG=` stands in for `--config` (the flag wins) in both the CLI and the server. +#### Scopes & profiles (RFC-011) + +A command resolves a **scope** — a server, a cluster, or a store — then selects a +graph in it; the served-vs-direct access path is derived from the scope, not +toggled. The scope comes from one of (highest precedence first): an explicit +address (a positional URI, `--target`, `--server`, or `--store `); a named +`--profile ` (or `$OMNIGRAPH_PROFILE`); or the flat `defaults.server` + +`defaults.default_graph`. A **profile** binds exactly one of `server` / `cluster` +/ `store` plus an optional default graph — config data, not state: every command +resolves its scope fresh, there is no sticky "current" mode. + +- `--store ` addresses a single graph's storage directly (ad-hoc / break-glass). +- A `cluster`-bound profile reaches `optimize` / `repair` / `cleanup` for a managed + graph (resolving its storage root from `clusters:`), the same as + `--cluster --cluster-graph `. +- A `server`-bound scope on a maintenance verb, or a `cluster`-bound scope on a + data verb, is rejected with a message pointing at the right addressing. + +This model **coexists** with the legacy addressing (`--uri` / `--target` / +`--cluster-graph` / `omnigraph.yaml`) — nothing is removed yet; an explicit legacy +address always wins. + #### Credentials keyed by server name `omnigraph login ` stores a bearer token in