feat(cli)!: excise omnigraph.yaml from the CLI; policy/queries tooling reads --cluster (#251)

The server already dropped omnigraph.yaml (cluster-only boot). This removes the
CLI's last use of the legacy `OmnigraphConfig`: graphs are addressed only via
`--store`/`--server`/`--cluster`/`--profile`/operator defaults, and actor,
output format, and bearer credentials come from `~/.omnigraph/config.yaml`.
After this change no CLI command reads `omnigraph.yaml` except `config migrate`.

Resolvers (helpers.rs): drop every legacy fallback —
- `resolve_actor` → `--as` > `operator.actor` (no `cli.actor`);
- `resolve_read_format` → `--json`/`--format` > alias > `defaults.output`;
- `resolve_branch`/`resolve_read_target` → `--branch` > alias > "main";
- `resolve_uri`/`resolve_cli_graph` → scope path only; an absent address is a
  loud error;
- `resolve_remote_bearer_token` → operator keyed chain + `OMNIGRAPH_BEARER_TOKEN`.
`GraphClient::resolve`/`resolve_with_policy` drop the `&OmnigraphConfig` param;
direct-store access carries no Cedar policy (policy lives in the cluster/server).

Flags (cli.rs): remove `--config` from every data/query command; it stays only
on `cluster *` (the cluster dir) and `config migrate` (the legacy path).

Re-home control-plane tooling to `--cluster` (RFC-011):
- `policy validate|test|explain` source the Cedar bundle from the cluster's
  applied policies; `--graph` picks a graph's bundle; `policy test` takes
  `--tests <file>`;
- `queries list|validate` source the registry + schemas from the cluster
  serving snapshot; `--graph` scopes to one graph;
- `lint` requires `--schema` (offline) or a direct/cluster graph target;
- `schema plan`/`lint` route their graph-target through the shared direct-scope
  resolver so `--store`/`--profile`/`defaults.store` addressing works.

Tests migrate from `omnigraph.yaml` fixtures to `--store`/operator-config/
`--cluster` (converged-cluster fixtures); the now-impossible command-path
RFC-008 tests are deleted (`config migrate` coverage kept). The
`OmnigraphConfig` type, `load_config`/deprecation machinery, and `config
migrate` are removed in a follow-up.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Andrew Altshuler 2026-06-15 21:48:39 +03:00 committed by GitHub
parent 8b01c6e547
commit 0bee746a31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1464 additions and 2262 deletions

View file

@ -683,51 +683,8 @@ fn cluster_apply_locked_exits_nonzero() {
assert!(!temp.path().join("__cluster/resources").exists());
}
#[test]
fn cluster_apply_uses_cli_actor_from_local_config() {
let temp = tempdir().unwrap();
write_cluster_config_fixture(temp.path());
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-local\n",
)
.unwrap();
// Phase 1: import once (setup, not under test).
let output = cli()
.current_dir(temp.path())
.arg("cluster")
.arg("import")
.arg("--config")
.arg(temp.path())
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
// Phase 2: apply alone, capturing the echoed actor (idempotent re-runs).
let apply = |extra: &[&str]| {
let mut command = cli();
command.current_dir(temp.path());
for arg in extra {
command.arg(arg);
}
let output = command
.arg("cluster")
.arg("apply")
.arg("--config")
.arg(temp.path())
.arg("--json")
.output()
.unwrap();
let json: serde_json::Value =
serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap();
json["actor"].clone()
};
assert_eq!(apply(&[]), "act-local", "cli.actor is the no-flag default");
assert_eq!(apply(&["--as", "andrew"]), "andrew", "--as overrides cli.actor");
}
/// RFC-007 PR 1: the operator layer joins the actor chain —
/// `--as` > legacy `cli.actor` (RFC-008 window) > `operator.actor` > none.
/// RFC-011: the actor chain is `--as` > `operator.actor` > none. The CLI no
/// longer reads omnigraph.yaml `cli.actor`.
#[test]
fn cluster_apply_uses_operator_actor_from_omnigraph_home() {
let temp = tempdir().unwrap();
@ -771,41 +728,31 @@ fn cluster_apply_uses_operator_actor_from_omnigraph_home() {
json["actor"].clone()
};
// No --as, no omnigraph.yaml: the operator identity applies.
// No --as: the operator identity applies.
assert_eq!(
apply(&[]),
"act-operator",
"operator.actor is the no-flag, no-legacy-config default"
"operator.actor is the no-flag default"
);
// --as still wins over everything.
// --as still wins over the operator layer.
assert_eq!(apply(&["--as", "andrew"]), "andrew");
// A legacy cli.actor (RFC-008 window) outranks the operator layer.
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-legacy\n",
)
.unwrap();
assert_eq!(
apply(&[]),
"act-legacy",
"legacy cli.actor wins over operator.actor during the deprecation window"
);
}
#[test]
fn cluster_approve_uses_cli_actor_fallback() {
fn cluster_approve_uses_operator_actor_fallback() {
let temp = tempdir().unwrap();
write_cluster_config_fixture(temp.path());
let operator_home = tempdir().unwrap();
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-local\n",
operator_home.path().join("config.yaml"),
"operator:\n actor: act-operator\n",
)
.unwrap();
// Converge, then remove the graph so a gated delete is pending.
for command in ["import", "apply"] {
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("cluster")
.arg(command)
.arg("--config")
@ -818,6 +765,7 @@ fn cluster_approve_uses_cli_actor_fallback() {
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("cluster")
.arg("approve")
.arg("graph.knowledge")
@ -829,14 +777,17 @@ fn cluster_approve_uses_cli_actor_fallback() {
assert!(output.status.success(), "{output:?}");
let json: serde_json::Value =
serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap();
assert_eq!(json["approved_by"], "act-local");
assert_eq!(json["approved_by"], "act-operator");
// With neither flag nor config: refused with the actionable message.
// With neither flag nor operator config: refused with the actionable
// message (an approval without an approver is meaningless).
let bare = tempdir().unwrap();
write_cluster_config_fixture(bare.path());
let bare_home = tempdir().unwrap();
let output = output_failure(
cli()
.current_dir(bare.path())
.env("OMNIGRAPH_HOME", bare_home.path())
.arg("cluster")
.arg("approve")
.arg("graph.knowledge")
@ -845,11 +796,13 @@ fn cluster_approve_uses_cli_actor_fallback() {
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("--as"), "{stderr}");
assert!(stderr.contains("cli.actor"), "{stderr}");
}
#[test]
fn cluster_commands_ignore_malformed_local_config() {
fn cluster_commands_ignore_legacy_omnigraph_yaml() {
// RFC-011: the CLI never reads omnigraph.yaml for cluster commands — a
// present (even malformed) legacy file is inert. The actor falls back to
// `operator.actor`, then to none (no loud failure on absence).
let temp = tempdir().unwrap();
write_cluster_config_fixture(temp.path());
fs::write(temp.path().join("omnigraph.yaml"), "{{{{ not yaml").unwrap();
@ -873,14 +826,11 @@ fn cluster_commands_ignore_malformed_local_config() {
"cluster {command} touched omnigraph.yaml"
);
}
// import + apply with an explicit --as: the config is never loaded.
for (command, args) in [("import", vec![]), ("apply", vec!["--as", "andrew"])] {
let mut invocation = cli();
invocation.current_dir(temp.path());
for arg in &args {
invocation.arg(arg);
}
let output = invocation
// import + apply (no --as, no operator config): the legacy file is never
// loaded and the no-actor apply succeeds (actor defaults to none).
for command in ["import", "apply"] {
let output = cli()
.current_dir(temp.path())
.arg("cluster")
.arg(command)
.arg("--config")
@ -893,20 +843,6 @@ fn cluster_commands_ignore_malformed_local_config() {
String::from_utf8_lossy(&output.stderr)
);
}
// Only the no-flag actor lookup is allowed to fail, and loudly.
let output = output_failure(
cli()
.current_dir(temp.path())
.arg("cluster")
.arg("apply")
.arg("--config")
.arg(temp.path()),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("omnigraph.yaml") && stderr.contains("--as"),
"the actor-default config read must fail loudly and actionably: {stderr}"
);
}
#[test]

View file

@ -599,13 +599,15 @@ query list_people() {
}
#[test]
fn query_lint_can_resolve_graph_and_query_from_config() {
fn query_lint_can_resolve_graph_from_store_scope() {
// RFC-011: lint resolves its graph target through `--store` (the direct
// scope), not omnigraph.yaml's cli.graph; the .gq path is plain cwd-relative.
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config_path = temp.path().join("omnigraph.yaml");
init_graph(&graph);
let query_path = temp.path().join("queries.gq");
write_query_file(
&temp.path().join("queries.gq"),
&query_path,
r#"
query list_people() {
match { $p: Person }
@ -613,16 +615,15 @@ query list_people() {
}
"#,
);
write_config(&config_path, &local_yaml_config(&graph));
let output = output_success(
cli()
.arg("query")
.arg("lint")
.arg("--query")
.arg("queries.gq")
.arg("--config")
.arg(&config_path)
.arg(&query_path)
.arg("--store")
.arg(&graph)
.arg("--json"),
);
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
@ -690,7 +691,9 @@ query list_people() {
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("lint requires --schema <schema.pg> or a resolvable graph target")
stderr.contains("lint requires --schema <schema.pg>")
|| stderr.contains("no graph addressed"),
"expected a schema-or-graph-target requirement; got: {stderr}"
);
}
@ -987,43 +990,38 @@ fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() {
);
}
// RFC-011: `policy validate|test|explain` source the Cedar bundle from a
// converged cluster's applied policies (`--cluster <dir>` + `--graph <id>`),
// not omnigraph.yaml's policy.file.
#[test]
fn policy_validate_accepts_valid_policy_file() {
let temp = tempdir().unwrap();
let (config, _) = write_policy_config_fixture(temp.path());
fn policy_validate_accepts_cluster_bundle() {
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
let output = output_success(
cli()
.arg("policy")
.arg("validate")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge"),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("policy valid:"));
assert!(stdout.contains("policy.yaml"));
assert!(stdout.contains("[2 actors]"));
}
#[test]
fn policy_validate_fails_for_invalid_policy_file() {
let temp = tempdir().unwrap();
let config = temp.path().join("omnigraph.yaml");
let policy = temp.path().join("policy.yaml");
fs::write(
&config,
r#"
project:
name: policy-test-graph
policy:
file: ./policy.yaml
"#,
)
.unwrap();
fs::write(
&policy,
r#"
fn policy_validate_fails_for_invalid_cluster_bundle() {
// The cluster does not validate a policy bundle's internal rules, so an
// applied-but-malformed bundle reaches `policy validate`, which compiles it
// and surfaces the error (here: a duplicate rule id).
let cluster = converged_loaded_cluster(
"knowledge",
Some(
r#"
version: 1
groups:
team: [act-andrew]
@ -1039,26 +1037,42 @@ rules:
actions: [export]
branch_scope: any
"#,
)
.unwrap();
),
);
let output = output_failure(
cli()
.arg("policy")
.arg("validate")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("duplicate policy rule id"));
assert!(
stderr.contains("duplicate policy rule id"),
"expected a duplicate-rule error; got: {stderr}"
);
}
#[test]
fn policy_test_runs_declarative_cases() {
let temp = tempdir().unwrap();
let (config, _) = write_policy_config_fixture(temp.path());
fn policy_test_runs_declarative_cases_against_cluster_bundle() {
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
let tests = cluster.path().join("policy.tests.yaml");
fs::write(&tests, POLICY_TESTS_YAML).unwrap();
let output = output_success(cli().arg("policy").arg("test").arg("--config").arg(&config));
let output = output_success(
cli()
.arg("policy")
.arg("test")
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.arg("--tests")
.arg(&tests),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("policy tests passed: 2 cases"));
@ -1066,15 +1080,16 @@ fn policy_test_runs_declarative_cases() {
#[test]
fn policy_explain_reports_decision_and_matched_rule() {
let temp = tempdir().unwrap();
let (config, _) = write_policy_config_fixture(temp.path());
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
let allow = output_success(
cli()
.arg("policy")
.arg("explain")
.arg("--config")
.arg(&config)
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.arg("--actor")
.arg("act-andrew")
.arg("--action")
@ -1090,8 +1105,10 @@ fn policy_explain_reports_decision_and_matched_rule() {
cli()
.arg("policy")
.arg("explain")
.arg("--config")
.arg(&config)
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.arg("--actor")
.arg("act-bruno")
.arg("--action")
@ -1105,19 +1122,24 @@ fn policy_explain_reports_decision_and_matched_rule() {
}
#[test]
fn read_can_resolve_uri_from_config() {
fn read_resolves_uri_from_default_store_scope() {
// RFC-011: a zero-flag read resolves its graph from `defaults.store` in the
// operator config (the local-dev default scope) — no omnigraph.yaml.
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
init_graph(&graph);
load_fixture(&graph);
write_config(&config, &local_yaml_config(&graph));
let home = tempdir().unwrap();
std::fs::write(
home.path().join("config.yaml"),
format!("defaults:\n store: {}\n", graph.to_string_lossy()),
)
.unwrap();
let output = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("read")
.arg("--config")
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("get_person")
@ -1278,13 +1300,13 @@ query insert_person($name: String, $age: I32) {
}
#[test]
fn change_can_resolve_uri_and_branch_from_config() {
fn change_resolves_uri_and_default_branch_from_store_scope() {
// RFC-011: a mutate resolves its graph from `--store` and defaults the
// branch to main (no omnigraph.yaml cli.graph / cli.branch).
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
init_graph(&graph);
load_fixture(&graph);
write_config(&config, &local_yaml_config(&graph));
let mutation_file = temp.path().join("config-mutations.gq");
write_query_file(
&mutation_file,
@ -1298,8 +1320,8 @@ query insert_person($name: String, $age: I32) {
let output = output_success(
cli()
.arg("change")
.arg("--config")
.arg(&config)
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&mutation_file)
.arg("--params")
@ -1896,19 +1918,17 @@ fn snapshot_json_returns_manifest_version_and_tables() {
}
#[test]
fn snapshot_can_resolve_uri_from_config() {
fn snapshot_resolves_uri_from_store_scope() {
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
init_graph(&graph);
load_fixture(&graph);
write_config(&config, &local_yaml_config(&graph));
let output = output_success(
cli()
.arg("snapshot")
.arg("--config")
.arg(&config)
.arg("--store")
.arg(&graph)
.arg("--json"),
);
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();

View file

@ -94,90 +94,91 @@ fn alias_unknown_name_errors_listing_defined() {
);
}
// RFC-011: `queries validate`/`list` source the registry + schemas from a
// converged cluster's applied state (`--cluster <dir>`), not omnigraph.yaml.
/// Build a converged single-graph cluster (id `knowledge`) with one stored
/// query. `query_block` is the YAML under the graph's `queries:` key.
fn converged_cluster_with_query(query_file: &str, query_src: &str, query_block: &str) -> tempfile::TempDir {
let temp = tempdir().unwrap();
let dir = temp.path();
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
write_query_file(&dir.join(query_file), query_src);
std::fs::write(
dir.join("cluster.yaml"),
format!(
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n{query_block}"
),
)
.unwrap();
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
temp
}
#[test]
fn queries_validate_exits_zero_on_clean_registry() {
let graph = SystemGraph::loaded();
graph.write_query(
let cluster = converged_cluster_with_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&queries_test_config(
&graph.path().to_string_lossy(),
"find_person",
"find_person.gq",
),
" find_person:\n file: ./find_person.gq\n",
);
let output = output_success(
cli()
.arg("queries")
.arg("validate")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path()),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
}
#[test]
fn queries_validate_exits_nonzero_on_type_broken_query() {
let graph = SystemGraph::loaded();
// `Widget` is not in the fixture schema.
graph.write_query(
"ghost.gq",
fn cluster_import_rejects_a_type_broken_query() {
// In the cluster model a stored query is type-checked at the cluster
// boundary (import/apply), so a broken query can never reach the applied
// state `queries validate` reads — the gate is upstream. `Widget` is not in
// the fixture schema, so import must reject it, naming the query.
let temp = tempdir().unwrap();
let dir = temp.path();
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
write_query_file(
&dir.join("ghost.gq"),
"query ghost() { match { $w: Widget } return { $w.name } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"),
std::fs::write(
dir.join("cluster.yaml"),
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n ghost:\n file: ./ghost.gq\n",
)
.unwrap();
let output = output_failure(cli().arg("cluster").arg("import").arg("--config").arg(dir));
let combined = format!(
"{}{}",
stdout_string(&output),
String::from_utf8_lossy(&output.stderr)
);
let output = output_failure(
cli()
.arg("queries")
.arg("validate")
.arg("--config")
.arg(&config),
);
let stdout = stdout_string(&output);
assert!(
stdout.contains("ghost"),
"validation should name the broken query; stdout:\n{stdout}"
combined.contains("ghost"),
"cluster import must reject the broken query, naming it; got:\n{combined}"
);
}
#[test]
fn queries_list_prints_registered_query() {
let graph = SystemGraph::loaded();
graph.write_query(
let cluster = converged_cluster_with_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
// Exposed with an explicit tool name so the list shows the MCP suffix.
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" find_person:\n",
" file: ./find_person.gq\n",
" mcp: {{ expose: true, tool_name: lookup_person }}\n",
"cli:\n",
" graph: local\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
" find_person:\n file: ./find_person.gq\n",
);
let output = output_success(
cli()
.arg("queries")
.arg("list")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path()),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("find_person"), "stdout:\n{stdout}");
@ -185,242 +186,37 @@ fn queries_list_prints_registered_query() {
stdout.contains("$name: String"),
"list should show typed params; stdout:\n{stdout}"
);
assert!(
stdout.contains("[mcp: lookup_person]"),
"list should show the MCP tool name for exposed queries; stdout:\n{stdout}"
);
}
#[test]
fn queries_list_requires_graph_selection_for_per_graph_only_registries() {
let graph = SystemGraph::loaded();
graph.write_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" find_person:\n",
" file: ./find_person.gq\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
);
let output = output_failure(
cli()
.arg("queries")
.arg("list")
.arg("--config")
.arg(&config),
);
fn queries_validate_requires_a_cluster() {
// RFC-011: with no --cluster (and no cluster profile), the command errors
// loudly rather than reading any omnigraph.yaml.
let output = output_failure(cli().arg("queries").arg("validate"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("local") && stderr.contains("set `cli.graph`"),
"error must name the graph and give a concrete selection hint; stderr:\n{stderr}"
stderr.contains("needs a cluster") || stderr.contains("--cluster"),
"queries validate must require a cluster; stderr:\n{stderr}"
);
}
#[test]
fn queries_list_without_graph_selection_lists_top_level_registry() {
let graph = SystemGraph::loaded();
graph.write_query(
"top_find.gq",
"query top_find($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
concat!(
"queries:\n",
" top_find:\n",
" file: ./top_find.gq\n",
"policy: {}\n",
),
);
let output = output_success(
cli()
.arg("queries")
.arg("list")
.arg("--config")
.arg(&config),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("top_find"), "stdout:\n{stdout}");
}
#[test]
fn queries_list_unknown_cli_graph_errors() {
// `queries list` opens no graph URI, so unknown-graph validation can't ride
// along on URI resolution the way it does for every other command. An
// unknown `cli.graph` selection must still error (naming the graph) instead
// of silently falling back to the top-level registry and showing the wrong
// (or empty) catalog. (`--target` was removed; `cli.graph` drives selection.)
let graph = SystemGraph::loaded();
graph.write_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
"graphs:\n local:\n uri: '{}'\n queries:\n find_person:\n file: ./find_person.gq\ncli:\n graph: nonexistent\npolicy: {{}}\n",
graph.path().to_string_lossy().replace('\'', "''"),
),
);
let output = output_failure(cli().arg("queries").arg("list").arg("--config").arg(&config));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("nonexistent"),
"error must name the unknown graph; stderr:\n{stderr}"
);
}
#[test]
fn queries_commands_reject_named_graph_with_populated_top_level_block() {
// A named graph (here via `cli.graph`) uses its own `graphs.<name>` block,
// so a populated top-level `queries:` block would be silently ignored — a
// config the server REFUSES to boot. `queries validate`/`list` must reject
// it too (matching boot) instead of validating/listing the per-graph block
// and giving a false green.
let graph = SystemGraph::loaded();
graph.write_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" find_person:\n",
" file: ./find_person.gq\n",
"cli:\n",
" graph: local\n",
"queries:\n", // populated top-level block: the coherence violation
" legacy:\n",
" file: ./legacy.gq\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
);
// Both resolve `local` from cli.graph (no positional URI), so both must
// error and name the graph + the ignored block — like server boot does.
for sub in ["validate", "list"] {
let output = output_failure(cli().arg("queries").arg(sub).arg("--config").arg(&config));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("local") && stderr.contains("queries"),
"`queries {sub}` must reject a named graph with a populated top-level block; stderr:\n{stderr}"
);
}
}
#[test]
fn queries_validate_exits_nonzero_on_duplicate_tool_name() {
// Two exposed queries claiming one MCP tool name is a load-time
// collision — `queries validate` must fail (offline, before the engine
// opens) and name both queries plus the contested tool.
let graph = SystemGraph::loaded();
graph.write_query(
"a.gq",
"query a() { match { $p: Person } return { $p.name } }",
);
graph.write_query(
"b.gq",
"query b() { match { $p: Person } return { $p.name } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" a:\n",
" file: ./a.gq\n",
" mcp: {{ expose: true, tool_name: dup }}\n",
" b:\n",
" file: ./b.gq\n",
" mcp: {{ expose: true, tool_name: dup }}\n",
"cli:\n",
" graph: local\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
);
let output = output_failure(
cli()
.arg("queries")
.arg("validate")
.arg("--config")
.arg(&config),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("dup") && stderr.contains("'a'") && stderr.contains("'b'"),
"duplicate tool name should be reported naming both queries; stderr:\n{stderr}"
);
}
#[test]
fn queries_validate_positional_uri_ignores_default_graph() {
// A positional URI is anonymous → the schema AND the registry both come
// from top-level, even when `cli.graph` names a graph whose per-graph
// queries would fail. Pins that the URI and registry can't diverge.
let graph = SystemGraph::loaded();
graph.write_query(
"clean.gq",
"query clean($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
// `Widget` is not in the fixture schema — the default graph's per-graph
// query would break validate if it were (wrongly) selected.
graph.write_query(
"broken.gq",
"query broken() { match { $w: Widget } return { $w.name } }",
);
let config = graph.write_config(
"omnigraph.yaml",
concat!(
"cli:\n graph: prod\n",
"graphs:\n",
" prod:\n",
" uri: /nonexistent-prod.omni\n",
" queries:\n",
" broken:\n",
" file: ./broken.gq\n",
"queries:\n",
" clean:\n",
" file: ./clean.gq\n",
"policy: {}\n",
),
);
// Positional URI = the real loaded graph; selection is anonymous, so the
// CLEAN top-level registry validates (not prod's broken one).
fn queries_validate_graph_filter_selects_one_graph() {
// A multi-graph cluster: validate scoped to `knowledge` type-checks only
// that graph's registry, ignoring `engineering`'s.
let temp = tempdir().unwrap();
let dir = temp.path();
write_multi_graph_cluster_fixture(dir);
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
let output = output_success(
cli()
.arg("queries")
.arg("validate")
.arg(graph.path())
.arg("--config")
.arg(&config),
);
let stdout = stdout_string(&output);
assert!(
stdout.contains("OK"),
"positional URI must validate the top-level registry, not the cli.graph default; stdout:\n{stdout}"
.arg("--cluster")
.arg(dir)
.arg("--graph")
.arg("knowledge"),
);
assert!(stdout_string(&output).contains("OK"));
}

View file

@ -546,60 +546,22 @@ fn graphs_subcommand_help_lists_list_only() {
#[test]
fn graphs_list_against_local_uri_errors_with_remote_only_message() {
// RFC-011: `graphs list` is served-only; a `--store` (local) address has no
// enumeration endpoint, so it fails loudly pointing at a server / cluster.
let output = output_failure(
cli()
.arg("graphs")
.arg("list")
.arg("--uri")
.arg("--store")
.arg("/tmp/local"),
);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
stderr.contains("remote multi-graph server URL"),
"expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}"
stderr.contains("remote multi-graph server"),
"expected a remote-server rejection in stderr; got:\n{stderr}"
);
}
/// RFC-008 stage 1: loading a legacy omnigraph.yaml emits the per-key
/// deprecation block (the migration map applied to THIS file), suppressible
/// via OMNIGRAPH_SUPPRESS_YAML_DEPRECATION.
#[test]
fn legacy_config_load_warns_per_key_and_suppression_silences() {
let temp = tempdir().unwrap();
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-x\ngraphs:\n g:\n uri: /tmp/never-opened\n",
)
.unwrap();
// `graphs list --json` loads the config and exits without touching the
// graph URI.
let output = cli()
.current_dir(temp.path())
.arg("graphs")
.arg("list")
.arg("--json")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("deprecated (RFC-008)") && stderr.contains("`cli.actor` -> `operator.actor`"),
"{stderr}"
);
assert!(stderr.contains("config migrate"), "{stderr}");
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
.arg("graphs")
.arg("list")
.arg("--json")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.contains("deprecated (RFC-008)"), "{stderr}");
}
/// RFC-008 stage 2: `config migrate` proposes the split read-only, applies
/// it with --write (operator merge never clobbers; cluster.yaml emitted),
/// and a second --write is idempotent.
@ -671,38 +633,3 @@ fn config_migrate_splits_legacy_config() {
assert!(temp.path().join("cluster.yaml.proposed").exists());
}
/// RFC-008 stage 4: OMNIGRAPH_NO_LEGACY_CONFIG refuses a present legacy
/// file (pointing at config migrate) but changes nothing on migrated
/// setups with no file.
#[test]
fn strict_mode_refuses_legacy_file_but_not_its_absence() {
let temp = tempdir().unwrap();
fs::write(temp.path().join("omnigraph.yaml"), "cli:\n actor: a\n").unwrap();
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
.arg("graphs")
.arg("list")
.arg("--json")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("OMNIGRAPH_NO_LEGACY_CONFIG") && stderr.contains("config migrate"),
"{stderr}"
);
// Migrated setup (no file): strict mode is a no-op — a config-loading
// command that tolerates empty defaults succeeds.
let clean = tempdir().unwrap();
let output = cli()
.current_dir(clean.path())
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
.arg("queries")
.arg("list")
.arg("--json")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
}

View file

@ -25,13 +25,12 @@ const KNOWN_DIVERGENCES: &[&str] = &[
// populated by the rows below as they are written
];
/// One matched setup per row: twin graphs + the SAME Cedar bundle on both
/// arms (the local arm via --config top-level policy.file; the server via
/// its config). Returns everything a row needs.
/// One matched setup per row: twin graphs + the parity Cedar bundle on the
/// served arm. The local (`--store`) arm carries no policy (RFC-011); the
/// bundle is permissive for `act-parity`, so the arms still agree.
struct Parity {
_temp: TempDir,
local: std::path::PathBuf,
local_cfg: std::path::PathBuf,
server: TestServer,
}
@ -40,7 +39,7 @@ fn parity() -> Parity {
// RFC-011 cluster-only: the remote arm is served from a converged
// cluster directory (one graph, id `parity`), seeded with the same
// fixture data as the local twin.
let (local_cfg, cluster_dir) = parity_configs(temp.path(), &local, &remote);
let cluster_dir = parity_configs(temp.path(), &local, &remote);
let server = spawn_server_with_cluster_env(
&cluster_dir,
&[(
@ -51,14 +50,13 @@ fn parity() -> Parity {
Parity {
_temp: temp,
local,
local_cfg,
server,
}
}
impl Parity {
fn run(&self, args: &[&str]) -> (std::process::Output, std::process::Output) {
run_both_with_config(&self.local, Some(&self.local_cfg), &self.server.base_url, args)
run_both(&self.local, &self.server.base_url, args)
}
}

View file

@ -850,35 +850,25 @@ rules:
/// server is cluster-only, so a graph selector is required).
pub const PARITY_GRAPH_ID: &str = "parity";
/// Build both arms' configuration (RFC-011 cluster-only server).
/// Build the remote arm's configuration (RFC-011 cluster-only server).
///
/// * Local arm: a `--config` file carrying the TOP-LEVEL `policy.file`
/// (single-graph embedded semantics), used as-is by `run_both_with_config`.
/// * Remote arm: a converged cluster directory whose single graph (id
/// `parity`) carries the SAME Cedar bundle (bound to the graph scope).
/// The cluster's derived graph root (`<dir>/graphs/parity.omni`) is
/// seeded with the SAME fixture data as the local twin so the two arms
/// compare like-for-like.
/// The remote arm is served from a converged cluster directory whose single
/// graph (id `parity`) carries the parity Cedar bundle (bound to the graph
/// scope). The cluster's derived graph root (`<dir>/graphs/parity.omni`) is
/// seeded with the SAME fixture data as the local twin so the two arms compare
/// like-for-like. The local (`--store`) arm carries no Cedar policy (RFC-011),
/// which is fine because the parity bundle is permissive for `act-parity`.
///
/// `local_graph` is overwritten with a byte-for-byte copy of the cluster's
/// seeded served graph so identity-bearing values that are NOT scrubbed
/// (e.g. `graph_commit_id`, edge `id`s in export) match across the arms —
/// the served graph is the source of truth and the local twin mirrors it.
///
/// Returns `(local_config_path, cluster_dir)`. The caller spawns the
/// server with `--cluster <cluster_dir>`.
pub fn parity_configs(root: &Path, local_graph: &Path, _remote_graph: &Path) -> (PathBuf, PathBuf) {
/// Returns the `cluster_dir`. The caller spawns the server with `--cluster`.
pub fn parity_configs(root: &Path, local_graph: &Path, _remote_graph: &Path) -> PathBuf {
let policy = root.join("parity.policy.yaml");
fs::write(&policy, parity_policy_yaml()).unwrap();
// Local arm config: top-level single-graph policy.
let local_cfg = root.join("local.omnigraph.yaml");
fs::write(
&local_cfg,
format!("policy:\n file: {}\n", policy.display()),
)
.unwrap();
// Remote arm: a cluster directory the server boots from. One graph
// (`parity`), schema = the shared fixture, policy bound to the graph.
let cluster_dir = root.join("parity-cluster");
@ -942,7 +932,7 @@ policies:
}
copy_dir(&served_root, local_graph);
(local_cfg, cluster_dir)
cluster_dir
}
/// Run one CLI invocation per arm with identical verb args: locally against
@ -953,21 +943,14 @@ pub fn run_both(
local_graph: &Path,
server_url: &str,
args: &[&str],
) -> (std::process::Output, std::process::Output) {
run_both_with_config(local_graph, None, server_url, args)
}
pub fn run_both_with_config(
local_graph: &Path,
local_config: Option<&Path>,
server_url: &str,
args: &[&str],
) -> (std::process::Output, std::process::Output) {
// Address both arms with GLOBAL flags (`--store` / `--server`) appended after
// the verb + its args, so the address is placed correctly regardless of
// subcommand nesting (a positional graph only works for top-level verbs;
// `schema show <graph>` etc. need the global flag). Local = embedded store,
// remote = served.
// remote = served. RFC-011: a direct (`--store`) write carries no Cedar
// policy — the parity policy is permissive for `act-parity` on the served
// arm, so the two arms still agree.
let mut local = cli();
local
.args(args)
@ -975,9 +958,6 @@ pub fn run_both_with_config(
.arg(local_graph)
.arg("--as")
.arg(PARITY_ACTOR);
if let Some(config) = local_config {
local.arg("--config").arg(config);
}
let local_out = local.output().unwrap();
let mut remote = cli();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff