From db57cb2b1f8ac07af174fe1d6766f04d3af24a22 Mon Sep 17 00:00:00 2001 From: viveknathani Date: Wed, 5 Aug 2026 10:34:38 +0530 Subject: [PATCH 1/2] fix(cluster): validate policy bundle semantics --- crates/omnigraph-cli/tests/cli_cluster.rs | 44 +++++++++- crates/omnigraph-cli/tests/cli_data.rs | 22 ++--- crates/omnigraph-cli/tests/support/mod.rs | 12 ++- crates/omnigraph-cluster/src/config.rs | 13 ++- crates/omnigraph-cluster/src/tests.rs | 87 ++++++++++++++++++-- crates/omnigraph-cluster/tests/failpoints.rs | 6 +- docs/dev/testing.md | 2 +- docs/user/clusters/config.md | 2 + 8 files changed, 161 insertions(+), 27 deletions(-) diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index 0bdad871..df3f2846 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -25,6 +25,44 @@ fn cluster_validate_config_success() { assert!(stdout.contains("cluster config valid"), "{stdout}"); } +#[test] +fn cluster_validate_rejects_semantically_invalid_policy() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + fs::write( + temp.path().join("base.policy.yaml"), + r#" +version: 1 +groups: + team: [act-andrew] +rules: + - id: invalid-invoke-scope + allow: + actors: { group: team } + actions: [invoke_query] + branch_scope: any +"#, + ) + .unwrap(); + + let output = output_failure( + cli() + .arg("cluster") + .arg("validate") + .arg("--config") + .arg(temp.path()), + ); + let stdout = stdout_string(&output); + assert!( + stdout.contains("ERROR policy_invalid policies.base.file"), + "{stdout}" + ); + assert!( + stdout.contains("branch_scope") && stdout.contains("invoke_query"), + "{stdout}" + ); +} + #[test] fn cluster_validate_json_is_stable() { let temp = tempdir().unwrap(); @@ -989,7 +1027,11 @@ fn applied_two_graph_cluster() -> tempfile::TempDir { "node Person {\n name: String @key\n age: I32?\n}\n", ) .unwrap(); - fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap(); + fs::write( + root.join("base.policy.yaml"), + "version: 1\nrules: []\n", + ) + .unwrap(); fs::write( root.join("cluster.yaml"), r#" diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index 72a9adaf..1dec63ef 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -1030,10 +1030,10 @@ fn policy_validate_accepts_cluster_bundle() { } #[test] -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). +fn policy_validate_fails_for_wrong_kind_cluster_bundle() { + // Cluster validation owns bundle-wide syntax and semantic rules. The + // policy command additionally loads the bundle for its selected serving + // slot, so a server-scoped action bound only to a graph still fails here. let cluster = converged_loaded_cluster( "knowledge", Some( @@ -1042,16 +1042,10 @@ version: 1 groups: team: [act-andrew] rules: - - id: duplicate + - id: wrong-kind allow: actors: { group: team } - actions: [read] - branch_scope: any - - id: duplicate - allow: - actors: { group: team } - actions: [export] - branch_scope: any + actions: [graph_list] "#, ), ); @@ -1067,8 +1061,8 @@ rules: ); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( - stderr.contains("duplicate policy rule id"), - "expected a duplicate-rule error; got: {stderr}" + stderr.contains("server-scoped") && stderr.contains("graph_list"), + "expected a wrong-kind policy error; got: {stderr}" ); } diff --git a/crates/omnigraph-cli/tests/support/mod.rs b/crates/omnigraph-cli/tests/support/mod.rs index 36638b73..011e6685 100644 --- a/crates/omnigraph-cli/tests/support/mod.rs +++ b/crates/omnigraph-cli/tests/support/mod.rs @@ -712,8 +712,16 @@ query find_service($name: String) { "#, ) .unwrap(); - fs::write(root.join("cluster_wide.policy.yaml"), "rules: []\n").unwrap(); - fs::write(root.join("shared.policy.yaml"), "rules: []\n").unwrap(); + fs::write( + root.join("cluster_wide.policy.yaml"), + "version: 1\nrules: []\n", + ) + .unwrap(); + fs::write( + root.join("shared.policy.yaml"), + "version: 1\nrules: []\n", + ) + .unwrap(); fs::write( root.join("cluster.yaml"), r#" diff --git a/crates/omnigraph-cluster/src/config.rs b/crates/omnigraph-cluster/src/config.rs index d5cec30b..66b35bd0 100644 --- a/crates/omnigraph-cluster/src/config.rs +++ b/crates/omnigraph-cluster/src/config.rs @@ -984,17 +984,24 @@ pub(crate) fn load_desired(config_dir: &Path) -> LoadOutcome { policy_bindings.insert(policy_address.clone(), normalized_bindings); let policy_path = resolve_config_path(&config_dir, &policy.file); - match fs::read(&policy_path) { - Ok(bytes) => { + match fs::read_to_string(&policy_path) { + Ok(source) => { resources.insert( policy_address.clone(), ResourceSummary { address: policy_address, kind: "policy".to_string(), - digest: sha256_hex(&bytes), + digest: sha256_hex(source.as_bytes()), path: Some(display_path(&policy_path)), }, ); + if let Err(err) = omnigraph_policy::PolicyConfig::from_source(&source) { + diagnostics.push(Diagnostic::error( + "policy_invalid", + format!("policies.{policy_name}.file"), + format!("policy file '{}' is invalid: {err}", policy_path.display()), + )); + } } Err(err) => diagnostics.push(Diagnostic::error( "policy_file_missing", diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index 19e0a85e..72b1758b 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -37,11 +37,13 @@ query find_person($name: String) { } "#; + const POLICY: &str = "version: 1\nrules: []\n"; + fn fixture() -> tempfile::TempDir { let dir = tempdir().unwrap(); fs::write(dir.path().join("people.pg"), SCHEMA).unwrap(); fs::write(dir.path().join("people.gq"), QUERY).unwrap(); - fs::write(dir.path().join("base.policy.yaml"), "rules: []\n").unwrap(); + fs::write(dir.path().join("base.policy.yaml"), POLICY).unwrap(); fs::write( dir.path().join(CLUSTER_CONFIG_FILE), r#" @@ -236,6 +238,48 @@ policies: assert!(codes.contains("policy_file_missing")); } + #[test] + fn semantically_invalid_policy_bundle_fails_validation() { + for (action, scope) in [ + ("invoke_query", "branch_scope"), + ("schema_apply", "branch_scope"), + ] { + let dir = fixture(); + fs::write( + dir.path().join("base.policy.yaml"), + format!( + r#" +version: 1 +groups: + team: [act-andrew] +rules: + - id: invalid-scope + allow: + actors: {{ group: team }} + actions: [{action}] + {scope}: any +"# + ), + ) + .unwrap(); + + let out = validate_config_dir(dir.path()); + assert!(!out.ok, "{action} with {scope} must be rejected"); + let diagnostic = out + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "policy_invalid") + .unwrap_or_else(|| { + panic!("missing policy_invalid diagnostic: {:?}", out.diagnostics) + }); + assert_eq!(diagnostic.path, "policies.base.file"); + assert!( + diagnostic.message.contains(scope) && diagnostic.message.contains(action), + "unexpected diagnostic: {diagnostic:?}" + ); + } + } + #[test] fn wrong_kind_and_dangling_refs_fail() { let dir = fixture(); @@ -1298,7 +1342,7 @@ graphs: let query_blob = query_payload_path(dir.path(), &query_digest); assert_eq!(fs::read_to_string(&query_blob).unwrap(), QUERY); let policy_blob = policy_payload_path(dir.path(), &policy_digest); - assert_eq!(fs::read_to_string(&policy_blob).unwrap(), "rules: []\n"); + assert_eq!(fs::read_to_string(&policy_blob).unwrap(), POLICY); let state = read_state_json(dir.path()); assert_eq!(state["state_revision"], 2); @@ -2026,7 +2070,11 @@ graphs: assert!(approved.ok, "{:?}", approved.diagnostics); // The config moves after approval: the bound config digest no longer // matches and the artifact authorizes nothing. - fs::write(dir.path().join("base.policy.yaml"), "rules: [] # moved\n").unwrap(); + fs::write( + dir.path().join("base.policy.yaml"), + "version: 1\nrules: [] # moved\n", + ) + .unwrap(); let out = apply_config_dir(dir.path()).await; assert!( @@ -2181,7 +2229,7 @@ graphs: } #[tokio::test] - async fn apply_invalid_config_fails_before_lock() { + async fn apply_invalid_config_or_policy_fails_before_lock() { let dir = fixture(); fs::write( dir.path().join(CLUSTER_CONFIG_FILE), @@ -2193,6 +2241,35 @@ graphs: assert!(!out.ok); // Config errors bail before the lock or any state directory exists. assert!(!dir.path().join(CLUSTER_STATE_DIR).exists()); + + let dir = fixture(); + fs::write( + dir.path().join("base.policy.yaml"), + r#" +version: 1 +groups: + team: [act-andrew] +rules: + - id: invalid-scope + allow: + actors: { group: team } + actions: [invoke_query] + branch_scope: any +"#, + ) + .unwrap(); + + let out = apply_config_dir(dir.path()).await; + assert!(!out.ok); + assert!( + out.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "policy_invalid"), + "{:?}", + out.diagnostics + ); + // Policy errors share the same pre-lock, pre-state refusal boundary. + assert!(!dir.path().join(CLUSTER_STATE_DIR).exists()); } /// When the state write fails after payloads landed, the output must @@ -3436,7 +3513,7 @@ policies: assert_eq!(snapshot.policies.len(), 1); assert_eq!(snapshot.policies[0].applies_to, vec!["graph.knowledge"]); // Content, not a path: the catalog may live on object storage. - // The fixture bundle is `rules: []` — assert the verified text. + // The fixture bundle has no rules — assert the verified text. assert!(snapshot.policies[0].source.contains("rules:")); } diff --git a/crates/omnigraph-cluster/tests/failpoints.rs b/crates/omnigraph-cluster/tests/failpoints.rs index 1e8d01e5..833813cb 100644 --- a/crates/omnigraph-cluster/tests/failpoints.rs +++ b/crates/omnigraph-cluster/tests/failpoints.rs @@ -43,7 +43,11 @@ fn fixture() -> tempfile::TempDir { let dir = tempdir().unwrap(); fs::write(dir.path().join("people.pg"), SCHEMA).unwrap(); fs::write(dir.path().join("people.gq"), QUERY).unwrap(); - fs::write(dir.path().join("base.policy.yaml"), "rules: []\n").unwrap(); + fs::write( + dir.path().join("base.policy.yaml"), + "version: 1\nrules: []\n", + ) + .unwrap(); fs::write( dir.path().join("cluster.yaml"), r#" diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 0e338bdf..9ed8abe1 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -9,7 +9,7 @@ This file is the always-on map of the test surface. **Consult it before every ta | `omnigraph` (engine) | `crates/omnigraph/tests/` | Integration tests (one file per behavior area — see the table below), fixture-driven, share `tests/helpers/mod.rs` | | `omnigraph-cli` | `crates/omnigraph-cli/tests/` | Per-area suites (post-modularization): `cli_cluster.rs` (cluster command surface + operator-actor cascade, including strict stream-block/dead-letter grammar, scope, plan parsing, and effect-free offline preflight), `cli_cluster_e2e.rs` (spawned-binary lifecycle compositions — lost-state re-import recovery, out-of-band drift, graph-root destruction, multi-graph mixed-disposition convergence), `cli_data.rs` (load/read/change/branch/commit/export/snapshot/policy/embed/maintenance + operator format cascade), `cli_schema_config.rs` (init/config, schema plan/apply), `cli_queries.rs`, `parity_matrix.rs` (RFC-009 Phase 1: the embedded-vs-remote referee — every forked verb run against both arms with matched Cedar policy and the same actor, scrubbed-JSON + exit-code equality; divergences are pinned in its `KNOWN_DIVERGENCES` ledger, never silently repaired), `system_local.rs` (full-cycle cluster lifecycle with a spawned `--cluster` server, applied-policy enforcement over HTTP, keyed-credential auth, operator aliases), `system_remote.rs`, `crossversion_upgrade.rs` (genuine historical source→CURRENT rebuild/refusal cells plus the required adjacent harness — see below); share `tests/support/mod.rs` (hermetic `OMNIGRAPH_HOME` by default) | | `omnigraph-control-authority` | in-source `#[cfg(test)] mod tests` | Concrete-storage, lock-derived checked authority: offline confirmation/actor/operation binding, state-CAS and graph/declaration/profile-revision validation, normalized graph-root binding, non-cloneable runtime guards, and one process-local writer registration per cluster graph. F6b1 pins a distinct served-export guard for exact terminal `DISABLED | RETIRED` state and proves it shares that registration without becoming writer authority | -| `omnigraph-cluster` | mostly in-source `#[cfg(test)] mod tests`; `tests/failpoints.rs` (feature-gated); `tests/s3_cluster.rs` (bucket-gated full lifecycle on object storage) | Cluster config parser, local JSON state diff, state CAS/lock handling/recovery, read-only validate/plan/status plus explicit refresh/import graph observations, config-only apply (content-addressed payload publish, disposition gating, composite-digest convergence, idempotent re-apply), catalog payload verification (status read-only, refresh drift + self-heal), failpoint crash-mid-apply / CAS-race coverage, graph create/schema/delete lifecycle, policy binding and serving snapshots, v11 streaming-profile ownership, authority-retirement preflight, and stopped/offline stream-control preflight. The current dead-letter owner pins actor/offline/applied-streaming/declaration/state-lock binding before selected-token list or payload export; it is inspection-only and exposes no served route. F6b1 adds only the exact-terminal served-export binding consumed at boot | +| `omnigraph-cluster` | mostly in-source `#[cfg(test)] mod tests`; `tests/failpoints.rs` (feature-gated); `tests/s3_cluster.rs` (bucket-gated full lifecycle on object storage) | Cluster config parser and semantic policy-bundle validation, local JSON state diff, state CAS/lock handling/recovery, read-only validate/plan/status plus explicit refresh/import graph observations, config-only apply (content-addressed payload publish, disposition gating, composite-digest convergence, idempotent re-apply), catalog payload verification (status read-only, refresh drift + self-heal), failpoint crash-mid-apply / CAS-race coverage, graph create/schema/delete lifecycle, policy binding and serving snapshots, v11 streaming-profile ownership, authority-retirement preflight, and stopped/offline stream-control preflight. The current dead-letter owner pins actor/offline/applied-streaming/declaration/state-lock binding before selected-token list or payload export; it is inspection-only and exposes no served route. F6b1 adds only the exact-terminal served-export binding consumed at boot | | `omnigraph-server` | `crates/omnigraph-server/tests/` | Per-area suites (post-modularization): `auth_policy.rs`, `data_routes.rs`, `schema_routes.rs`, `stored_queries.rs`, `multi_graph.rs` (cluster-mode boot — converged serving, policy binding wiring, boot refusals — + the concurrent branch-ops matrix), `boot_settings.rs` (mode inference, PolicySource), `s3.rs` (bucket-gated: single-graph serving + config-free `--cluster s3://` boot), `openapi.rs` (OpenAPI drift / regeneration); share `tests/support/mod.rs`. F5a changes no route: `serve` starts checked-runtime resident fold supervisors only after listener bind and joins every selected graph concurrently after Axum graceful shutdown; engine/failpoint owners pin the scheduler behavior. F6b1 boot consumes the terminal served-export guard and installs the hidden engine authority; it adds no handler, route, or OpenAPI surface | | `omnigraph-compiler` | mostly in-source `#[cfg(test)] mod tests` | Parser, type-checker, IR lowering, lint. Schema parser and SchemaIR validation tests both reject the five exact Lance virtual system-column property names while preserving near-miss identifiers | diff --git a/docs/user/clusters/config.md b/docs/user/clusters/config.md index da4b1294..0a34fde3 100644 --- a/docs/user/clusters/config.md +++ b/docs/user/clusters/config.md @@ -232,6 +232,8 @@ operation is active. - schema parsing and catalog construction - stored-query parsing and query-name matching - stored-query type-checking against the desired schema +- policy YAML parsing and semantic rule validation (groups, actions, and scope + compatibility) - policy `applies_to` graph references - embedding provider profiles and graph `embedding_provider` references From 541fcb50dd815d0a29d9ed527d29c6b780710f04 Mon Sep 17 00:00:00 2001 From: viveknathani Date: Wed, 5 Aug 2026 12:00:51 +0530 Subject: [PATCH 2/2] fix(cluster): validate policy binding kinds --- crates/omnigraph-cli/tests/cli_cluster.rs | 54 +++++++++++++++++++ crates/omnigraph-cli/tests/cli_data.rs | 37 ------------- crates/omnigraph-cluster/src/config.rs | 29 ++++++++-- crates/omnigraph-cluster/src/tests.rs | 57 ++++++++++++++++++-- crates/omnigraph-server/tests/multi_graph.rs | 16 ++++-- docs/dev/testing.md | 2 +- docs/user/clusters/config.md | 4 +- 7 files changed, 148 insertions(+), 51 deletions(-) diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index df3f2846..0eb37bea 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -63,6 +63,60 @@ rules: ); } +#[test] +fn cluster_validate_rejects_policy_binding_kind_mismatch() { + for (applies_to, action, scope, expected_kind) in [ + ("knowledge", "graph_list", "", "server-scoped"), + ( + "cluster", + "read", + " branch_scope: any\n", + "per-graph", + ), + ] { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let config_path = temp.path().join("cluster.yaml"); + let config = fs::read_to_string(&config_path) + .unwrap() + .replace("applies_to: [knowledge]", &format!("applies_to: [{applies_to}]")); + fs::write(config_path, config).unwrap(); + fs::write( + temp.path().join("base.policy.yaml"), + format!( + r#" +version: 1 +groups: + team: [act-andrew] +rules: + - id: wrong-kind + allow: + actors: {{ group: team }} + actions: [{action}] +{scope}"# + ), + ) + .unwrap(); + + let output = output_failure( + cli() + .arg("cluster") + .arg("validate") + .arg("--config") + .arg(temp.path()), + ); + let stdout = stdout_string(&output); + assert!( + stdout.contains("ERROR policy_invalid policies.base.file"), + "{stdout}" + ); + assert!( + stdout.contains(expected_kind) && stdout.contains(action), + "{stdout}" + ); + } +} + #[test] fn cluster_validate_json_is_stable() { let temp = tempdir().unwrap(); diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index 1dec63ef..d6c3eb00 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -1029,43 +1029,6 @@ fn policy_validate_accepts_cluster_bundle() { assert!(stdout.contains("[2 actors]")); } -#[test] -fn policy_validate_fails_for_wrong_kind_cluster_bundle() { - // Cluster validation owns bundle-wide syntax and semantic rules. The - // policy command additionally loads the bundle for its selected serving - // slot, so a server-scoped action bound only to a graph still fails here. - let cluster = converged_loaded_cluster( - "knowledge", - Some( - r#" -version: 1 -groups: - team: [act-andrew] -rules: - - id: wrong-kind - allow: - actors: { group: team } - actions: [graph_list] -"#, - ), - ); - - let output = output_failure( - cli() - .arg("policy") - .arg("validate") - .arg("--cluster") - .arg(cluster.path()) - .arg("--graph") - .arg("knowledge"), - ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("server-scoped") && stderr.contains("graph_list"), - "expected a wrong-kind policy error; got: {stderr}" - ); -} - #[test] fn policy_test_runs_declarative_cases_against_cluster_bundle() { let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML)); diff --git a/crates/omnigraph-cluster/src/config.rs b/crates/omnigraph-cluster/src/config.rs index 66b35bd0..13ff24c3 100644 --- a/crates/omnigraph-cluster/src/config.rs +++ b/crates/omnigraph-cluster/src/config.rs @@ -949,12 +949,16 @@ pub(crate) fn load_desired(config_dir: &Path) -> LoadOutcome { let policy_address = policy_address(policy_name); let mut normalized_bindings: Vec = Vec::new(); + let mut binds_cluster = false; + let mut graph_binding = None; for (idx, target) in policy.applies_to.iter().enumerate() { match normalize_policy_target(target) { PolicyTarget::Cluster => { + binds_cluster = true; normalized_bindings.push("cluster".to_string()); } PolicyTarget::Graph(graph_id) => { + graph_binding.get_or_insert_with(|| graph_id.clone()); normalized_bindings.push(graph_address(&graph_id)); if raw.graphs.contains_key(&graph_id) { dependencies.insert(Dependency { @@ -981,7 +985,6 @@ pub(crate) fn load_desired(config_dir: &Path) -> LoadOutcome { normalized_bindings.sort(); normalized_bindings.dedup(); - policy_bindings.insert(policy_address.clone(), normalized_bindings); let policy_path = resolve_config_path(&config_dir, &policy.file); match fs::read_to_string(&policy_path) { @@ -989,13 +992,32 @@ pub(crate) fn load_desired(config_dir: &Path) -> LoadOutcome { resources.insert( policy_address.clone(), ResourceSummary { - address: policy_address, + address: policy_address.clone(), kind: "policy".to_string(), digest: sha256_hex(source.as_bytes()), path: Some(display_path(&policy_path)), }, ); - if let Err(err) = omnigraph_policy::PolicyConfig::from_source(&source) { + let validation = omnigraph_policy::PolicyConfig::from_source(&source) + .and_then(|_| { + if binds_cluster { + omnigraph_policy::PolicyEngine::load_server_from_source(&source) + .map(|_| ()) + } else { + Ok(()) + } + }) + .and_then(|_| { + if let Some(graph_id) = graph_binding.as_deref() { + omnigraph_policy::PolicyEngine::load_graph_from_source( + &source, graph_id, + ) + .map(|_| ()) + } else { + Ok(()) + } + }); + if let Err(err) = validation { diagnostics.push(Diagnostic::error( "policy_invalid", format!("policies.{policy_name}.file"), @@ -1012,6 +1034,7 @@ pub(crate) fn load_desired(config_dir: &Path) -> LoadOutcome { ), )), } + policy_bindings.insert(policy_address, normalized_bindings); } let mut resource_digests = BTreeMap::new(); diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index 72b1758b..0380c983 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -280,6 +280,58 @@ rules: } } + #[test] + fn policy_binding_kind_mismatch_fails_validation() { + for (applies_to, action, scope, expected_kind) in [ + ("knowledge", "graph_list", "", "server-scoped"), + ( + "cluster", + "read", + " branch_scope: any\n", + "per-graph", + ), + ] { + let dir = fixture(); + let config_path = dir.path().join(CLUSTER_CONFIG_FILE); + let config = fs::read_to_string(&config_path) + .unwrap() + .replace("applies_to: [knowledge]", &format!("applies_to: [{applies_to}]")); + fs::write(config_path, config).unwrap(); + fs::write( + dir.path().join("base.policy.yaml"), + format!( + r#" +version: 1 +groups: + team: [act-andrew] +rules: + - id: wrong-kind + allow: + actors: {{ group: team }} + actions: [{action}] +{scope}"# + ), + ) + .unwrap(); + + let out = validate_config_dir(dir.path()); + assert!(!out.ok, "{action} must be rejected for {applies_to}"); + let diagnostic = out + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "policy_invalid") + .unwrap_or_else(|| { + panic!("missing policy_invalid diagnostic: {:?}", out.diagnostics) + }); + assert_eq!(diagnostic.path, "policies.base.file"); + assert!( + diagnostic.message.contains(expected_kind) + && diagnostic.message.contains(action), + "unexpected diagnostic: {diagnostic:?}" + ); + } + } + #[test] fn wrong_kind_and_dangling_refs_fail() { let dir = fixture(); @@ -2250,11 +2302,10 @@ version: 1 groups: team: [act-andrew] rules: - - id: invalid-scope + - id: wrong-kind allow: actors: { group: team } - actions: [invoke_query] - branch_scope: any + actions: [graph_list] "#, ) .unwrap(); diff --git a/crates/omnigraph-server/tests/multi_graph.rs b/crates/omnigraph-server/tests/multi_graph.rs index 0bc504e7..56311454 100644 --- a/crates/omnigraph-server/tests/multi_graph.rs +++ b/crates/omnigraph-server/tests/multi_graph.rs @@ -1563,10 +1563,16 @@ async fn cluster_boot_wires_policy_bindings_into_cedar_slots() { .unwrap(); fs::write( temp.path().join("cluster.policy.yaml"), - permit_all_policy_yaml(&["default"]).replace( - "protected_branches: [main]\n", - "protected_branches: [main]\nkind: server\n", - ), + r#" +version: 1 +groups: + permitted: [default] +rules: + - id: permit-graph-list + allow: + actors: { group: permitted } + actions: [graph_list] +"#, ) .unwrap(); fs::write( @@ -1613,7 +1619,7 @@ graphs: else { panic!("cluster-mode server policy must be inline content"); }; - assert!(server_policy.contains("kind: server"), "{server_policy:?}"); + assert!(server_policy.contains("graph_list"), "{server_policy:?}"); } #[tokio::test] diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 9ed8abe1..3765583e 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -9,7 +9,7 @@ This file is the always-on map of the test surface. **Consult it before every ta | `omnigraph` (engine) | `crates/omnigraph/tests/` | Integration tests (one file per behavior area — see the table below), fixture-driven, share `tests/helpers/mod.rs` | | `omnigraph-cli` | `crates/omnigraph-cli/tests/` | Per-area suites (post-modularization): `cli_cluster.rs` (cluster command surface + operator-actor cascade, including strict stream-block/dead-letter grammar, scope, plan parsing, and effect-free offline preflight), `cli_cluster_e2e.rs` (spawned-binary lifecycle compositions — lost-state re-import recovery, out-of-band drift, graph-root destruction, multi-graph mixed-disposition convergence), `cli_data.rs` (load/read/change/branch/commit/export/snapshot/policy/embed/maintenance + operator format cascade), `cli_schema_config.rs` (init/config, schema plan/apply), `cli_queries.rs`, `parity_matrix.rs` (RFC-009 Phase 1: the embedded-vs-remote referee — every forked verb run against both arms with matched Cedar policy and the same actor, scrubbed-JSON + exit-code equality; divergences are pinned in its `KNOWN_DIVERGENCES` ledger, never silently repaired), `system_local.rs` (full-cycle cluster lifecycle with a spawned `--cluster` server, applied-policy enforcement over HTTP, keyed-credential auth, operator aliases), `system_remote.rs`, `crossversion_upgrade.rs` (genuine historical source→CURRENT rebuild/refusal cells plus the required adjacent harness — see below); share `tests/support/mod.rs` (hermetic `OMNIGRAPH_HOME` by default) | | `omnigraph-control-authority` | in-source `#[cfg(test)] mod tests` | Concrete-storage, lock-derived checked authority: offline confirmation/actor/operation binding, state-CAS and graph/declaration/profile-revision validation, normalized graph-root binding, non-cloneable runtime guards, and one process-local writer registration per cluster graph. F6b1 pins a distinct served-export guard for exact terminal `DISABLED | RETIRED` state and proves it shares that registration without becoming writer authority | -| `omnigraph-cluster` | mostly in-source `#[cfg(test)] mod tests`; `tests/failpoints.rs` (feature-gated); `tests/s3_cluster.rs` (bucket-gated full lifecycle on object storage) | Cluster config parser and semantic policy-bundle validation, local JSON state diff, state CAS/lock handling/recovery, read-only validate/plan/status plus explicit refresh/import graph observations, config-only apply (content-addressed payload publish, disposition gating, composite-digest convergence, idempotent re-apply), catalog payload verification (status read-only, refresh drift + self-heal), failpoint crash-mid-apply / CAS-race coverage, graph create/schema/delete lifecycle, policy binding and serving snapshots, v11 streaming-profile ownership, authority-retirement preflight, and stopped/offline stream-control preflight. The current dead-letter owner pins actor/offline/applied-streaming/declaration/state-lock binding before selected-token list or payload export; it is inspection-only and exposes no served route. F6b1 adds only the exact-terminal served-export binding consumed at boot | +| `omnigraph-cluster` | mostly in-source `#[cfg(test)] mod tests`; `tests/failpoints.rs` (feature-gated); `tests/s3_cluster.rs` (bucket-gated full lifecycle on object storage) | Cluster config parser plus semantic policy-bundle and graph/server binding-kind validation, local JSON state diff, state CAS/lock handling/recovery, read-only validate/plan/status plus explicit refresh/import graph observations, config-only apply (content-addressed payload publish, disposition gating, composite-digest convergence, idempotent re-apply), catalog payload verification (status read-only, refresh drift + self-heal), failpoint crash-mid-apply / CAS-race coverage, graph create/schema/delete lifecycle, policy binding and serving snapshots, v11 streaming-profile ownership, authority-retirement preflight, and stopped/offline stream-control preflight. The current dead-letter owner pins actor/offline/applied-streaming/declaration/state-lock binding before selected-token list or payload export; it is inspection-only and exposes no served route. F6b1 adds only the exact-terminal served-export binding consumed at boot | | `omnigraph-server` | `crates/omnigraph-server/tests/` | Per-area suites (post-modularization): `auth_policy.rs`, `data_routes.rs`, `schema_routes.rs`, `stored_queries.rs`, `multi_graph.rs` (cluster-mode boot — converged serving, policy binding wiring, boot refusals — + the concurrent branch-ops matrix), `boot_settings.rs` (mode inference, PolicySource), `s3.rs` (bucket-gated: single-graph serving + config-free `--cluster s3://` boot), `openapi.rs` (OpenAPI drift / regeneration); share `tests/support/mod.rs`. F5a changes no route: `serve` starts checked-runtime resident fold supervisors only after listener bind and joins every selected graph concurrently after Axum graceful shutdown; engine/failpoint owners pin the scheduler behavior. F6b1 boot consumes the terminal served-export guard and installs the hidden engine authority; it adds no handler, route, or OpenAPI surface | | `omnigraph-compiler` | mostly in-source `#[cfg(test)] mod tests` | Parser, type-checker, IR lowering, lint. Schema parser and SchemaIR validation tests both reject the five exact Lance virtual system-column property names while preserving near-miss identifiers | diff --git a/docs/user/clusters/config.md b/docs/user/clusters/config.md index 0a34fde3..ebe88965 100644 --- a/docs/user/clusters/config.md +++ b/docs/user/clusters/config.md @@ -232,8 +232,8 @@ operation is active. - schema parsing and catalog construction - stored-query parsing and query-name matching - stored-query type-checking against the desired schema -- policy YAML parsing and semantic rule validation (groups, actions, and scope - compatibility) +- policy YAML parsing and semantic rule validation (groups, actions, scopes, + and graph/server binding compatibility) - policy `applies_to` graph references - embedding provider profiles and graph `embedding_provider` references