From 65160cc060c17052bc4bcaa78c28216c2ccf345c Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Thu, 11 Jun 2026 22:15:19 +0300 Subject: [PATCH 1/4] =?UTF-8?q?docs(rfc):=20aliases=20are=20bindings,=20no?= =?UTF-8?q?t=20content=20=E2=80=94=20the=20ratified=20alias=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-007 §D2 gains the model the alias design reasoned through: stored queries are content + its canonical team-owned name; legacy omnigraph.yaml aliases conflate a personal name with a local-file content pointer (the muddle RFC-008 retires); operator aliases are pure bindings (server, graph, stored-query NAME, arg mapping, defaults) — an alias that carries content competes with the catalog, one that references a name composes with it. The three senses of 'global' are resolved explicitly: cross-graph globality is strengthened (one $HOME file vs per-directory), team-shared shorthand is deliberately NOT an alias mechanism (the shared name IS the catalog name), cross-machine follows the dotfile. Collision rule: legacy wins during the RFC-008 window, with a warning. RFC-008's migration row for aliases sharpens accordingly: a legacy alias splits — content to the catalog (via cluster apply), binding to the operator layer; config migrate proposes both halves. Co-Authored-By: Claude Fable 5 --- docs/dev/rfc-007-operator-config.md | 43 ++++++++++++++++++-- docs/dev/rfc-008-deprecate-omnigraph-yaml.md | 2 +- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/dev/rfc-007-operator-config.md b/docs/dev/rfc-007-operator-config.md index 5abf4e1..96daad8 100644 --- a/docs/dev/rfc-007-operator-config.md +++ b/docs/dev/rfc-007-operator-config.md @@ -137,10 +137,13 @@ servers: # operator-owned endpoint definitions # No token here, ever. Resolution: §D4. aliases: # personal shorthand over CLUSTER-owned queries - triage: # (the query is the shared contract; the alias, - server: intel-dev # its defaults, and its name are mine — RFC-008) - graph: spike - query: weekly_triage + triage: + server: intel-dev # required: names an operator server above + graph: spike # optional (omit for single-mode servers) + query: weekly_triage # STORED query name on that server — never a file + args: [since] # positional CLI args -> params, in order + params: { limit: 20 } # optional fixed defaults (positionals/--params win) + format: table # optional; feeds the format cascade defaults: output: table # read --format default @@ -151,6 +154,38 @@ written by a newer CLI must not brick an older one; contrast with `cluster.yaml`, where unknown keys are deliberately fatal because they change what a *plan* means). +#### Aliases are bindings, not content + +Three things must not be conflated: + +- **Stored queries (the cluster catalog)** are *content plus its canonical, + team-owned name* — reviewed, digest-pinned, invocable by name over HTTP. +- **Legacy `omnigraph.yaml` aliases** conflate a personal name with a + pointer to query *content in a local file* — which is why they break + across directories and can drift from the catalog. RFC-008 retires them. +- **Operator aliases** are pure **bindings, zero content**: a personal name + → (server, graph, stored-query *name*, arg mapping, defaults). An alias + that carries content competes with the catalog; an alias that references + a name composes with it. + +The three senses of "global", resolved by this split: + +1. **Across graphs/servers** — preserved and strengthened: today's aliases + are "global" only within one per-directory config file; operator + aliases live in one `$HOME` file, each binding self-contained, usable + from any cwd. +2. **Across operators (team-shared shorthand)** — deliberately *no alias + mechanism*: the shared name IS the stored query's catalog name. A team + that wants a shorter shared name renames the query in `cluster.yaml` + (reviewed, one name). A parallel team-alias namespace would be two + shared names for one thing — pure drift surface. +3. **Across machines** — dotfile the one operator file; bindings carry no + local-file dependencies. + +Collision rule during the RFC-008 window: a legacy file-alias with the +same name **wins**, with a warning naming both definitions — consistent +with §D3's legacy-outranks-operator ordering. + ### D3. Precedence and the merge rule The end-state cascade is short, because the team surface (cluster config) diff --git a/docs/dev/rfc-008-deprecate-omnigraph-yaml.md b/docs/dev/rfc-008-deprecate-omnigraph-yaml.md index 49e2c4b..d496df8 100644 --- a/docs/dev/rfc-008-deprecate-omnigraph-yaml.md +++ b/docs/dev/rfc-008-deprecate-omnigraph-yaml.md @@ -88,7 +88,7 @@ The full `OmnigraphConfig` surface (verified against | `cli.actor` | identity | `operator.actor` (RFC-007 §D3) | | `cli.output_format`, `cli.table_*` | personal ergonomics | `defaults:` in operator config (RFC-007 §D2) | | `cli.graph`, `cli.branch` | personal targeting | operator config: named servers + a per-operator default target (RFC-007 PR 3) | -| `aliases.` | personal ergonomics over shared queries | operator config `aliases:` — the *queries* they invoke are cluster-owned; the *shorthand* is personal | +| `aliases.` | a personal name conflated with a content pointer | **splits in two** (RFC-007 §D2 "bindings, not content"): the referenced `.gq` file's *content* becomes a catalog stored query (team-reviewed); the *binding* becomes an operator alias referencing that name. `config migrate` proposes both halves but cannot publish catalog content itself — that is a `cluster apply` | | `query.roots` | discovery convenience | obsolete — cluster query discovery (#183) replaced it | | `project.name` | label | dropped (the cluster's `metadata.name` is the deployment label) | From 2b33ab64f2a33260c917bbe3bfe13f1abacfbc5e Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Thu, 11 Jun 2026 22:19:25 +0300 Subject: [PATCH 2/4] feat(cli): --server targeting (RFC-007 PR 3, part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global flags --server (operator-defined server name) and --graph (graph id on a multi-graph server, requires --server) resolve to the effective remote URI through one helper and feed the ordinary uri slot — graph resolution and the PR-2 keyed-token URL match work unchanged; the flag is sugar for a URI the operator already owns. Exclusive with a positional URI and --target (loud error, never silent precedence). Unknown names fail listing the servers that ARE defined. Co-Authored-By: Claude Fable 5 --- crates/omnigraph-cli/src/cli.rs | 11 ++++++ crates/omnigraph-cli/src/helpers.rs | 51 ++++++++++++++++++++++++++++ crates/omnigraph-cli/src/main.rs | 28 +++++++++++++++ crates/omnigraph-cli/src/operator.rs | 19 +++++++++++ 4 files changed, 109 insertions(+) diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 7708c0a..feb08e8 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -21,6 +21,17 @@ pub(crate) struct Cli { #[arg(long = "as", global = true, value_name = "ACTOR")] pub(crate) as_actor: Option, + /// Target an operator-defined server by name (RFC-007): resolves to + /// its `url` from `servers:` in ~/.omnigraph/config.yaml. Exclusive + /// with a positional URI or `--target`. + #[arg(long, global = true, value_name = "NAME")] + pub(crate) server: Option, + + /// Graph id on a multi-graph `--server` (appends `/graphs/` to + /// the server url). Requires --server. + #[arg(long, global = true, value_name = "GRAPH_ID", requires = "server")] + pub(crate) graph: Option, + #[command(subcommand)] pub(crate) command: Command, } diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index b837192..7adac16 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -264,6 +264,57 @@ pub(crate) fn resolve_remote_bearer_token( Ok(None) } +/// `--server ` (RFC-007 PR 3): resolve an operator-defined server +/// name (+ optional `--graph` for multi-graph servers) to the effective +/// remote URI. The result feeds the ordinary `uri` slot, so graph +/// resolution and the keyed-token URL match work unchanged — the flag is +/// sugar for a URI the operator already owns. Unknown names fail loudly, +/// listing what IS defined. +pub(crate) fn resolve_server_flag( + server: Option<&str>, + graph: Option<&str>, +) -> Result> { + let Some(server) = server else { + return Ok(None); + }; + let operator_config = operator::load_operator_config()?; + let Some(entry) = operator_config.servers.get(server) else { + let known = operator_config + .servers + .keys() + .map(String::as_str) + .collect::>() + .join(", "); + color_eyre::eyre::bail!( + "unknown server '{server}' — servers defined in the operator config: [{known}] (add it under servers: in ~/.omnigraph/config.yaml)" + ); + }; + let base = entry.url.trim_end_matches('/'); + Ok(Some(match graph { + Some(graph) => format!("{base}/graphs/{graph}"), + None => base.to_string(), + })) +} + +/// Apply `--server`/`--graph` to a command's uri/target slots: exclusive +/// with both (loud error, not silent precedence), no-op when absent. +pub(crate) fn apply_server_flag( + server: Option<&str>, + graph: Option<&str>, + uri: Option, + target: Option<&str>, +) -> Result> { + if server.is_none() { + return Ok(uri); + } + if uri.is_some() || target.is_some() { + color_eyre::eyre::bail!( + "--server is exclusive with a positional URI and --target — pick one way to address the graph" + ); + } + resolve_server_flag(server, graph) +} + /// The remote base URL a token resolution is FOR — the same scoping /// `graph_bearer_token_env` uses: an explicit http(s) `--uri` wins, else /// the config-resolved target's uri (when remote). Local URIs → None. diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 85fe537..0ee3851 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -130,6 +130,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let graph = resolve_cli_graph(&config, uri, target.as_deref())?; @@ -198,6 +200,8 @@ async fn main() -> Result<()> { use `omnigraph load --from --mode ` (ingest defaults: --from main --mode merge)" ); let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let graph = resolve_cli_graph(&config, uri, target.as_deref())?; @@ -250,6 +254,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let graph = resolve_cli_graph(&config, uri, target.as_deref())?; @@ -293,6 +299,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let graph = resolve_cli_graph(&config, uri, target.as_deref())?; @@ -328,6 +336,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let graph = resolve_cli_graph(&config, uri, target.as_deref())?; @@ -367,6 +377,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let graph = resolve_cli_graph(&config, uri, target.as_deref())?; @@ -417,6 +429,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; @@ -456,6 +470,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; @@ -519,6 +535,8 @@ async fn main() -> Result<()> { allow_data_loss, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let graph = resolve_cli_graph(&config, uri, target.as_deref())?; @@ -576,6 +594,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; @@ -640,6 +660,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; @@ -675,6 +697,8 @@ async fn main() -> Result<()> { table_keys, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; @@ -736,6 +760,7 @@ async fn main() -> Result<()> { let target_name = target .as_deref() .or_else(|| alias_config.and_then(|alias| alias.graph.as_deref())); + let uri = apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target_name)?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?; let graph = resolve_cli_graph(&config, uri, target_name)?; let uri = graph.uri.clone(); @@ -822,6 +847,7 @@ async fn main() -> Result<()> { let target_name = target .as_deref() .or_else(|| alias_config.and_then(|alias| alias.graph.as_deref())); + let uri = apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target_name)?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?; let graph = resolve_cli_graph(&config, uri, target_name)?; let uri = graph.uri.clone(); @@ -1177,6 +1203,8 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; + let uri = + apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; diff --git a/crates/omnigraph-cli/src/operator.rs b/crates/omnigraph-cli/src/operator.rs index 1b95e24..64b5756 100644 --- a/crates/omnigraph-cli/src/operator.rs +++ b/crates/omnigraph-cli/src/operator.rs @@ -467,6 +467,25 @@ mod tests { assert_eq!(config.find_server_for_url("http://other:9999"), None); } + #[test] + fn server_lookup_supports_targeting() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + fs::write( + &path, + "servers:\n intel-dev:\n url: http://127.0.0.1:8080/\n", + ) + .unwrap(); + let config = load_operator_config_at(&path).unwrap(); + // the --server resolution shape: bare url and graph-scoped url + let base = config.servers["intel-dev"].url.trim_end_matches('/'); + assert_eq!(base, "http://127.0.0.1:8080"); + assert_eq!( + format!("{base}/graphs/spike"), + "http://127.0.0.1:8080/graphs/spike" + ); + } + #[test] fn token_env_name_uppercases_and_underscores() { assert_eq!(token_env_name("intel-dev"), "OMNIGRAPH_TOKEN_INTEL_DEV"); From dc91c55970ae94362e6f5f51e23355db8fb8abf6 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Thu, 11 Jun 2026 22:25:42 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(cli):=20operator=20aliases=20=E2=80=94?= =?UTF-8?q?=20pure=20bindings=20invoking=20stored=20queries=20(RFC-007=20P?= =?UTF-8?q?R=203,=20part=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aliases: in the operator config bind a personal name to (server, graph, stored-query NAME, positional arg mapping, fixed param defaults, format) — zero content, per the ratified bindings-not-content model. Invocation goes through the server's stored-query endpoint (POST {base}/graphs/{g}/queries/{name}) with the keyed credential resolving via the ordinary URL match; param precedence --params > positionals > fixed defaults; the result renders through the existing format cascade with the alias's format as its hop. A legacy omnigraph.yaml alias with the same name wins during the RFC-008 window, with a warning naming both. E2e (spawned policy-gated server, invoke_query granted via a per-graph bundle): the alias invokes with name + one positional and nothing else — server, graph, query, and token all from the operator layer; --server/ --graph explicit targeting; unknown --server lists defined names; --server exclusive with a positional URI. Co-Authored-By: Claude Fable 5 --- crates/omnigraph-cli/src/helpers.rs | 51 +++++++++ crates/omnigraph-cli/src/main.rs | 28 +++++ crates/omnigraph-cli/src/operator.rs | 35 +++++- crates/omnigraph-cli/tests/system_local.rs | 121 +++++++++++++++++++++ docs/dev/rfc-007-operator-config.md | 2 +- docs/user/cli-reference.md | 25 ++++- 6 files changed, 256 insertions(+), 6 deletions(-) diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index 7adac16..a48f2e4 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -296,6 +296,57 @@ pub(crate) fn resolve_server_flag( })) } +/// Execute an OPERATOR alias (RFC-007 PR 3): a pure binding invoking a +/// stored query by name on a named server — POST {base}/queries/{name}. +/// Param precedence: --params > positional args > the alias's fixed +/// params. The keyed token applies via the ordinary URL match. +pub(crate) async fn execute_operator_alias( + client: &reqwest::Client, + config: &OmnigraphConfig, + alias_name: &str, + alias: &crate::operator::OperatorAlias, + alias_args: &[String], + explicit_params: Option, +) -> Result { + let uri = resolve_server_flag(Some(&alias.server), alias.graph.as_deref())? + .expect("server name is present"); + let bearer_token = resolve_remote_bearer_token(config, Some(&uri), None)?; + + let mut params = serde_json::Map::new(); + for (key, value) in &alias.params { + let Some(key) = key.as_str() else { + bail!("alias '{alias_name}': params keys must be strings"); + }; + params.insert(key.to_string(), serde_json::to_value(value)?); + } + if alias_args.len() > alias.args.len() { + bail!( + "alias '{alias_name}' takes {} positional arg(s) ({}), got {}", + alias.args.len(), + alias.args.join(", "), + alias_args.len() + ); + } + for (name, value) in alias.args.iter().zip(alias_args) { + params.insert(name.clone(), parse_alias_value(value)); + } + if let Some(Value::Object(explicit)) = explicit_params { + for (key, value) in explicit { + params.insert(key, value); + } + } + + let body = (!params.is_empty()).then(|| serde_json::json!({ "params": params })); + remote_json( + client, + Method::POST, + remote_url(&uri, &format!("/queries/{}", alias.query)), + body, + bearer_token.as_deref(), + ) + .await +} + /// Apply `--server`/`--graph` to a command's uri/target slots: exclusive /// with both (loud error, not silent precedence), no-op when absent. pub(crate) fn apply_server_flag( diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 0ee3851..284cff8 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -746,6 +746,34 @@ async fn main() -> Result<()> { } let config = load_cli_config(config.as_ref())?; + // Operator aliases (RFC-007 PR 3): pure bindings to stored + // queries. A legacy file-alias with the same name wins during + // the RFC-008 window (with a warning); an alias name found + // only in the operator layer takes the invoke path here. + if let Some(alias_name) = alias.as_deref() { + let operator_config = crate::operator::load_operator_config()?; + if let Some(operator_alias) = operator_config.aliases.get(alias_name) { + if config.alias(alias_name).is_ok() { + eprintln!( + "warning: alias '{alias_name}' is defined in both omnigraph.yaml (legacy, wins during the deprecation window) and the operator config; the legacy definition applies" + ); + } else { + let output = execute_operator_alias( + &http_client, + &config, + alias_name, + operator_alias, + &alias_args, + load_params_json(¶ms)?, + ) + .await?; + let format = + resolve_read_format(&config, format, json, operator_alias.format); + print_read_output(&output, format, &config)?; + return Ok(()); + } + } + } let alias = resolve_alias(&config, alias.as_deref(), AliasCommand::Read)?; let alias_name = alias.as_ref().map(|(name, _)| *name); let alias_config = alias.as_ref().map(|(_, alias)| *alias); diff --git a/crates/omnigraph-cli/src/operator.rs b/crates/omnigraph-cli/src/operator.rs index 64b5756..16f5550 100644 --- a/crates/omnigraph-cli/src/operator.rs +++ b/crates/omnigraph-cli/src/operator.rs @@ -38,12 +38,38 @@ pub(crate) struct OperatorConfig { /// can redefine an entry here. No tokens in this file, ever. #[serde(default)] pub(crate) servers: BTreeMap, + /// Personal alias bindings (RFC-007 PR 3); see OperatorAlias. + #[serde(default)] + pub(crate) aliases: BTreeMap, /// Everything this CLI version doesn't know. Warned once at load, /// otherwise ignored (forward compatibility within the operator layer). #[serde(flatten)] unknown: serde_yaml::Mapping, } +/// A personal alias: a pure BINDING to a stored query on a named server — +/// never content, never a file (RFC-007 §D2 "Aliases are bindings, not +/// content"). The stored query is the team's contract; the alias, its +/// defaults, and its name are the operator's. +#[derive(Debug, Deserialize)] +pub(crate) struct OperatorAlias { + /// Names an entry under `servers:`. + pub(crate) server: String, + /// Graph id for multi-graph servers (appends `/graphs/`). + pub(crate) graph: Option, + /// The STORED query's name on that server. + pub(crate) query: String, + /// Positional CLI args bind to these param names, in order. + #[serde(default)] + pub(crate) args: Vec, + /// Fixed default params; positionals and `--params` override per key. + #[serde(default)] + pub(crate) params: serde_yaml::Mapping, + pub(crate) format: Option, + #[serde(flatten)] + unknown: serde_yaml::Mapping, +} + #[derive(Debug, Deserialize)] pub(crate) struct OperatorServer { pub(crate) url: String, @@ -163,6 +189,9 @@ impl OperatorConfig { for (name, server) in &self.servers { collect(&server.unknown, &format!("servers.{name}.")); } + for (name, alias) in &self.aliases { + collect(&alias.unknown, &format!("aliases.{name}.")); + } warnings } } @@ -425,10 +454,8 @@ mod tests { let config = load_operator_config_at(&path).unwrap(); assert_eq!(config.actor(), Some("act-a")); let warnings = config.unknown_key_warnings(); - // `servers` became a known key in PR 2; `aliases` stays unknown - // until PR 3. - assert_eq!(warnings.len(), 2, "{warnings:?}"); - assert!(warnings.iter().any(|w| w.contains("`aliases`"))); + // `servers` (PR 2) and `aliases` (PR 3) are known keys now. + assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("`operator.color`"))); assert_eq!(config.servers["prod"].url, "https://example.com"); } diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index 5eb739f..28ed7a3 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -2424,3 +2424,124 @@ fn local_cli_keyed_credentials_authenticate_url_matched_server() { let output = remote_read(&[]); assert!(!output.status.success(), "logout must revoke access"); } + +/// RFC-007 PR 3: --server targeting and operator aliases (pure bindings to +/// stored queries) end to end, with the keyed credential from PR 2. +#[test] +fn local_cli_operator_alias_and_server_flag_invoke_stored_query() { + let graph = SystemGraph::loaded(); + graph.write_query( + "stored-find-person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }", + ); + // invoke_query is policy-gated (anti-probing 404 without the grant), + // so the server gets a per-graph bundle granting it to the operator. + graph.write_file( + "graph.policy.yaml", + "version: 1\ngroups:\n ops: [\"act-op\"]\nprotected_branches: [main]\nrules:\n - id: allow-invoke\n allow:\n actors: { group: ops }\n actions: [invoke_query]\n - id: allow-read\n allow:\n actors: { group: ops }\n actions: [read]\n branch_scope: any\n", + ); + let config = graph.write_config( + "omnigraph-server.yaml", + &format!( + "graphs:\n local:\n uri: {}\n policy:\n file: ./graph.policy.yaml\n queries:\n find_person:\n file: ./stored-find-person.gq\n", + yaml_string(&graph.path().to_string_lossy()) + ), + ); + let server = spawn_server_with_config_env( + &config, + &[( + "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", + r#"{"act-op":"srv-tok"}"#, + )], + ); + + let operator_home = tempfile::tempdir().unwrap(); + fs::write( + operator_home.path().join("config.yaml"), + format!( + "servers:\n dev:\n url: {}\naliases:\n who:\n server: dev\n graph: local\n query: find_person\n args: [name]\n", + server.base_url + ), + ) + .unwrap(); + fs::write( + operator_home.path().join("credentials"), + "[dev]\ntoken = srv-tok\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + operator_home.path().join("credentials"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + + // The operator alias: name + positional arg, nothing else — server, + // graph, stored query, and token all resolve from the operator layer. + let output = cli() + .env("OMNIGRAPH_HOME", operator_home.path()) + .arg("query") + .arg("--alias") + .arg("who") + .arg("Alice") + .arg("--json") + .output() + .unwrap(); + assert!( + output.status.success(), + "operator alias must invoke the stored query: {output:?}" + ); + let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["rows"][0]["p.name"], "Alice", "{payload}"); + + // --server/--graph: the same stored query via explicit targeting. + let output = cli() + .env("OMNIGRAPH_HOME", operator_home.path()) + .arg("query") + .arg("--server") + .arg("dev") + .arg("--graph") + .arg("local") + .arg("--query-string") + .arg("query q($name: String) { match { $p: Person { name: $name } } return { $p.name } }") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--json") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + + // Unknown --server errors listing what IS defined. + let output = cli() + .env("OMNIGRAPH_HOME", operator_home.path()) + .arg("query") + .arg("--server") + .arg("nope") + .arg("--query-string") + .arg("query q() { match { $p: Person } return { $p.name } }") + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unknown server 'nope'") && stderr.contains("dev"), "{stderr}"); + + // --server is exclusive with a positional URI. + let output = cli() + .env("OMNIGRAPH_HOME", operator_home.path()) + .arg("query") + .arg(&server.base_url) + .arg("--server") + .arg("dev") + .arg("--query-string") + .arg("query q() { match { $p: Person } return { $p.name } }") + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("exclusive"), + "{output:?}" + ); +} diff --git a/docs/dev/rfc-007-operator-config.md b/docs/dev/rfc-007-operator-config.md index 96daad8..5bd8afb 100644 --- a/docs/dev/rfc-007-operator-config.md +++ b/docs/dev/rfc-007-operator-config.md @@ -286,7 +286,7 @@ Three PRs, each independently useful, each landable without the next: §D4 chain (env + credentials file), the §D5 trust rules, and `omnigraph login ` (atomic write, `0600`). Legacy mechanisms untouched and tested-as-untouched. -3. **PR 3 — operator targeting.** `--server ` on remote-capable +3. **PR 3 — operator targeting** *(landed)*. `--server ` on remote-capable commands and `aliases:` in the operator layer (server + graph + query + default params), resolving through operator-defined servers. This is the *bridge* toward RFC-002's locator — multi-server addressing in a diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index c41a15c..b113ef3 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](cli.md). -Top-level command families and subcommands. Graph-targeting commands accept either a positional `URI`, `--uri`, or a `--target ` resolved against `omnigraph.yaml`; `cluster` commands use `--config `. +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 commands @@ -67,6 +67,29 @@ refused). Token from `--token`, or — preferred, keeps it out of shell history — one line on stdin: `echo $TOKEN | omnigraph login prod`. `omnigraph logout ` removes it (idempotent). +#### Operator aliases — bindings, not content + +An operator alias is a personal name for *invoking a stored query on a +named server* — it carries no query content (the stored query in the +catalog is the team's contract; the alias, its defaults, and its name are +yours): + +```yaml +aliases: + triage: + server: intel-dev # names an entry under servers: + graph: spike # optional (multi-graph servers) + query: weekly_triage # the STORED query's name — never a file + args: [since] # positional args -> params, in order + params: { limit: 20 } # fixed defaults; positionals/--params win + format: table +``` + +`omnigraph query --alias triage 2026-06-01` invokes +`POST /graphs/spike/queries/weekly_triage` with the keyed +credential. A legacy `omnigraph.yaml` alias with the same name wins during +the deprecation window (with a warning). + A remote command whose URL prefix-matches an operator server's `url` (the `gh` host model — no flags needed) resolves its token through: From 20ddfc61c1605d790ab59bbcd7f108d138c7b37b Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Thu, 11 Jun 2026 22:29:57 +0300 Subject: [PATCH 4/4] fix(cli): reclaim the hidden legacy-uri positional for operator aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught on the live smoke: with --alias, the first bare CLI arg lands in the hidden legacy_uri positional, so an operator alias's positional param never bound ('parameter not provided' from the server). An operator alias always knows its target, so the existing normalize_legacy_alias_uri reclaims the swallowed positional as the first alias arg — same rule the legacy path already applies. Co-Authored-By: Claude Fable 5 --- crates/omnigraph-cli/src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 284cff8..4306b67 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -758,6 +758,15 @@ async fn main() -> Result<()> { "warning: alias '{alias_name}' is defined in both omnigraph.yaml (legacy, wins during the deprecation window) and the operator config; the legacy definition applies" ); } else { + // The hidden legacy-uri positional swallows the first + // bare arg; an operator alias always knows its target, + // so reclaim it as the first positional param. + let (_, alias_args) = normalize_legacy_alias_uri( + legacy_uri.clone(), + true, + Some(alias_name), + alias_args.clone(), + ); let output = execute_operator_alias( &http_client, &config,