omnigraph/crates/omnigraph-cli/src/scope.rs
Andrew Altshuler a4d08a4184
Some checks are pending
CI / Classify Changes (push) Waiting to run
CI / Check AGENTS.md Links (push) Waiting to run
CI / Container Entrypoint (push) Waiting to run
CI / Test Workspace (push) Blocked by required conditions
CI / Test omnigraph-server --features aws (push) Blocked by required conditions
CI / RustFS S3 Integration (push) Blocked by required conditions
Release Edge / Prepare edge release (push) Waiting to run
Release Edge / Build edge omnigraph-linux-x86_64 (push) Blocked by required conditions
Release Edge / Build edge omnigraph-macos-arm64 (push) Blocked by required conditions
Release Edge / Build edge omnigraph-windows-x86_64 (push) Blocked by required conditions
Release Edge / Smoke Windows installer (push) Blocked by required conditions
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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 <noreply@anthropic.com>

* 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 <NAME> and --store <URI>. (--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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 02:37:55 +03:00

321 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String>,
pub(crate) graph: Option<String>,
pub(crate) uri: Option<String>,
pub(crate) target: Option<String>,
pub(crate) cluster: Option<String>,
pub(crate) cluster_graph: Option<String>,
}
/// 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<String>,
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<ResolvedScope> {
// 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<String>,
source: &str,
) -> Result<ResolvedScope> {
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 <uri> (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 <uri> 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());
}
}