* docs(rfc): RFC-011 — CLI refactoring (one addressing & config model) A maintainer-internal RFC (Status: Proposed) for the post-omnigraph.yaml CLI: one ontology (store/server/cluster; cluster vs operator config; catalog; context; capability); addressing = scope + --graph with the access path *derived*; served is the default front door and direct storage is privileged (admin/break-glass); stateless per command; definitions named, payloads passed. Includes the full end-state command taxonomy (by capability), a current-state appendix, migration, invariants check, and the resolved Decisions (with two deferred). Completes the config/CLI lineage RFC-007 → RFC-008 → RFC-009 → RFC-010. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(rfc): RFC-011 — address Greptile review (4 doc fixes) - P1: end-state taxonomy `schema apply` annotation said "Open Q10" — now points at the resolved Decision 10 (cluster graphs via cluster apply). - P1: add the `alias <name>` verb (Decision 4) to the end-state taxonomy's local section — it was claimed "full command set" but omitted. - P2: Decision 11's bulk-data-plane reference now carries the "PR #219, not yet merged" caveat (matches the Relationship section). - P2: footnote now states the `check`→`lint` argv-shim is removed (its end-state disposition was unspecified). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
43 KiB
RFC-011: CLI refactoring — one addressing & config model
Status: Proposed
Date: 2026-06-14
Audience: CLI/server maintainers
Builds on: rfc-007-operator-config.md
(per-operator config, keyed credentials, named servers),
rfc-008-deprecate-omnigraph-yaml.md
(the legacy file this RFC finishes removing),
rfc-009-unify-access-paths.md
(GraphClient — embedded ≡ remote at the execution layer),
rfc-010-cli-planes-restructure.md
(declared planes + the wrong-plane guard this RFC subsumes).
Sequencing: lands as / after RFC-008 stage 5 (the omnigraph.yaml removal).
Summary
Refactor the CLI around one coherent model once omnigraph.yaml is gone. The
shape:
- One ontology (store, server, cluster; cluster config vs operator config; catalog; context; capability) where each term names exactly one concept.
- Addressing = scope +
--graph, with the access path derived. A command resolves a scope (operator defaults, an optional named context, or one explicit primitive address —--store/--server/--cluster), selects a graph inside it with--graph, and the served-vs-direct access path falls out of the scope's bindings × the verb's capability — it is never a per-command toggle and never inferred from a URI scheme. - Served is the front door; direct storage is privileged. The everyday scope is a server (a bearer token, no bucket credentials). Reading or writing a remote store/cluster directly is an explicit, credentialed, admin/break-glass act — never the default, never baked into everyday operator config.
- The CLI is stateless per command. No
current_contextpointer, noUSE-style mode; every command is fully determined by its flags + static config. You select a graph, you do not switch into one. - Definitions are named; payloads are passed. Queries (
.gq) and schema (.pg) live in the catalog and are invoked by name; params and bulk data are the only per-call inputs.
This removes --target, --cluster-graph, --uri scheme-dispatch, and the
plane guard's "a --target that resolves to a remote URL" special case — and it
collapses the four-plane vocabulary, for users, into a single capability rule.
Motivation: the legacy file pollutes the taxonomy
Today the CLI exposes four overlapping addressing forms but the system has only
three real entities; the mismatch is the whole problem, and omnigraph.yaml is
the carrier:
--targetstraddles kinds. It resolves through the legacyomnigraph.yamlgraphs:map (config.rs::resolve_target_uri), and that.urican be a storage location (file/s3) or a remote server (http). One flag, two access paths with different capability and trust models. The wrong-plane guard's storage-plane remote rejection (helpers.rs:467) exists only to compensate for this overload.- Scheme-inferred transport.
<URI>/--urihas the same disease a level down:is_remote_uri(helpers.rs:15) silently picks embedded vs remote from the scheme. Transport is guessed from a string, not declared. - No single environment concept. Defaults are smeared across the deprecated
omnigraph.yaml(cli.graph,server.graph) with no clean way to name or switch environments.
Removing omnigraph.yaml is the moment to fix all three at once.
Ontology
Every term is one concept. The rest of this RFC uses them precisely.
Entities — the things that exist
- Graph — a typed property graph (node/edge types over Lance); the thing you
query and mutate. Example: the
knowledgegraph. - Store — the storage location of a single graph: its Lance datasets at a
file:///s3://URI. Addressed directly with--store. Example:s3://acme/clusters/brain/graphs/knowledge.omni. - Cluster — a storage root holding many graphs plus the catalog and
control-plane state (state ledger, approvals, recovery). Managed as-code by the
team. Example: the
braincluster ats3://acme/clusters/brain. - Server — an
omnigraph-serverprocess serving graphs over HTTP with bearer auth and Cedar policy; boots from a bare graph or a cluster. Example:prodathttps://graph.example.com, serving thebraincluster.
Config & catalog — the descriptions
- Cluster config —
cluster.yamlin the cluster root, declaring the desired state (graphs, schemas, stored queries, policies, storage), applied withcluster apply. Team-owned; the source of truth for what the system is. - Catalog — the applied registry the cluster owns in storage: the graphs,
stored queries, and policies
cluster applymaterialized. What a server serves and whatquery <name>resolves against. (Cluster config is the spec; the catalog is the applied result.) - Operator config —
~/.omnigraph/config.yaml, your personal file: identity (actor), default graph, named servers/clusters, output prefs, optional contexts. Declares who I am, never what the system is. - Context — an optional named bundle of defaults inside the operator
config (one of {cluster, server, store} + a default graph). Config data,
not state: selecting one fills in omitted flags for a command; it does not
put you "in" a mode. Chosen per command (
--context <name>) or per shell (OMNIGRAPH_CONTEXT). - Credential — a bearer token keyed to a server name, resolved via
OMNIGRAPH_TOKEN_<NAME>or~/.omnigraph/credentials(0600); sent only to the server it is keyed to. (Per RFC-007 — the operator config holds endpoints, never tokens.)
What you run — definitions vs payloads
- Schema — the
.pgtype definitions for a graph; authored as a file, applied viaschema apply(orcluster apply). - Stored query — a named query in the catalog, the team's reusable contract;
invoked by name. Example:
find_people. - Query file (
.gq) — an authoring artifact holdingquery <name>declarations; becomes a stored query whencluster applyadopts it. For authoring/ad-hoc, not everyday invocation. - Payload — the per-call inputs that vary each run: params (
--params, positional args) and bulk data (--data). Never part of config.
How a command resolves
- Scope — the resolved environment a command addresses: operator defaults, a named context, or one explicit primitive address.
- Access path — served (through a server) or direct (open storage in-process). Derived from scope × capability; see "Access path" below.
- Capability — what a verb requires:
any,served,direct,control, orlocal. - Target shape — whether the verb is graph-scoped (selects one graph inside the scope), scope-scoped (operates on the whole server/cluster scope), or local (does not resolve scope or graph).
- Actor — the identity a write is attributed to: server-resolved from the
bearer token (served), or
--as??operator.actor(direct).
The relationships that prevent confusion
- Exactly two config surfaces: cluster config (team) and operator config (personal). Nothing else is "a config."
- A context is not a third config — it lives inside the operator config, and it is defaults, not state.
- A catalog is not config — it is the applied state the cluster owns.
- A store is one graph; a cluster is many graphs + catalog + control state.
- A graph is the logical thing; store/server/cluster are ways to reach it.
- "State" elsewhere is not the context: graph state is committed data in Lance; cluster state is the applied control-plane ledger. Neither is operator config.
Design
First principles
Addressing should be 1:1 with the system's real entities; the access path (served vs direct) should be derived, never inferred from a string or toggled per command; the CLI should be terse by config and stateless per command; and definitions are named while payloads are passed.
Every command answers four orthogonal questions — kept orthogonal here:
| Axis | Question | Today | Target |
|---|---|---|---|
| Scope | which environment? | omnigraph.yaml defaults / --target |
operator defaults · --context · one primitive |
| Target shape | whole scope or one graph? | implicit in command family | declared per verb |
| Graph | which graph in it? | tangled into the address | --graph only for graph-scoped server/cluster verbs |
| Access path | served or direct? | inferred from scheme / target | derived from scope × capability |
| Actor | who am I? | --as > cli.actor (yaml) > operator.actor |
--as/operator.actor (direct) · token (served) |
A scope binds one entity — and served is the default
A scope (a context, the flat defaults, or one primitive flag) binds exactly one
of {server, cluster, store}. Server and cluster scopes may contain many graphs
and can carry a default_graph; a store scope is already one graph and does not
accept --graph. They differ by privilege, and the everyday default is a
server:
- server → served (the everyday scope). A bearer token, no storage credentials. Data verbs run through it, policy-enforced; maintenance verbs are unavailable from this scope — there is no server route for them, so you must name storage explicitly. This is what a normal operator's config binds.
- cluster → direct storage to a managed cluster, for control,
maintenance, and graph-backed validation only (
cluster *,optimize/repair/cleanup/schema plan, graph-backedlint, andqueries validate). Data verbs are not run directly against a cluster — they go served, or--storefor ad-hoc. Privileged: requires bucket credentials, so it appears only in a maintainer's config or as an explicit--clusterflag — never in an everyday operator's defaults. - store → one graph's storage, direct. A local file store is ordinary
local dev; a remote
s3://store is break-glass. No catalog (named queries do not resolve — the ad-hoc lane).
A scope names one thing, so there is no independent server+cluster pair
that could disagree (the audit's coherence hazard is gone by construction — the
default is just a server). And the storage root lives only where it must:
Direct storage access is privileged (the storage-root rule)
The storage root (
s3://…) is server-and-admin knowledge, never everyday-operator knowledge. Everyday operator config binds a server (a bearer token, no bucket credentials). Direct remote access — opening a cluster root or ans3://store — is always explicit and privileged: you name--cluster/--store, and only someone with bucket credentials can. The CLI never opens a remote store from a default scope.
This is the least-privilege posture — revoke a bearer token, don't rotate bucket keys; only the server process and an occasional maintenance admin ever hold storage credentials. It makes "use the server, not raw storage" structural, not advisory: direct access requires credentials a normal operator does not have and a flag they must type. The only storage root in an everyday setup is the one the server boots from; operators never see it. (Local file stores for dev are unaffected — a local file is not the production bucket.)
Access path is derived, not chosen
The two access paths are genuinely different — not two transports for one thing:
- Served (through a server): the server resolves your actor from a token and enforces Cedar policy at the HTTP boundary. In cluster mode the catalog and config (graph set, stored queries, policy bundles) are pinned to the applied serving revision and move only on restart; graph data is read through the server's engine handle against the requested branch/snapshot (it is not frozen at boot, though a long-running server will not observe out-of-band direct writes to storage until its handle refreshes). No storage credentials needed.
- Direct (open the Lance storage in-process): a privileged path — it needs
your own storage credentials, so only an admin/maintainer (or a local-dev file
store) takes it. Actor self-declared (
--as??operator.actor), reads live storage HEAD. There is no server-side identity/auth gate — but engine-level Cedar policy is still enforced when the graph selection provides a policy (enforcement is engine-wide; embedded_aswriters call the sameenforce). "Direct" means "no HTTP boundary," not "unpoliced."
Because they differ in authority, freshness, and availability, a graph reached via
a server and that graph's raw storage are different things you name
differently — not one identity you flip. Making the access path a per-command
toggle (--via) is the --target mistake in new clothes; it is rejected.
The access path follows from the scope and the verb. A server scope → served (data/catalog). A cluster scope → direct control, maintenance, and validation. A store scope → direct ad-hoc data (no catalog). The verb's capability picks which applies and rejects the mismatches.
State the bound plainly: the everyday data path
(query/mutate/load/branch/export/commit) against a served graph
never needs direct storage access, and direct access is legitimate only in
bounded places: bootstrap (init), storage-native maintenance
(optimize/repair/cleanup/schema plan), graph-backed validation
(lint), catalog validation (queries validate), the control plane
(cluster *), local dev with no server, and break-glass (recovery, or
checking whether a long-running server's handle lags live HEAD). Everything else
is served. This is what makes "discourage direct storage" enforceable rather
than aspirational.
This list is expected to shrink: Decision 11 moves
optimize/cleanup (and healthy-path repair) to server-managed jobs, which
would leave direct access to just standalone/local dev, the control plane, and
break-glass — and remove the last routine reason an admin needs bucket
credentials.
Capability semantics
The CLI validates through verb capability, not plane jargon:
| Capability | Meaning | Examples |
|---|---|---|
any |
graph-scoped data; served via a server scope; direct only against a store scope (local dev / break-glass); errors on a cluster scope | query, mutate, load, export, branch reads, schema show/apply |
served |
requires an HTTP server; may be graph-scoped or scope-scoped | graphs list, queries list |
direct |
graph-scoped storage-native or graph-backed validation; no server form exists | init, optimize, repair, cleanup, schema plan, graph-backed lint |
control |
cluster-scoped catalog/control-plane work; addresses the cluster, not a single raw store | cluster *, queries validate |
local |
does not address a graph or scope | config, context, lint --query ... --schema ... |
any does not mean "the user picks": the resolver picks from the scope.
Internally the exhaustive command_plane match (planes.rs) stays as the drift
guard; user-facing errors speak in terms of what the command needs.
Definitions vs payloads
Queries and schema are definitions — contracts that live in the catalog and
are invoked by name; params and data are payloads passed per call. So the
everyday form is omnigraph query <name> [params], not
omnigraph query --file find.gq. A .gq path on a routine query is a smell: the
query is not in the catalog yet. Lifecycle: author a .gq → cluster apply
adopts it → invoke by name thereafter.
Named queries resolve through a server (which serves the cluster's catalog).
queries list is therefore a served catalog read. queries validate is a
control/catalog check against the cluster-owned query definitions. A bare
--store has no catalog, so it is the ad-hoc lane (-e / --file), and
--cluster does not invoke stored queries. So named-query invocation is a
served convenience; direct access (--store) is always ad-hoc.
| Kind | Examples | How it enters a command |
|---|---|---|
| Definition | stored query, schema | named in the catalog; authored as a file, adopted by cluster apply |
| Payload | params, bulk data | passed per call (--params, positional args, --data) |
| Authoring / ad-hoc | a .gq you're writing |
-e '…', --file new.gq, lint --query new.gq --schema schema.pg, schema apply --schema |
Resolution rule
- If the verb is
local, reject graph/scope flags and run without resolving a scope. - If a primitive address is supplied (
--store/--server/--cluster), use it and ignore operator-config scope defaults. (A named primitive —--server prod,--cluster brain— still resolves through the operator-config registry; a literal —--server https://…,--store s3://…— bypasses it. Per Decision 2: a value containing://is a literal, otherwise a config-name lookup.) - Else if
--context <name>(orOMNIGRAPH_CONTEXT) selects a context, use it. - Else use the operator config's flat defaults. Error only if neither resolves. (No sticky "current" pointer — each command resolves scope fresh.)
- Resolve the graph only for graph-scoped verbs. Server/cluster scopes:
exactly one graph in scope → use it; else
default_graph; else require--graph <id>. Store scopes are already one graph, so--graphis rejected. Scope-scoped verbs (graphs list,queries list,queries validate, andcluster *) do not select a graph unless their own resource argument says otherwise. - Derive the access path from capability × scope:
directverb → the scope's cluster/store; if the scope is a server, error (name storage explicitly — it is privileged).servedverb → the scope's server; if the scope is a cluster/store, error.controlverb → the scope's cluster; if the scope is a server/store, error (name a cluster explicitly — it is privileged).anyverb → served if the scope is a server; direct against a store scope (ad-hoc); on a cluster scope, error — cluster is maintenance-only, so use a server for data or--storefor ad-hoc.
- Reject mismatches with an error naming the missing axis.
Good errors:
scope "prod" has 4 graphs; pass --graph <id> or set default_graph
optimize needs direct storage access; scope "prod" is a server — name storage with --cluster s3://… or --store (requires storage credentials)
graphs list enumerates a server scope; do not pass --graph
--store opens raw storage directly, bypassing any server (no HTTP auth gate, live HEAD); for recovery/inspection
Config shape (operator config)
~/.omnigraph/config.yaml — your personal file; the cluster config
(cluster.yaml + catalog) is the separate, team-owned surface. The default-graph
key is default_graph everywhere (the per-command flag is --graph).
Everyday operator — binds a server, holds no storage root:
defaults:
server: prod
default_graph: knowledge
output: table
servers:
prod: { url: https://graph.example.com } # token keyed by name (RFC-007); no creds here
staging: { url: https://staging.example.com }
contexts: # optional, only for multiple environments
staging: { server: staging, default_graph: knowledge }
A normal operator never has a storage root or bucket credentials. Their default
scope is served; optimize/repair/cleanup error with a pointer to name
storage explicitly.
Maintainer — opts into a cluster root (and has bucket credentials):
contexts:
brain-admin: { cluster: brain, default_graph: knowledge } # direct; admin/control/maintenance
clusters:
brain: { root: s3://acme/clusters/brain } # the s3:// root lives ONLY here
The clusters: block — the only place a storage root appears in operator config —
is admin-only and opt-in, absent from a normal operator's file. Equivalently,
skip config and name it per command:
omnigraph optimize --cluster s3://acme/clusters/brain --graph knowledge. The
cluster stays the source of truth for the managed catalog; tokens live in the
keyed credential store, never in this file.
Command shape
Assume the everyday flat defaults: server prod, default graph knowledge.
| Intent | Command | Path |
|---|---|---|
| Run a catalog query | omnigraph query find_people |
served |
| …with params | omnigraph query find_people --params '{"title":"Eng"}' |
served |
| Another graph in scope | omnigraph query find_people --graph archive |
served |
| Write | omnigraph load --data batch.jsonl --mode append |
served |
| A different environment | omnigraph --context staging query find_people |
served |
| One-off server, no config | omnigraph query find_people --server https://graph.example.com --graph knowledge |
served |
| Maintain (admin, explicit storage) | omnigraph optimize --cluster s3://acme/clusters/brain --graph knowledge |
direct (privileged) |
| Maintain (admin, via admin context) | omnigraph --context brain-admin optimize --graph knowledge |
direct (privileged) |
| List catalog queries | omnigraph queries list |
served |
| Validate cluster query catalog | omnigraph queries validate --cluster s3://acme/clusters/brain |
control (privileged) |
| Offline query lint | omnigraph lint --query new.gq --schema schema.pg |
local |
| Graph-backed query lint | omnigraph lint --query new.gq --cluster s3://acme/clusters/brain --graph knowledge |
direct (privileged) |
| Local dev, no server | omnigraph query -e 'match { … } return { … }' --store graph.omni |
direct (local file) |
| Break-glass: raw storage of a served graph | omnigraph query --file find.gq --store s3://acme/clusters/brain/graphs/knowledge.omni |
direct (privileged, rare) |
Note what the everyday rows are: all served. optimize does not appear in
the default-scope rows — from a server scope it errors and points you to name
storage (see the resolution rule), so maintenance is always a deliberate,
credentialed act. There is no "force served/direct" row — you never toggle the
path on a configured graph; the only way to reach raw storage is to name it
(--cluster/--store), which makes the privileged bypass unmistakable. Everyday
rows invoke a query by name; a .gq file appears only where there is no
catalog (bare store, break-glass) via -e/--file.
Before / after
Before = best available today (legacy omnigraph.yaml --target, .gq
files, --cluster-graph, scheme inference). After = this model.
| Intent | Before | After |
|---|---|---|
| Run a query | omnigraph query --target knowledge --query find.gq --name find_people |
omnigraph query find_people |
| Another graph | omnigraph query --target archive --query find.gq --name find_people |
omnigraph query find_people --graph archive |
| Load | omnigraph load --data b.jsonl --mode append --target knowledge |
omnigraph load --data b.jsonl --mode append |
| Maintain (admin) | omnigraph optimize --cluster brain --cluster-graph knowledge |
omnigraph optimize --cluster s3://acme/clusters/brain --graph knowledge |
| Another environment | edit omnigraph.yaml, or re-address with full URIs |
--context staging … or OMNIGRAPH_CONTEXT=staging |
| One-off remote | omnigraph query --uri https://… --query find.gq (scheme→remote) |
omnigraph query find_people --server https://… --graph knowledge |
| Raw storage of a served graph | omnigraph query s3://…/knowledge.omni --query find.gq (looks like a normal query) |
omnigraph query --file find.gq --store s3://…/knowledge.omni (explicit bypass) |
Removed: --target; --cluster-graph (--graph is the graph selector only
for graph-scoped server/cluster verbs); --uri http-scheme dispatch; --via
(never ships); everyday --query <file> (definitions are named);
omnigraph.yaml and its cli.graph/server.graph defaults.
Server-side corollary
The same ontology applies to omnigraph-server boot: with omnigraph.yaml gone,
a server boots from a single bare graph URI or a cluster (--cluster <dir|s3>,
RFC-005), never a graphs: map. The store/server/cluster ontology is then
consistent across CLI and server.
Migration & compatibility
Addressing flags and config keys are observable contract (Hyrum); every removal is staged and release-noted.
config migrate(shipped) maps each legacygraphs:entry by what it actually is:http(s)URIs → aserver:(the recommended everyday shape);fileURIs → a localstore:; ans3://graph URI → an adminstore:(it is a single graph, not a cluster); ans3://cluster root (one that carries cluster state) → an admincluster:. Everydays3://graph usage migrates with a warning — prefer serving it via a server rather than re-establishing direct remote access. It reports dropped keys.- Operators move to a server-default scope. Where a legacy setup pointed
cli.graphat ans3://graph for everyday use, migration flags it: the recommended shape is aserver:scope (bearer token, no bucket creds), with thes3://root kept only in a maintainer's config — not every operator's. --targetwarns for one release, then errors;OMNIGRAPH_NO_LEGACY_CONFIG=1(already the strict switch) becomes the default — loadingomnigraph.yamlis a hard error.--cluster-graph→--graph:--cluster-graphis accepted with a warning for one release, then removed.--graphmeaning change: today--graphis "graph id on a multi-graph server" (paired with--server); it generalizes to "select the graph for graph-scoped verbs in server/cluster scopes." Existing--server --graphusage keeps working (it is a strict superset); release-note the broadened meaning and the fact that store/scope-scoped verbs reject it.--uri http://…warns, then errors with a pointer to--server.--ason served paths: today global--asis accepted (a no-op on remote writes — the server resolves the actor from the token); rejecting it on the served path is staged — warn for one release, then error.--alias→ thealiasnamespace (omnigraph alias <name>, Decision 4); the old--aliasflag warns for one release, then is removed.
Non-goals
- No change to the direct/served capability split. Maintenance stays
storage-direct by design (no server routes for
optimize/repair/cleanup); this RFC only makes the split explicit. - No new transport. Addressing surface, not protocol.
- No positional sigil grammar (
@server/graph,%cluster/graph). Considered and rejected: explicit flags are more discoverable; contexts already give brevity. Revisit only on demonstrated expert-terseness demand.
Decisions
The questions this RFC opened are resolved as follows. Two are explicitly deferred (see below); they do not block the model.
- Local-dev path → embedded
--storescope. Local dev runs the engine in-process against a--store <file>(or a store-scoped context);omnigraph servestays available but is not required. Consistent with embedded ≡ remote (RFC-009). - Primitives are one flag, typed by content.
--serverand--clusteraccept either a config name or a literal URI: a value containing://is a literal (bypasses the registry); otherwise it is a config-name lookup (error if unknown).--storeis always a URI. (Replaces the earlier "literal-vs-named" question — no--server-url/--cluster-rootsplit.) - Stored invocation:
query <name>(read) /mutate <name>(write), one catalog namespace. A name maps to one definition; the verb asserts its kind and the CLI errors on mismatch ('apply_labels' is a mutation — use omnigraph mutate apply_labels). Noinvokeverb. - Aliases live under an
aliasnamespace —omnigraph alias <name> [args], never bare top-level. An alias can therefore neither shadow nor be shadowed by a built-in (current or future) verb. - Context merge: scope wholesale, prefs layered. The entity binding +
default_graphcome wholesale from the active scope (a context, or flat defaults if none) — never per-key merged across the entity dimension (that would yield "server and cluster"). Only non-scope preferences (output, table layout) take flat defaults as a base. Precedence: explicit flag > context > flat defaults. - No default graph → error + list candidates. A graph-scoped verb with no
--graph, nodefault_graph, and >1 graph in scope errors and lists candidates (served:GET /graphs; cluster-direct: catalog enumeration). If enumeration is policy-gated/unavailable, it says so and asks for--graph. Never auto-pick. - Diagnostics & safety. Writes echo the resolved scope + access path to stderr
(suppress with
--quiet). Destructive verbs (cleanup, overwriteload,branch delete) require confirmation when the scope is not local;--yesskips it; no TTY without--yeserrors (never silently proceed).--json/CI never prompt — destructive without--yeserrors. - Cluster graphs evolve only via
cluster apply.schema apply(ananyverb) targets standalone graphs; against a cluster-managed graph it errors and points atcluster apply(which records ledger/recovery/approvals — RFC-004). Mirrorsinit's refusal of a cluster-managed path. - Maintenance moves server-side (committed direction).
optimize/cleanup(and healthy-pathrepair) become server/cluster-managed async jobs — policy-gated, audited, single-coordinator — withdirectretained only as break-glass (repairwhen the server is down). Runs out-of-band (a worker + async job routes, thePOST …/GET …/{id}shape of the bulk-data-plane RFC (docs/rfcs/0001-bulk-data-plane.md, PR #219, not yet merged)), never inline in serving;schema planis excluded (≈cluster planin cluster mode). The mechanism (job routes, worker, scheduling) is a follow-up RFC; until it lands the capability table above stands, and maintenance isdirect. When it lands, the maintenance verbs' capability becomes "served-job + direct break-glass."
Deferred
Non-blocking; settle when convenient.
- D5 — combined admin scope. A scope binds one entity; admins read via a
server scope and maintain via
--cluster. Adeployments: { … }object (server + cluster validated coherent, referenced by a context) is revisited only if admin ergonomics demand it — and Decision 11 largely removes the need. - D8 — the
contextcommand surface.context list/context show(read-only inspection) are additive diagnostics, shippable anytime; they don't touch the grammar or resolution. The no stickycontext useconstraint holds regardless — it is a design principle, not a command.
Safety
Dropping the sticky current_context pointer removes the main footgun — a
destructive command silently inheriting a "current" environment from an earlier
session. Because each command resolves scope fresh, what is on the command line is
what runs. Two guards remain (a flat default or OMNIGRAPH_CONTEXT can still point
at prod): echo the resolved scope + access path on writes, and require
confirmation (or --yes) for destructive verbs when the resolved scope is not
local (Decision 9). The most dangerous direct writes (cleanup, overwrite
load) are structurally rare now — unavailable from the everyday server scope,
and gated behind bucket credentials plus an explicit --cluster/--store — so a
normal operator's setup mostly cannot issue them by accident at all.
Invariants & deny-list check
- §10 query semantics first-class / §11 transport at the boundary: preserved —
addressing resolves CLI-side to a
GraphClient; no transport concepts leak into engine crates. - §12 no client-set actor: strengthened — the served path's actor stays
token-resolved and
--asis rejected there; direct self-declares. - Least privilege (security posture): everyday operators hold a revocable bearer token, not bucket credentials; only the server process and maintenance admins hold storage creds. Direct remote access is structural opt-in, not a default — narrowing the blast radius of a leaked operator config.
- §6 strong consistency: both paths are snapshot-isolated per query; this RFC changes addressing, not isolation.
- Deny-list (no state that drifts): contexts and aliases are static config sugar that resolve to canonical scopes; they declare nothing the cluster or server doesn't already own. No sticky session state is introduced.
- No Hard Invariant is weakened; the change is CLI surface + config removal.
Relationship to prior work
The completion of the config/CLI lineage: RFC-007 added the operator config and
keyed credentials; RFC-008 demoted omnigraph.yaml; RFC-009 unified execution
behind GraphClient; RFC-010 declared the planes. This RFC removes the last
legacy addressing surface so the plane model becomes a clean function of the three
real entities, and folds the planes into a single capability rule. It is adjacent
to the public-track bulk-data-plane RFC (docs/rfcs/0001-bulk-data-plane.md,
PR #219, not yet merged), which canonicalizes load/export verbs; this RFC
canonicalizes how every verb addresses a graph.
Appendix: target CLI taxonomy (end state)
The full command set under this model, organized by capability (the new classifying axis) instead of plane — the end-state counterpart to the current-taxonomy appendix below. Every command, with its end-state addressing.
omnigraph
│
├─ any — data verbs · served by default (server scope, or --server <url|name>);
│ --graph selects the graph in scope; --store forces ad-hoc direct (no catalog)
│ ├─ query (alias: read*) invoke a stored query by NAME; -e/--file for ad-hoc
│ ├─ mutate (alias: change*) invoke a stored mutation by name; -e/--file for ad-hoc
│ ├─ load bulk write — --data, --mode required; --from forks a missing branch
│ ├─ export dump graph data (NDJSON / Arrow)
│ ├─ snapshot current per-table versions
│ ├─ branch { create | list | delete | merge } merge takes --into <target>
│ ├─ commit { list | show } inspect the commit graph
│ └─ schema { show (alias: get) | apply } cluster graphs evolve via cluster apply (Decision 10)
│
├─ served — needs a server (errors on a store/cluster scope)
│ ├─ graphs list enumerate the graphs a server serves
│ └─ queries list list stored queries in the served catalog
│
├─ direct — storage-native, PRIVILEGED · --cluster <root> | --store <uri> + bucket creds; never a server
│ ├─ init bootstrap a graph (--store <uri>); refuses a cluster-managed path
│ ├─ optimize compaction; --graph selects
│ ├─ repair publish uncovered drift; --confirm / --force
│ ├─ cleanup version GC; --keep / --older-than / --confirm
│ ├─ schema plan migration preview (reads storage directly)
│ └─ lint --query <path> graph-backed query lint (with --graph on cluster scope)
│
├─ control — cluster/catalog control, PRIVILEGED · --cluster <dir|s3>
│ ├─ cluster { validate | plan | apply | approve | status | refresh | import | force-unlock }
│ apply/approve take --as <actor>; force-unlock takes <LOCK_ID>
│ └─ queries validate validate cluster-owned stored queries against graph schemas
│
└─ local — no graph
├─ policy { validate | test | explain } offline Cedar tooling
├─ context { list | show } read-only; NO mutating `use` (no sticky state)
├─ alias <name> [args] personal shortcut; expands to its bound stored-query call (D4)
├─ config { migrate } finish the omnigraph.yaml split (RFC-008)
├─ login / logout per-server bearer credentials
├─ embed offline embedding pipeline
├─ lint --query <path> --schema <path> file-only query lint
└─ version (-v)
* read/change remain as deprecated aliases (warn on use); ingest and the
check→lint argv-shim are removed. get aliases schema show.
Addressing forms (end state)
Three scope forms — one per real entity — plus the graph selector. No --target,
no --cluster-graph, no --uri scheme-dispatch, no --via.
| Form | Resolves to | Access | Privilege |
|---|---|---|---|
server scope — operator default, a --context, or --server <url|name> |
a served endpoint + keyed token | served | everyday (bearer token) |
cluster scope — an admin context, or --cluster <root> |
a managed cluster's storage + catalog | direct | privileged (bucket creds) |
store scope — --store <uri> |
one graph's storage (no catalog) | direct | local-dev (file) / break-glass (s3) |
--graph <id> |
selects the graph for graph-scoped verbs in server/cluster scopes; invalid for store scopes and scope-scoped verbs | — | — |
Resolution: explicit primitive (--server/--cluster/--store) → --context /
OMNIGRAPH_CONTEXT → operator flat defaults. Access path is then derived from the
scope kind × the verb's capability (see the Resolution rule); it is never inferred
from a URI scheme and never toggled.
What moved vs today
| Command(s) | Today (plane) | End state (capability) |
|---|---|---|
query/mutate/load/export/snapshot/branch/commit/schema show/schema apply |
Data | any (served-default; --store ad-hoc) |
graphs list |
Data (remote-only) | served |
queries list |
Session | served (catalog read) |
init/optimize/repair/cleanup/schema plan/graph-backed lint |
Storage | direct (privileged) |
queries validate |
Storage | control (catalog validation) |
cluster * |
Control | control (unchanged) |
policy */embed/login/logout/config/version/offline lint --query --schema |
Session | local |
ingest; --target; --cluster-graph; --uri http dispatch |
present | removed |
| — | — | added: `context { list |
Cross-capability families: schema (plan is direct, show/apply are
any), queries (list is served, validate is control), and lint
(offline with --schema is local, graph-backed is direct) split per
subcommand/mode, exactly where their authority and data dependencies differ.
Appendix: current CLI taxonomy (today)
The as-is command surface this RFC transforms, kept so the RFC is
self-contained. The source of truth is the exhaustive command_plane match in
crates/omnigraph-cli/src/planes.rs.
Where it disagrees with the design above (four planes, --target,
--cluster-graph, scheme-inferred transport), the design is the target and this
is today.
The four planes (today)
| Plane | What it touches | Addressing accepted |
|---|---|---|
| Data | a graph — embedded or via a server | <URI> · --target · --server (+--graph) |
| Storage | direct storage, no server | <URI> · --target (local/S3 only) · some also --cluster+--cluster-graph |
| Control | a cluster directory | --config <dir> |
| Session | no graph | — |
--server/--graph are gated strictly to the data plane; guard_addressing
(planes.rs:128) rejects them elsewhere (RFC-010 Slice 1).
Command tree by plane (today)
omnigraph
├─ DATA ────────── run against a graph; embedded or --server
│ ├─ query (alias: read) · mutate (alias: change) · load · ingest (hidden, deprecated)
│ ├─ branch { create | list | delete | merge } · snapshot · export · commit { list | show }
│ ├─ graphs { list } (remote-only)
│ └─ schema { show (alias: get) | apply } ← show/apply are DATA
├─ STORAGE ─────── direct file://|s3:// access; --server rejected
│ ├─ init · optimize · repair · cleanup (optimize/repair/cleanup also: --cluster --cluster-graph)
│ ├─ lint (check shim) · schema plan ← plan is STORAGE
│ └─ queries validate
├─ CONTROL ─────── cluster directory via --config <dir>
│ └─ cluster { validate | plan | apply | approve | status | refresh | import | force-unlock }
└─ SESSION ─────── no graph
├─ policy { validate | test | explain } · embed · login / logout
├─ config { migrate } · queries list ← list is SESSION
└─ version (-v)
read/change are visible clap aliases (deprecated names, warn); check is an
argv-shim → lint; get aliases schema show; ingest is hidden but runs.
Cross-plane families (today)
schema:schema planis Storage;schema show/applyare Data.queries:queries validateis Storage;queries listis Session.
Addressing forms (today)
| Form | Looks up in | Resolves to | Source |
|---|---|---|---|
<URI> / --uri |
nothing (explicit) | the literal URI | — |
--target <name> |
omnigraph.yaml graphs: |
that graph's uri (local / S3 / http) |
config.rs::resolve_target_uri |
--server <name> (+--graph) |
~/.omnigraph/config.yaml servers: |
a remote server URL | helpers.rs::resolve_server_flag |
--cluster <dir|s3> --cluster-graph <id> |
served cluster state | the graph's storage URI | helpers.rs (RFC-010 Slice 3) |
Precedence (resolve_target_uri): explicit <URI>/--uri → --target →
cli.graph default → error. is_remote_uri (helpers.rs:15) then selects
GraphClient::Remote vs Embedded (client.rs:86).
Enforcement points (today)
guard_addressing(planes.rs:128):--server/--graphon a non-data verb fails with a declared message.- Storage-plane remote rejection (
helpers.rs:467): a storage verb whose--targetresolves tohttp(s)://is rejected. initinto a cluster layout is refused (usecluster apply).
Audit comments
Reviewed against the current CLI taxonomy, planes.rs, cli.rs, helpers.rs,
client.rs, RFC-007/RFC-010, and the user-facing CLI/server docs.
Validated
- The target taxonomy now has a stable classifier:
any,served,direct,control, andlocalare all declared capabilities. - Cluster scope is coherent: it is privileged direct storage for control,
maintenance, and validation, not a direct data path.
anydata verbs served by default and reject cluster scope. - Graph selection is no longer universal. Graph-scoped verbs select a graph;
scope-scoped verbs such as
graphs list,queries list,queries validate, andcluster *address the whole server/cluster scope. - The current-state appendix still matches the implemented CLI: four planes,
--target,--cluster-graph, scheme-inferred transport,schema planas Storage, andschema show/applyas Data.
Decisions and deferrals are tracked in Decisions above — not duplicated here.