feat(cli): RFC-011 Slice B — capability vocabulary (any/served/direct/control/local) (#237)

* feat(cli): RFC-011 Slice B — capability vocabulary (any/served/direct/control/local)

User-facing CLI errors and --help now speak a single "capability" vocabulary —
what a command needs — instead of the internal four-plane jargon. Behavior is
unchanged: the --server/--graph allow set is identical (the served-graph
capabilities `any` ∪ `served` = the old `Data` plane, since `graphs` was already
allowed). Only error text and the --help legend change.

- planes.rs: add `Capability { Any, Served, Direct, Control, Local }` derived from
  the existing exhaustive `command_plane` classifier (which stays as the drift
  guard) plus the one Data→Served refinement (`graphs`). `guard_addressing` now
  allows `--server`/`--graph` on `{Any, Served}` and rejects elsewhere with a
  capability-worded message. The mapping reflects *current* behavior (`queries
  list` → Local, `queries validate` → Direct); it converges to the RFC end-state
  table when later slices re-route those verbs.
- scope.rs: `resolve_scope` takes `Capability` instead of `Plane`, so the whole
  addressing path speaks one vocabulary; call sites in client.rs (Any) and the 3
  maintenance verbs in main.rs (Direct) updated.
- helpers.rs: the storage-direct remote rejection reworded to "direct
  (storage-native) command".
- cli.rs: the --help legend is now "COMMANDS BY CAPABILITY".
- Tests: the 5 assertions pinning the old plane text updated; added planes.rs unit
  tests proving the allow set is exactly {Any, Served} (behavior-preservation),
  the per-verb mapping, and distinct capability phrases.

Full omnigraph-cli suite: 225 green (222 + 3 new), zero behavior-test changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(cli): capability vocabulary in the CLI reference + maintenance addressing

Rename the reference's "Command planes" section to "Command capabilities"
(any/served/direct/control/local), reword the error examples, and update the
maintenance doc's addressing note + its section cross-link to match Slice B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Andrew Altshuler 2026-06-15 03:02:07 +03:00 committed by GitHub
parent a4d08a4184
commit 7eeced3e88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 191 additions and 79 deletions

View file

@ -10,7 +10,7 @@
//! 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
//! scope's binding × the verb's capability. The capability→scope check rejects
//! mismatches (e.g. a server scope on a maintenance verb) only on the *new*
//! resolution paths.
@ -20,7 +20,7 @@ use color_eyre::Result;
use color_eyre::eyre::{bail, eyre};
use crate::operator::{OperatorConfig, ScopeBinding};
use crate::planes::Plane;
use crate::planes::Capability;
pub(crate) const PROFILE_ENV: &str = "OMNIGRAPH_PROFILE";
@ -48,14 +48,14 @@ pub(crate) struct ScopeFlags<'a> {
pub(crate) target: Option<&'a str>,
}
/// Resolve the scope for a command on `plane`. Precedence (RFC-011):
/// Resolve the scope for a command with `capability`. 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,
capability: Capability,
flags: ScopeFlags<'_>,
) -> Result<ResolvedScope> {
// 1. Any explicit address wins; reproduce today's behavior untouched.
@ -85,7 +85,7 @@ pub(crate) fn resolve_scope(
.graph
.map(str::to_string)
.or_else(|| profile.default_graph.clone());
return scope_from_binding(op, plane, binding, graph, &format!("profile '{name}'"));
return scope_from_binding(op, capability, binding, graph, &format!("profile '{name}'"));
}
// 3. Flat default server scope.
@ -96,7 +96,7 @@ pub(crate) fn resolve_scope(
.or_else(|| op.default_graph().map(str::to_string));
return scope_from_binding(
op,
plane,
capability,
ScopeBinding::Server(server.to_string()),
graph,
"operator defaults",
@ -108,20 +108,20 @@ pub(crate) fn resolve_scope(
Ok(ResolvedScope::default())
}
/// Map a resolved binding to the effective tuple, enforcing scope × plane
/// Map a resolved binding to the effective tuple, enforcing scope × capability
/// 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,
capability: Capability,
binding: ScopeBinding,
graph: Option<String>,
source: &str,
) -> Result<ResolvedScope> {
match binding {
ScopeBinding::Server(server) => {
if plane == Plane::Storage {
if capability == Capability::Direct {
bail!(
"this command needs direct storage access, but {source} resolves a \
server scope; name storage explicitly with --store <uri> (or a \
@ -135,7 +135,7 @@ fn scope_from_binding(
})
}
ScopeBinding::Cluster(cluster) => {
if plane == Plane::Data {
if capability == Capability::Any {
bail!(
"{source} resolves a cluster scope, which is maintenance-only; run \
data commands through a server, or use --store <uri> for ad-hoc \
@ -200,7 +200,7 @@ mod tests {
// A positional URI given → profile/defaults are ignored entirely.
let scope = resolve_scope(
&op,
Plane::Data,
Capability::Any,
ScopeFlags {
uri: Some("graph.omni".into()),
..flags()
@ -216,7 +216,7 @@ mod tests {
let op = OperatorConfig::default();
let scope = resolve_scope(
&op,
Plane::Data,
Capability::Any,
ScopeFlags {
store: Some("s3://b/g.omni"),
..flags()
@ -229,7 +229,7 @@ mod tests {
#[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();
let scope = resolve_scope(&op, Capability::Any, flags()).unwrap();
assert_eq!(scope.server.as_deref(), Some("prod"));
assert_eq!(scope.graph.as_deref(), Some("knowledge"));
}
@ -241,7 +241,7 @@ mod tests {
);
let scope = resolve_scope(
&op,
Plane::Data,
Capability::Any,
ScopeFlags {
profile: Some("staging"),
graph: Some("archive"),
@ -260,7 +260,7 @@ mod tests {
);
let scope = resolve_scope(
&op,
Plane::Storage,
Capability::Direct,
ScopeFlags {
profile: Some("admin"),
..flags()
@ -274,7 +274,7 @@ mod tests {
#[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();
let err = resolve_scope(&op, Capability::Direct, flags()).unwrap_err().to_string();
assert!(err.contains("direct storage access"), "{err}");
}
@ -285,7 +285,7 @@ mod tests {
);
let err = resolve_scope(
&op,
Plane::Data,
Capability::Any,
ScopeFlags {
profile: Some("admin"),
..flags()
@ -301,7 +301,7 @@ mod tests {
let op = OperatorConfig::default();
let err = resolve_scope(
&op,
Plane::Data,
Capability::Any,
ScopeFlags {
profile: Some("nope"),
..flags()
@ -315,7 +315,7 @@ mod tests {
#[test]
fn no_address_resolves_empty_for_legacy_fallthrough() {
let op = OperatorConfig::default();
let scope = resolve_scope(&op, Plane::Data, flags()).unwrap();
let scope = resolve_scope(&op, Capability::Any, flags()).unwrap();
assert_eq!(scope, ResolvedScope::default());
}
}