feat(cli): operator aliases — pure bindings invoking stored queries (RFC-007 PR 3, part 2)

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 <noreply@anthropic.com>
This commit is contained in:
aaltshuler 2026-06-11 22:25:42 +03:00
parent 2b33ab64f2
commit dc91c55970
6 changed files with 256 additions and 6 deletions

View file

@ -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<Value>,
) -> Result<ReadOutput> {
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(

View file

@ -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(&params)?,
)
.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);

View file

@ -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<String, OperatorServer>,
/// Personal alias bindings (RFC-007 PR 3); see OperatorAlias.
#[serde(default)]
pub(crate) aliases: BTreeMap<String, OperatorAlias>,
/// 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/<id>`).
pub(crate) graph: Option<String>,
/// 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<String>,
/// Fixed default params; positionals and `--params` override per key.
#[serde(default)]
pub(crate) params: serde_yaml::Mapping,
pub(crate) format: Option<ReadOutputFormat>,
#[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");
}

View file

@ -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:?}"
);
}

View file

@ -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 <name>` (atomic write, `0600`). Legacy mechanisms
untouched and tested-as-untouched.
3. **PR 3 — operator targeting.** `--server <name>` on remote-capable
3. **PR 3 — operator targeting** *(landed)*. `--server <name>` 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

View file

@ -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 <name>` resolved against `omnigraph.yaml`; `cluster` commands use `--config <dir>`.
Top-level command families and subcommands. Graph-targeting commands accept a positional `URI`, `--uri`, a `--target <name>` resolved against `omnigraph.yaml`, or `--server <name>` (an operator-defined server from `~/.omnigraph/config.yaml`, optionally with `--graph <id>` for multi-graph servers; exclusive with the other forms); `cluster` commands use `--config <dir>`.
## 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 <name>` 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 <server>/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: