diff --git a/AGENTS.md b/AGENTS.md index a4ad21c..97ab51f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ Rust stable workspace (edition 2024). `protoc` is a build dependency (`brew inst cargo build --workspace --locked # build everything cargo test --workspace --locked # the canonical CI gate (matches CI exactly) cargo run -p omnigraph-cli -- # run the `omnigraph` CLI from source -cargo run -p omnigraph-server -- --bind 0.0.0.0:8080 # run the server from source +cargo run -p omnigraph-server -- --cluster --bind 0.0.0.0:8080 # run the server from source # Run one crate / one test file / one test fn cargo test -p omnigraph-engine --test traversal # one integration-test file (see docs/dev/testing.md) @@ -232,10 +232,10 @@ omnigraph cleanup --keep 10 --older-than 7d --confirm s3://my-bucket/graph.omni # Stand up the HTTP server (token from env) OMNIGRAPH_SERVER_BEARER_TOKEN=xxxx \ - omnigraph-server s3://my-bucket/graph.omni --bind 0.0.0.0:8080 + omnigraph-server --cluster s3://my-bucket/cluster --bind 0.0.0.0:8080 # Cedar policy explain -omnigraph policy explain --actor act-alice --action change --branch main +omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act-alice --action change --branch main ``` --- diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index f1e3f77..94bec5a 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -21,7 +21,7 @@ direct — direct storage access; reject --server (init, optimize, repair, clean schema plan, lint).\n \ control — manage or inspect a cluster (cluster via --config; policy & queries via \ --cluster).\n \ -local — no graph; local config & tooling: embed, login, logout, profile, version.\n\ +local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\ See the 'Command capabilities' section of the CLI reference for which flags apply where.")] pub(crate) struct Cli { /// Actor id for direct-engine writes; overrides `cli.actor`. No effect on @@ -325,8 +325,7 @@ pub(crate) enum Command { command: ClusterCommand, }, - // ── Session / config ── no graph addressing; local tooling. - /// Policy administration and diagnostics + /// Policy administration and diagnostics against a cluster's applied bundles Policy { #[command(subcommand)] command: PolicyCommand, diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index ac4f5c2..971ca30 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -367,12 +367,16 @@ pub(crate) async fn execute_operator_alias( } } - let body = (!params.is_empty()).then(|| serde_json::json!({ "params": params })); + let mut body = serde_json::Map::new(); + body.insert("expect_mutation".to_string(), Value::Bool(false)); + if !params.is_empty() { + body.insert("params".to_string(), Value::Object(params)); + } remote_json( client, Method::POST, remote_url(&uri, &["queries", &alias.query], &[])?, - body, + Some(Value::Object(body)), bearer_token.as_deref(), ) .await diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 744189f..bb3b062 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -106,15 +106,44 @@ async fn main() -> Result<()> { .profiles .iter() .map(|(name, profile)| { - let binding = match profile.binding(name) { - Ok(ScopeBinding::Server(s)) => format!("server: {s}"), - Ok(ScopeBinding::Cluster(c)) => format!("cluster: {c}"), - Ok(ScopeBinding::Store(u)) => format!("store: {u}"), - Err(e) => format!("invalid: {e}"), - }; + let (binding, scope_kind, target, valid, error) = + match profile.binding(name) { + Ok(ScopeBinding::Server(s)) => ( + format!("server: {s}"), + "server".to_string(), + Some(s), + true, + None, + ), + Ok(ScopeBinding::Cluster(c)) => ( + format!("cluster: {c}"), + "cluster".to_string(), + Some(c), + true, + None, + ), + Ok(ScopeBinding::Store(u)) => ( + format!("store: {u}"), + "store".to_string(), + Some(u), + true, + None, + ), + Err(e) => ( + format!("invalid: {e}"), + "invalid".to_string(), + None, + false, + Some(e.to_string()), + ), + }; ProfileListItem { name: name.clone(), binding, + scope_kind, + target, + valid, + error, default_graph: profile.default_graph.clone(), active: active.as_deref() == Some(name.as_str()), } diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs index 25d1cc8..d0f5add 100644 --- a/crates/omnigraph-cli/src/output.rs +++ b/crates/omnigraph-cli/src/output.rs @@ -892,6 +892,12 @@ pub(crate) struct ProfileListItem { pub(crate) name: String, /// `server: ` / `cluster: ` / `store: ` / `invalid: `. pub(crate) binding: String, + /// `server` | `cluster` | `store` | `invalid`. + pub(crate) scope_kind: String, + /// The bound server/cluster name, or the store URI. `None` when invalid. + pub(crate) target: Option, + pub(crate) valid: bool, + pub(crate) error: Option, pub(crate) default_graph: Option, pub(crate) active: bool, } diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index 4308b4c..b599076 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -98,13 +98,11 @@ pub(crate) fn command_capability(cmd: &Command) -> Capability { /// The plane a subcommand belongs to. Exhaustive — a new `Command` variant /// will not compile until classified. Descends into the nested enums where /// the plane differs per subcommand (`schema plan` is storage while `schema -/// show`/`apply` are data; `queries validate` opens the graph while `queries -/// list` only reads config). +/// show`/`apply` are data; `queries`/`policy` read cluster applied state). pub(crate) fn command_plane(cmd: &Command) -> Plane { match cmd { Command::Query { .. } | Command::Mutate { .. } - | Command::Alias { .. } | Command::Load { .. } | Command::Ingest { .. } | Command::Branch { .. } @@ -129,7 +127,8 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane { | Command::Cleanup { .. } | Command::Lint { .. } => Plane::Storage, Command::Cluster { .. } => Plane::Control, - Command::Embed(_) + Command::Alias { .. } + | Command::Embed(_) | Command::Login { .. } | Command::Logout { .. } | Command::Profile { .. } @@ -175,12 +174,13 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str { } } -/// The verbs that address an existing graph through a cluster scope -/// (`--cluster --graph `): the storage-maintenance commands. -/// `init` is storage-plane too but *creates* a graph (cluster graphs are born -/// from `cluster apply`, not `init`), and `schema plan` / `lint` take a -/// positional URI — none consume cluster addressing, so the guard rejects -/// `--cluster`/`--graph` on them rather than silently dropping the flag. +/// The verbs that consume a cluster scope. Maintenance/lint select a graph with +/// `--cluster --graph `; policy/queries inspect the cluster's +/// applied control-plane state and may optionally use `--graph` to select one +/// bundle/registry. `init` is storage-plane too but *creates* a graph (cluster +/// graphs are born from `cluster apply`, not `init`), and `schema plan` takes a +/// positional URI, so the guard rejects `--cluster`/`--graph` there rather than +/// silently dropping the flag. pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool { matches!( cmd, @@ -201,12 +201,43 @@ pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool { /// Reject a scope-addressing flag (`--server`/`--cluster`/`--graph`) on a verb /// that cannot consume it, rather than silently dropping it (the old behavior: /// e.g. `optimize --server prod` dropped `--server` and failed later with an -/// unrelated message). Each flag has a distinct valid surface: +/// unrelated message). `alias` gets an extra guard because its binding owns all +/// addressing and several ignored globals sit outside this three-flag guard. +/// Each flag has a distinct valid surface: /// - `--server` → served-graph scopes (`any`/`served`); -/// - `--cluster` → the cluster-maintenance verbs (optimize/repair/cleanup); +/// - `--cluster` → cluster-scoped direct/control verbs; /// - `--graph` → any multi-graph scope: a served scope *or* a cluster one. /// RFC-010 Slice 1, generalized for RFC-011 cluster addressing. pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> { + if let Command::Alias { .. } = &cli.command { + let mut flags = Vec::new(); + if cli.server.is_some() { + flags.push("--server"); + } + if cli.graph.is_some() { + flags.push("--graph"); + } + if cli.store.is_some() { + flags.push("--store"); + } + if cli.cluster.is_some() { + flags.push("--cluster"); + } + if cli.profile.is_some() { + flags.push("--profile"); + } + if cli.as_actor.is_some() { + flags.push("--as"); + } + if !flags.is_empty() { + bail!( + "`alias` uses the server, graph, and stored query declared in \ + `aliases.` in ~/.omnigraph/config.yaml; remove global scope \ + flag(s): {}", + flags.join(", ") + ); + } + } if cli.server.is_none() && cli.cluster.is_none() && cli.graph.is_none() { return Ok(()); } @@ -223,8 +254,8 @@ pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> { } if cli.cluster.is_some() && !cluster_ok { bail!( - "`{label}` is a {} command; --cluster addresses a cluster-managed graph for \ - maintenance (optimize/repair/cleanup) and does not apply.{}", + "`{label}` is a {} command; --cluster addresses a cluster-scoped command \ + and does not apply.{}", capability.describe(), remediation(capability, &cli.command), ); @@ -253,7 +284,15 @@ fn remediation(capability: Capability, cmd: &Command) -> &'static str { } _ => " Pass a storage URI.", }, - Capability::Control => " It operates on a cluster (pass --config ).", + Capability::Control => match cmd { + Command::Cluster { .. } => { + " It operates on a cluster config directory (pass --config )." + } + Command::Policy { .. } | Command::Queries { .. } => { + " It operates on a cluster (pass --cluster , or select a cluster profile)." + } + _ => " It operates on a cluster.", + }, Capability::Local => " It does not address a graph.", Capability::Any | Capability::Served => "", } @@ -286,6 +325,7 @@ mod tests { // The one Data→Served refinement — if the `graphs` guard were deleted, // every other assertion here would still pass. assert_eq!(cap(&["omnigraph", "graphs", "list"]), Capability::Served); + assert_eq!(cap(&["omnigraph", "alias", "who"]), Capability::Local); assert_eq!(cap(&["omnigraph", "optimize", "graph.omni"]), Capability::Direct); assert_eq!(cap(&["omnigraph", "schema", "plan", "--schema", "s.pg", "graph.omni"]), Capability::Direct); assert_eq!(cap(&["omnigraph", "cluster", "status", "--config", "."]), Capability::Control); diff --git a/crates/omnigraph-cli/src/scope.rs b/crates/omnigraph-cli/src/scope.rs index 91a1c24..257907d 100644 --- a/crates/omnigraph-cli/src/scope.rs +++ b/crates/omnigraph-cli/src/scope.rs @@ -186,9 +186,9 @@ fn scope_from_binding( ScopeBinding::Cluster(cluster) => { if capability == Capability::Any { bail!( - "{source} resolves a cluster scope, which is maintenance-only; run \ - data commands through a server, or use --store for ad-hoc \ - direct access" + "{source} resolves a cluster scope, which is not valid for graph data \ + commands; run data commands through a server, or use --store \ + for ad-hoc direct access" ); } // A cluster value is a config name (resolved against `clusters:`) @@ -501,7 +501,7 @@ mod tests { ) .unwrap_err() .to_string(); - assert!(err.contains("maintenance-only"), "{err}"); + assert!(err.contains("not valid for graph data commands"), "{err}"); } #[test] diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index b75177c..81e1aab 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -2127,7 +2127,26 @@ fn profile_list_json_shape() { .find(|p| p["name"] == "brain-admin") .unwrap(); assert_eq!(brain["binding"], "cluster: brain"); + assert_eq!(brain["scope_kind"], "cluster"); + assert_eq!(brain["target"], "brain"); + assert_eq!(brain["valid"], true); + assert!(brain["error"].is_null()); assert_eq!(brain["active"], false); + let broken = items + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == "broken") + .unwrap(); + assert_eq!(broken["scope_kind"], "invalid"); + assert_eq!(broken["valid"], false); + assert!(broken["target"].is_null()); + assert!( + broken["error"] + .as_str() + .unwrap() + .contains("profile 'broken'") + ); } #[test] diff --git a/crates/omnigraph-cli/tests/cli_queries.rs b/crates/omnigraph-cli/tests/cli_queries.rs index 0b80f42..92f7879 100644 --- a/crates/omnigraph-cli/tests/cli_queries.rs +++ b/crates/omnigraph-cli/tests/cli_queries.rs @@ -94,6 +94,49 @@ fn alias_unknown_name_errors_listing_defined() { ); } +#[test] +fn alias_rejects_global_scope_flags_that_the_binding_owns() { + for (flag, value) in [ + ("--server", "dev"), + ("--graph", "local"), + ("--store", "file:///tmp/graph.omni"), + ("--cluster", "."), + ("--profile", "prod"), + ("--as", "act-op"), + ] { + let output = output_failure(cli().arg(flag).arg(value).arg("alias").arg("who")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("`alias` uses the server, graph, and stored query") + && stderr.contains(flag), + "expected {flag} to be rejected by the alias binding guard; got: {stderr}" + ); + } +} + +#[test] +fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() { + let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("pass --cluster ") && !stderr.contains("pass --config "), + "queries should point at --cluster, not --config; got: {stderr}" + ); + + let output = output_failure( + cli() + .arg("--server") + .arg("prod") + .arg("policy") + .arg("validate"), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("pass --cluster ") && !stderr.contains("pass --config "), + "policy should point at --cluster, not --config; got: {stderr}" + ); +} + // RFC-011: `queries validate`/`list` source the registry + schemas from a // converged cluster's applied state (`--cluster `), not omnigraph.yaml. diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index 77f1cb6..e971076 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -1398,154 +1398,6 @@ fn local_cli_ingest_enforces_engine_layer_policy() { assert_eq!(allowed["branch_created"], true); } -#[test] -fn local_cli_schema_apply_enforces_engine_layer_policy() { - // RFC-011 served re-point: the server enforces schema_apply against the - // graph-bound bundle. Bruno has no schema_apply rule → denied; ragnor - // has admins-schema-apply → allowed. The schema is additive (a nullable - // property), SDK-compatible with the fixture. - if skip_system_e2e("local_cli_schema_apply_enforces_engine_layer_policy") { - return; - } - let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML)); - let server = spawn_server_with_cluster_env( - cluster.path(), - &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], - ); - let new_schema = std::fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace( - " age: I32?\n}", - " age: I32?\n nickname: String?\n}", - ); - let temp = tempfile::tempdir().unwrap(); - let schema_path = temp.path().join("policy-additive.pg"); - std::fs::write(&schema_path, &new_schema).unwrap(); - - let denied = cli() - .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") - .arg("schema") - .arg("apply") - .arg("--server") - .arg(&server.base_url) - .arg("--graph") - .arg("knowledge") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .output() - .unwrap(); - assert!(!denied.status.success(), "bruno schema apply must be denied"); - let stderr = String::from_utf8_lossy(&denied.stderr); - assert!( - stderr.contains("denied"), - "expected 'denied' for bruno schema apply, got: {stderr}" - ); - - let allowed = parse_stdout_json(&output_success( - cli() - .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") - .arg("schema") - .arg("apply") - .arg("--server") - .arg(&server.base_url) - .arg("--graph") - .arg("knowledge") - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - )); - assert_eq!(allowed["applied"], true); -} - -#[test] -fn local_cli_schema_apply_rejects_stored_query_breakage_before_publish() { - // RFC-011: stored queries live in the cluster catalog, not omnigraph.yaml. - // The served `schema apply` runs the server's catalog check against the - // applied stored queries; renaming `age`→`years` breaks the bundled - // `find_person` (which projects `$p.age`), so the apply is rejected before - // publish — the schema stays unchanged. - if skip_system_e2e("local_cli_schema_apply_rejects_stored_query_breakage_before_publish") { - return; - } - // A graph-bound bundle that lets ragnor apply schema, plus a stored query - // `find_person` projecting $p.age (the catalog the server checks against). - let cluster = tempfile::tempdir().unwrap(); - let dir = cluster.path(); - fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap(); - fs::write( - dir.join("find-person.gq"), - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ) - .unwrap(); - fs::write(dir.join("graph.policy.yaml"), POLICY_E2E_YAML).unwrap(); - fs::write( - dir.join("cluster.yaml"), - "version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n knowledge:\n schema: ./graph.pg\n queries:\n find_person:\n file: ./find-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [knowledge]\n", - ) - .unwrap(); - output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir)); - output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir)); - output_success( - cli() - .arg("load") - .arg("--data") - .arg(fixture("test.jsonl")) - .arg("--mode") - .arg("overwrite") - .arg(dir.join("graphs").join("knowledge.omni")), - ); - let server = spawn_server_with_cluster_env( - dir, - &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], - ); - - let renamed_schema = std::fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace("age: I32?", "years: I32? @rename_from(\"age\")"); - let temp = tempfile::tempdir().unwrap(); - let schema_path = temp.path().join("stored-query-breaks.pg"); - fs::write(&schema_path, &renamed_schema).unwrap(); - - let rejected = cli() - .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") - .arg("schema") - .arg("apply") - .arg("--server") - .arg(&server.base_url) - .arg("--graph") - .arg("knowledge") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .output() - .unwrap(); - assert!( - !rejected.status.success(), - "schema apply that breaks a stored query must be rejected" - ); - let stderr = String::from_utf8_lossy(&rejected.stderr); - assert!( - stderr.contains("find_person") && stderr.contains("schema check"), - "schema apply should reject the stored-query breakage before publish; stderr: {stderr}" - ); - - // The schema stayed unchanged (read it back via the served graph as the - // bruno reader, who holds `team-read`). - let schema = stdout_string(&output_success( - cli() - .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") - .arg("schema") - .arg("show") - .arg("--server") - .arg(&server.base_url) - .arg("--graph") - .arg("knowledge"), - )); - assert!(schema.contains("age: I32?")); - assert!(!schema.contains("years: I32?")); -} - #[test] fn local_cli_branch_create_enforces_engine_layer_policy() { // RFC-011 served re-point: bruno has no branch-ops rule → denied; @@ -2482,14 +2334,19 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() { "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }", ) .unwrap(); + fs::write( + cluster.path().join("insert-person.gq"), + "query insert_person($name: String) { insert Person { name: $name, age: 41 } }", + ) + .unwrap(); fs::write( cluster.path().join("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", + "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 - id: allow-change\n allow:\n actors: { group: ops }\n actions: [change]\n branch_scope: any\n", ) .unwrap(); fs::write( cluster.path().join("cluster.yaml"), - "version: 1\nmetadata:\n name: alias-sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n local:\n schema: ./local.pg\n queries:\n find_person:\n file: ./find-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [local]\n", + "version: 1\nmetadata:\n name: alias-sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n local:\n schema: ./local.pg\n queries:\n find_person:\n file: ./find-person.gq\n insert_person:\n file: ./insert-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [local]\n", ) .unwrap(); output_success(cli().arg("cluster").arg("import").arg("--config").arg(cluster.path())); @@ -2515,7 +2372,7 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() { 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", + "servers:\n dev:\n url: {}\naliases:\n who:\n server: dev\n graph: local\n query: find_person\n args: [name]\n create_person:\n server: dev\n graph: local\n query: insert_person\n args: [name]\n", server.base_url ), ) @@ -2552,6 +2409,46 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() { let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(payload["rows"][0]["p.name"], "Alice", "{payload}"); + // Operator aliases are read-only conveniences: a binding to a stored + // mutation must be rejected before the server executes it. + let output = cli() + .env("OMNIGRAPH_HOME", operator_home.path()) + .arg("alias") + .arg("create_person") + .arg("AliasGuardPerson") + .output() + .unwrap(); + assert!(!output.status.success(), "mutation alias must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("'insert_person' is a mutation") + && stderr.contains("omnigraph mutate insert_person"), + "expected mutation-kind mismatch; got: {stderr}" + ); + let output = cli() + .env("OMNIGRAPH_HOME", operator_home.path()) + .arg("query") + .arg("find_person") + .arg("--server") + .arg("dev") + .arg("--graph") + .arg("local") + .arg("--params") + .arg(r#"{"name":"AliasGuardPerson"}"#) + .arg("--json") + .output() + .unwrap(); + assert!( + output.status.success(), + "post-alias read should succeed: {output:?}" + ); + let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + payload["rows"].as_array().unwrap().len(), + 0, + "mutation alias must not insert AliasGuardPerson: {payload}" + ); + // --server/--graph: the same stored query via explicit targeting. let output = cli() .env("OMNIGRAPH_HOME", operator_home.path()) diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 0c25d13..7de38d2 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -53,14 +53,13 @@ pub(crate) async fn server_graphs_list( ) -> std::result::Result, ApiError> { let registry = &state.routing().registry; - // Server-level Cedar gate. `state.server_policy` is loaded from - // `server.policy.file` in `omnigraph.yaml` at startup. When no - // server policy is configured, `authorize_request_server` falls - // through to the MR-723 default-deny semantics (every non-Read - // action denied for an authenticated actor). `GraphList` is not - // `Read`, so without a server policy the request gets 403 — which - // is the right default (don't leak the registry until the operator - // explicitly authorizes it). + // Server-level Cedar gate. `state.server_policy` is loaded from the + // cluster-scoped policy bundle at startup. When no server policy is + // configured, `authorize_request_server` falls through to the MR-723 + // default-deny semantics (every non-Read action denied for an + // authenticated actor). `GraphList` is not `Read`, so without a server + // policy the request gets 403 — which is the right default (don't leak + // the registry until the operator explicitly authorizes it). authorize_request( actor.as_ref().map(|Extension(actor)| actor), state.server_policy.as_deref(), @@ -360,22 +359,25 @@ pub(crate) fn authorize( // runtime state means the docstring contract on // `server_graphs_list` ("don't leak the registry until the // operator explicitly authorizes it") holds uniformly; the - // operator's only path to enabling it is configuring an - // explicit `server.policy.file` in omnigraph.yaml. + // operator's only path to enabling it is configuring a + // cluster-scoped policy bundle, applying the cluster, and + // restarting the server. if request.action.resource_kind() == PolicyResourceKind::Server { return Ok(Authz::Denied( - "server-scoped actions require an explicit `server.policy.file` \ - configured in omnigraph.yaml — the management surface is closed \ - by default in every runtime state, including --unauthenticated, \ - so that server topology is never exposed without operator opt-in." + "server-scoped actions require an explicit cluster policy bundle \ + applied with `omnigraph cluster apply` and served after restart — \ + the management surface is closed by default in every runtime state, \ + including --unauthenticated, so that server topology is never exposed \ + without operator opt-in." .to_string(), )); } if actor.is_some() && request.action != PolicyAction::Read { return Ok(Authz::Denied( "server runs in default-deny mode (bearer tokens configured but no \ - policy file). Only `read` actions are permitted; configure \ - `policy.file` in omnigraph.yaml to enable other actions." + applied policy bundle). Only `read` actions are permitted; configure \ + a graph or cluster policy bundle in the cluster config, run \ + `omnigraph cluster apply`, and restart the server to enable other actions." .to_string(), )); } @@ -488,7 +490,7 @@ pub(crate) fn deprecation_headers(successor_link: &'static str) -> [(HeaderName, operation_id = "read", request_body = ReadRequest, responses( - (status = 200, description = "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = ReadOutput), + (status = 200, description = "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = ReadOutput), (status = 400, description = "Bad request", body = ErrorOutput), (status = 401, description = "Unauthorized", body = ErrorOutput), (status = 403, description = "Forbidden", body = ErrorOutput), @@ -502,7 +504,7 @@ pub(crate) fn deprecation_headers(successor_link: &'static str) -> [(HeaderName, /// route is kept indefinitely for byte-stable back-compat. New integrations /// should target `POST /query`, which has clean field names (`query` / /// `name`) and a 400-on-mutation guard. Responses from this route include -/// `Deprecation: true` and `Link: ; rel="successor-version"` +/// `Deprecation: true` and `Link: ; rel="successor-version"` /// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the /// signal. pub(crate) async fn server_read( @@ -522,7 +524,7 @@ pub(crate) async fn server_read( ) .await?; Ok(( - deprecation_headers("; rel=\"successor-version\""), + deprecation_headers("; rel=\"successor-version\""), Json(api::read_output(selected_name, &target, result)), )) } @@ -771,7 +773,7 @@ pub(crate) async fn run_query( operation_id = "change", request_body = ChangeRequest, responses( - (status = 200, description = "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = ChangeOutput), + (status = 200, description = "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = ChangeOutput), (status = 400, description = "Bad request", body = ErrorOutput), (status = 401, description = "Unauthorized", body = ErrorOutput), (status = 403, description = "Forbidden", body = ErrorOutput), @@ -787,7 +789,7 @@ pub(crate) async fn run_query( /// kept indefinitely for back-compat. New integrations should target /// `POST /mutate`, which has identical semantics and a name that pairs /// cleanly with `POST /query`. Responses from this route include -/// `Deprecation: true` and `Link: ; rel="successor-version"` +/// `Deprecation: true` and `Link: ; rel="successor-version"` /// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the /// signal. pub(crate) async fn server_change( @@ -808,7 +810,7 @@ pub(crate) async fn server_change( ) .await?; Ok(( - deprecation_headers("; rel=\"successor-version\""), + deprecation_headers("; rel=\"successor-version\""), Json(output), )) } @@ -1111,12 +1113,16 @@ pub(crate) async fn server_schema_get( (status = 400, description = "Bad request", body = ErrorOutput), (status = 401, description = "Unauthorized", body = ErrorOutput), (status = 403, description = "Forbidden", body = ErrorOutput), + (status = 409, description = "Schema apply is disabled for cluster-backed serving; use `omnigraph cluster apply` and restart", body = ErrorOutput), (status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput), ), security(("bearer_token" = [])), )] /// Apply a schema migration. /// +/// Cluster-backed servers reject this route with `409 Conflict`; operators +/// must apply schema changes through `omnigraph cluster apply` and restart. +/// /// Diffs `schema_source` against the current schema and applies the resulting /// migration steps (add/drop type, add/drop column, etc.). **Destructive**: /// some steps drop data. Returns the list of steps applied; if `applied` is @@ -1143,6 +1149,17 @@ pub(crate) async fn server_schema_apply( target_branch: Some("main".to_string()), }, )?; + // Disable HTTP schema apply on cluster-backed serving AFTER the Cedar gate, + // so an unauthorized actor gets a 403 (not a 409 that would disclose the + // server is cluster-backed): 401 → 403 → 409, never leak topology before + // authorization. An authorized actor gets the actionable 409 signpost. + if state.routing().config_path.is_some() { + return Err(ApiError::conflict( + "server-side schema apply is disabled for cluster-backed serving; \ + update the cluster config, run `omnigraph cluster apply`, and restart \ + the server.", + )); + } let est_bytes = request.schema_source.len() as u64; let _admission = state .workload @@ -1324,7 +1341,7 @@ pub(crate) async fn server_load( operation_id = "ingest", request_body = IngestRequest, responses( - (status = 200, description = "Load results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = IngestOutput), + (status = 200, description = "Load results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = IngestOutput), (status = 400, description = "Bad request", body = ErrorOutput), (status = 401, description = "Unauthorized", body = ErrorOutput), (status = 403, description = "Forbidden", body = ErrorOutput), @@ -1338,7 +1355,7 @@ pub(crate) async fn server_load( /// Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is /// kept indefinitely for back-compat. New integrations should target /// `POST /load`, which has identical semantics. Responses from this route -/// include `Deprecation: true` and `Link: ; rel="successor-version"` +/// include `Deprecation: true` and `Link: ; rel="successor-version"` /// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal. pub(crate) async fn server_ingest( State(state): State, @@ -1354,7 +1371,7 @@ pub(crate) async fn server_ingest( ) .await?; Ok(( - deprecation_headers("; rel=\"successor-version\""), + deprecation_headers("; rel=\"successor-version\""), Json(output), )) } @@ -1738,4 +1755,3 @@ pub(crate) fn query_params_from_json( json_params_to_param_map(params_json, query_params, JsonParamMode::Standard) .map_err(|err| color_eyre::eyre::eyre!(err.to_string())) } - diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index e6f63dc..b83a166 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -191,10 +191,10 @@ pub enum ServerConfigMode { }, } -/// Where a Cedar policy bundle comes from at startup. File-based for -/// omnigraph.yaml deployments; inline (digest-verified catalog content) -/// for cluster-mode boots, where the catalog may live on object storage -/// and the server must not re-read mutable state after the snapshot. +/// Where a Cedar policy bundle comes from at startup. Cluster-local files are +/// used during config application; inline digest-verified catalog content is +/// used for serving, where the catalog may live on object storage and the +/// server must not re-read mutable state after the snapshot. #[derive(Debug, Clone)] pub enum PolicySource { File(PathBuf), @@ -249,12 +249,10 @@ pub struct AppState { /// see MR-668 decision Q6. workload: Arc, bearer_tokens: Arc<[(BearerTokenHash, Arc)]>, - /// Server-level Cedar policy. Used by management endpoints (`POST - /// /graphs`, `GET /graphs`) which act on the registry resource, - /// not on a per-graph resource. Loaded from `server.policy.file` - /// in `omnigraph.yaml`. `None` outside multi mode and when no - /// server policy is configured. Per-graph policies live on each - /// `GraphHandle.policy`. + /// Server-level Cedar policy. Used by management endpoints (`GET + /// /graphs`) which act on the registry resource, not on a per-graph + /// resource. Loaded from the cluster-scoped policy binding when + /// configured. Per-graph policies live on each `GraphHandle.policy`. server_policy: Option>, } @@ -534,12 +532,11 @@ impl AppState { } /// Multi-mode constructor — used by the startup loop. Operators - /// reach this by invoking `omnigraph-server --config omnigraph.yaml` - /// with a non-empty `graphs:` map. + /// reach this by invoking `omnigraph-server --cluster `. /// /// Caller supplies the already-opened `GraphHandle`s and (optionally) - /// the path to the source config file. `server_policy` is loaded - /// from `server.policy.file` if configured. + /// the path to the source cluster. `server_policy` is loaded from the + /// cluster-scoped policy binding if configured. pub fn new_multi( handles: Vec>, bearer_tokens: Vec<(String, String)>, @@ -993,7 +990,8 @@ pub async fn serve(config: ServerConfig) -> Result<()> { ServerRuntimeState::DefaultDeny => warn!( "bearer tokens are configured but no policy file is set — running in \ default-deny mode (only `read` actions are permitted for authenticated \ - actors). Configure `policy.file` in omnigraph.yaml to enable Cedar rules." + actors). Configure a graph or cluster policy bundle in the cluster config, \ + run `omnigraph cluster apply`, and restart to enable Cedar rules." ), ServerRuntimeState::PolicyEnabled => {} } @@ -1123,5 +1121,3 @@ async fn shutdown_signal() { } info!("shutdown signal received"); } - - diff --git a/crates/omnigraph-server/src/settings.rs b/crates/omnigraph-server/src/settings.rs index b8ebd37..34a76bd 100644 --- a/crates/omnigraph-server/src/settings.rs +++ b/crates/omnigraph-server/src/settings.rs @@ -1,14 +1,13 @@ -//! Server settings: omnigraph.yaml/CLI/env resolution, mode inference -//! (single vs multi vs cluster), bearer-token sources, and runtime-state -//! classification (moved verbatim from lib.rs in the modularization). +//! Server settings: cluster/CLI/env resolution, bearer-token sources, and +//! runtime-state classification (moved verbatim from lib.rs in the +//! modularization). use super::*; /// Build serving settings from a cluster directory's applied revision /// (RFC-005 §D2): graphs at derived roots, stored queries from verified /// catalog blob content, policy bundles from blob paths with their applied -/// bindings. Always multi-graph routing. The unauthenticated/env handling -/// matches the omnigraph.yaml path. +/// bindings. Always multi-graph routing. pub(crate) async fn load_cluster_settings( cluster_dir: &PathBuf, cli_bind: Option, @@ -189,7 +188,8 @@ pub fn classify_server_runtime_state( "server has no bearer tokens and no policy file configured. This is a fully \ open server — pass `--unauthenticated` (or set OMNIGRAPH_UNAUTHENTICATED=1) \ if you actually want that, otherwise configure bearer tokens (see \ - docs/user/operations/server.md) and/or `policy.file` in omnigraph.yaml." + docs/user/operations/server.md) and a graph or cluster policy bundle in \ + the cluster config, then run `omnigraph cluster apply` and restart." ), (false, false, true) => Ok(ServerRuntimeState::Open), (true, false, _) => Ok(ServerRuntimeState::DefaultDeny), diff --git a/crates/omnigraph-server/tests/data_routes.rs b/crates/omnigraph-server/tests/data_routes.rs index 172fb4f..65af2c6 100644 --- a/crates/omnigraph-server/tests/data_routes.rs +++ b/crates/omnigraph-server/tests/data_routes.rs @@ -580,7 +580,7 @@ async fn mutate_endpoint_runs_inline_mutation() { #[tokio::test(flavor = "multi_thread")] async fn change_endpoint_emits_deprecation_headers() { // `/change` is kept indefinitely for back-compat but flagged at runtime - // per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: ; + // per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: ; // rel="successor-version"`). The OpenAPI side is covered by // `openapi_change_is_deprecated` in tests/openapi.rs. let (_temp, app) = app_for_loaded_graph().await; @@ -615,7 +615,7 @@ async fn change_endpoint_emits_deprecation_headers() { ); assert_eq!( response.headers().get("link").and_then(|v| v.to_str().ok()), - Some("; rel=\"successor-version\""), + Some("; rel=\"successor-version\""), "POST /change must point at /mutate via `Link` rel=successor-version (RFC 8288)" ); } @@ -658,7 +658,7 @@ async fn load_endpoint_loads_into_existing_branch() { #[tokio::test(flavor = "multi_thread")] async fn ingest_endpoint_emits_deprecation_headers() { // `/ingest` is the deprecated alias of `/load` (RFC-009 Phase 5): flagged - // at runtime per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: ; + // at runtime per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: ; // rel="successor-version"`). The OpenAPI side is covered by // `openapi_ingest_is_deprecated` in tests/openapi.rs. let (_temp, app) = app_for_loaded_graph().await; @@ -692,7 +692,7 @@ async fn ingest_endpoint_emits_deprecation_headers() { ); assert_eq!( response.headers().get("link").and_then(|v| v.to_str().ok()), - Some("; rel=\"successor-version\""), + Some("; rel=\"successor-version\""), "POST /ingest must point at /load via `Link` rel=successor-version (RFC 8288)" ); } @@ -734,7 +734,7 @@ async fn read_endpoint_emits_deprecation_headers() { ); assert_eq!( response.headers().get("link").and_then(|v| v.to_str().ok()), - Some("; rel=\"successor-version\""), + Some("; rel=\"successor-version\""), "POST /read must point at /query via `Link` rel=successor-version (RFC 8288)" ); } diff --git a/crates/omnigraph-server/tests/schema_routes.rs b/crates/omnigraph-server/tests/schema_routes.rs index ec1727a..c73591c 100644 --- a/crates/omnigraph-server/tests/schema_routes.rs +++ b/crates/omnigraph-server/tests/schema_routes.rs @@ -2,6 +2,7 @@ //! Moved verbatim from tests/server.rs in the modularization. use std::fs; +use std::sync::Arc; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; @@ -11,7 +12,9 @@ use omnigraph::loader::LoadMode; use omnigraph_server::api::{ ChangeRequest, ErrorOutput, ReadRequest, SchemaApplyRequest, SchemaOutput, }; -use omnigraph_server::{AppState, build_app}; +use omnigraph_server::{ + AppState, GraphHandle, GraphId, GraphKey, PolicyEngine, build_app, workload, +}; use serde_json::json; @@ -54,6 +57,111 @@ async fn schema_apply_route_updates_graph_for_authorized_admin() { ); } +#[tokio::test] +async fn schema_apply_route_refuses_cluster_backed_server_mode() { + let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await; + let graph = graph_path(temp.path()); + let graph_uri = graph.to_string_lossy().to_string(); + let engine = Omnigraph::open(&graph_uri).await.unwrap(); + let handle = Arc::new(GraphHandle { + key: GraphKey::cluster(GraphId::try_from("default").unwrap()), + uri: graph_uri.clone(), + engine: Arc::new(engine), + policy: None, + queries: None, + }); + let state = AppState::new_multi( + vec![handle], + Vec::new(), + None, + workload::WorkloadController::from_env(), + Some(temp.path().join("cluster.yaml")), + ) + .unwrap(); + let app = build_app(state); + + let request = Request::builder() + .method(Method::POST) + .uri(g("/schema/apply")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: additive_schema_with_nickname(), + ..Default::default() + }) + .unwrap(), + )) + .unwrap(); + let (status, payload) = json_response(&app, request).await; + + assert_eq!(status, StatusCode::CONFLICT, "body: {payload}"); + assert!( + payload["error"] + .as_str() + .unwrap_or_default() + .contains("cluster apply"), + "body: {payload}" + ); + let reopened = Omnigraph::open(&graph_uri).await.unwrap(); + assert!( + !reopened.catalog().node_types["Person"] + .properties + .contains_key("nickname"), + "cluster-backed schema apply must not mutate the graph" + ); +} + +#[tokio::test] +async fn schema_apply_route_cluster_backed_denies_unauthorized_actor_before_409() { + // The cluster-backed 409 is reported AFTER the Cedar gate, so an actor + // without `schema_apply` permission gets a 403 — never a 409 that would + // disclose the server is cluster-backed (401 → 403 → 409, no topology leak + // before authorization). POLICY_YAML grants read/export but not schema_apply, + // so act-ragnor is denied. + let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await; + let graph = graph_path(temp.path()); + let graph_uri = graph.to_string_lossy().to_string(); + let engine = Omnigraph::open(&graph_uri).await.unwrap(); + let policy = PolicyEngine::load_graph_from_source(POLICY_YAML, "default").unwrap(); + let handle = Arc::new(GraphHandle { + key: GraphKey::cluster(GraphId::try_from("default").unwrap()), + uri: graph_uri, + engine: Arc::new(engine), + policy: Some(Arc::new(policy)), + queries: None, + }); + let state = AppState::new_multi( + vec![handle], + vec![("act-ragnor".to_string(), "admin-token".to_string())], + None, + workload::WorkloadController::from_env(), + Some(temp.path().join("cluster.yaml")), + ) + .unwrap(); + let app = build_app(state); + + let request = Request::builder() + .method(Method::POST) + .uri(g("/schema/apply")) + .header("content-type", "application/json") + .header("authorization", "Bearer admin-token") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: additive_schema_with_nickname(), + ..Default::default() + }) + .unwrap(), + )) + .unwrap(); + let (status, payload) = json_response(&app, request).await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "an unauthorized actor must get 403 before the cluster-backed 409: {payload}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn schema_apply_route_rejects_stored_query_breakage_before_publish() { let (temp, app) = app_with_stored_queries( diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index d8bf66e..6f49c42 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -59,10 +59,11 @@ omnigraph commit show --uri graph.omni --json ## Remote Server Mode -Serve a graph: +Serve a cluster-applied graph: ```bash -omnigraph-server graph.omni --bind 127.0.0.1:8080 +omnigraph cluster apply --config ./company-brain +omnigraph-server --cluster ./company-brain --bind 127.0.0.1:8080 ``` Read through the HTTP API — invoke a stored query by name from the catalog: diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 0a3b7eb..1d52e45 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 the per-operator `~/.omnigraph/config.yaml` schema. For a quick-start guide, see [cli.md](index.md). -Top-level command families and subcommands. Graph-targeting commands accept a positional `file://`/`s3://` URI, `--server ` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph ` for multi-graph servers; exclusive with a positional URI), `--store ` (a single graph's storage directly), or `--profile ` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config `. A remote server is addressed only with `--server` — a positional `http(s)://` URI is rejected. **`query`/`mutate` are the exception**: their positional is a stored-query *name* (RFC-011 D3), not a graph URI, so they address the graph only via `--store`/`--server`/`--profile`/defaults. +Top-level command families and subcommands. Graph-targeting commands accept a positional `file://`/`s3://` URI, `--server ` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph ` for multi-graph servers; exclusive with a positional URI), `--store ` (a single graph's storage directly), or `--profile ` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config `, while `policy` and `queries` read a cluster's applied state via `--cluster `. A remote server is addressed only with `--server` — a positional `http(s)://` URI is rejected. **`query`/`mutate` are the exception**: their positional is a stored-query *name* (RFC-011 D3), not a graph URI, so they address the graph only via `--store`/`--server`/`--profile`/defaults. ## Top-level commands @@ -13,7 +13,7 @@ Top-level command families and subcommands. Graph-targeting commands accept a po | `ingest` | deprecated alias of `load --from ` (defaults: `--from main --mode merge`); prints a one-line warning to stderr | | `query ` (alias: `read`) | run a read query. **Catalog lane** (default): `` is a stored query invoked **by name** from the served catalog (served-only — address with `--server`/`--profile`; the verb asserts the query is a read). **Ad-hoc lane**: with `--query ` or `-e`/`--query-string `, runs that source (the positional `` then selects which query in it). No positional graph URI — address via `--store`/`--server`/`--profile`. `read` is the deprecated previous name (one-line stderr warning) | | `mutate ` (alias: `change`) | run a mutation query; same catalog (by-name, served-only, verb asserts mutation) / ad-hoc (`--query`/`-e`) lanes as `query`. `change` is the deprecated previous name (one-line stderr warning) | -| `alias [args]` | invoke an operator alias — a personal binding (under `aliases:` in `~/.omnigraph/config.yaml`) to a stored query on a named server (RFC-011 D4; replaces the removed `--alias` flag) | +| `alias [args]` | invoke an operator alias — a read-only personal binding (under `aliases:` in `~/.omnigraph/config.yaml`) to a stored query on a named server (RFC-011 D4; replaces the removed `--alias` flag; stored mutations are rejected before execution) | | `snapshot` | print current snapshot (per-table version + row count) | | `export` | dump to JSONL on stdout (`--type T`, `--table K` filters) | | `branch create \| list \| delete \| merge` | branching ops | @@ -26,7 +26,7 @@ Top-level command families and subcommands. Graph-targeting commands accept a po | `cleanup --keep N --older-than 7d --confirm` | destructive version GC (`--confirm` to execute; also needs `--yes` against a non-local `s3://` target — see *Write diagnostics & destructive confirmation*) | | `embed` | offline JSONL embedding pipeline | | `policy validate \| test \| explain` | Cedar tooling against a cluster's applied policies (`--cluster `; `--graph ` picks a graph's bundle when several apply). `test` takes `--tests `; `explain` takes `--actor`/`--action`/`--branch`/`--target-branch` | -| `profile list \| show []` | read-only inspection of `~/.omnigraph/config.yaml` profiles. `list` shows each profile's binding (server/cluster/store) + default graph and marks the `$OMNIGRAPH_PROFILE`-active one; `show` resolves one profile's scope (endpoint + default graph), defaulting to the active profile, else the flat operator defaults | +| `profile list \| show []` | read-only inspection of `~/.omnigraph/config.yaml` profiles. `list` shows each profile's binding (server/cluster/store) + default graph and marks the `$OMNIGRAPH_PROFILE`-active one; JSON keeps `binding` and adds `scope_kind`, `target`, `valid`, and `error`; `show` resolves one profile's scope (endpoint + default graph), defaulting to the active profile, else the flat operator defaults | | `version` / `-v` | print `omnigraph 0.3.x` | ## Command capabilities @@ -35,13 +35,13 @@ Every command declares the **capability** it needs — what it requires to reach - **`any`** — `query`, `mutate`, `load`, `ingest`, `branch *`, `snapshot`, `export`, `commit *`, `schema show`, `schema apply`. Run against a graph **served (via a server) or embedded (direct against a store)**: accept a positional `file://`/`s3://` URI, `--server ` (+ `--graph ` for multi-graph servers), `--store `, or `--profile `. A remote server is addressed with `--server` — a positional `http(s)://` URI does **not** dispatch to one. - **`served`** — `graphs list`. Requires a server (accepts `--server` / `--profile`). -- **`direct`** — `init`, `optimize`, `repair`, `cleanup`, `schema plan`, `queries validate`, `lint`. Need **direct storage access** (`file://` / `s3://`), never through a server. They accept a positional `URI`, but **not** `--server`, and a remote (`http(s)://`) URI is rejected. `optimize` / `repair` / `cleanup` additionally accept **`--cluster --graph `** (`--cluster` is a cluster directory or storage-root URI, named via `clusters:` in `~/.omnigraph/config.yaml` or a literal root), which resolves the graph's storage URI from the served cluster state (so you needn't know the `/graphs/.omni` layout). `--graph` is the one graph selector across all scopes — on these three verbs it picks the cluster graph; on the other `direct` verbs it does not apply. -- **`control`** — `cluster *`. Operates on a cluster directory via `--config `. -- **`local`** — `policy *`, `embed`, `login`, `logout`, `config`, `version`, `queries list`. Address no graph. +- **`direct`** — `init`, `optimize`, `repair`, `cleanup`, `schema plan`, `lint`. Need **direct storage access** (`file://` / `s3://`), never through a server. They accept a positional `URI`, but **not** `--server`, and a remote (`http(s)://`) URI is rejected. `optimize` / `repair` / `cleanup` additionally accept **`--cluster --graph `** (`--cluster` is a cluster directory or storage-root URI, named via `clusters:` in `~/.omnigraph/config.yaml` or a literal root), which resolves the graph's storage URI from the served cluster state (so you needn't know the `/graphs/.omni` layout). `--graph` is the one graph selector across all scopes — on these three verbs it picks the cluster graph; on the other `direct` verbs it does not apply. +- **`control`** — `cluster *` via `--config `; `policy *` and `queries *` via `--cluster ` or a cluster profile. +- **`local`** — `alias`, `embed`, `login`, `logout`, `profile`, `version`. Address no explicit graph scope. These restrictions are enforced and reported, not silent: -- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside the maintenance verbs, or `--graph` where no multi-graph scope applies, e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` +- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, or `--graph` where no multi-graph scope applies, e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster --graph .`` - A `direct` verb pointed at a remote URI fails loudly, e.g.: ``optimize is a direct (storage-native) command and needs direct storage access; the resolved target is a remote server (https://…). Pass the graph's file:// or s3:// URI.`` - A data verb pointed at a positional `http(s)://` URI fails loudly: ``a remote graph must be addressed with --server — a positional (or --uri) http(s):// URL no longer dispatches to a server.`` - `init` into an **established cluster's** storage layout (`/graphs/.omni` where `` holds `__cluster/state.json`) is refused — graphs in a cluster are created by `cluster apply` (which records ledger / recovery / approvals), not `init`. diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index f307e86..bd14e1e 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -40,16 +40,16 @@ graph id from the cluster's applied revision: | GET | `/openapi.json` | none | — (strips security if auth disabled; emits the nested cluster paths with `cluster_` operation-id prefix) | | GET | `/graphs/{id}/snapshot?branch=` | bearer + `read` | snapshot of branch | | POST | `/graphs/{id}/query` | bearer + `read` | inline read query (canonical; clean field names `query`/`name`; mutations → 400) | -| POST | `/graphs/{id}/read` | bearer + `read` | **deprecated** alias of `/query` (legacy field names `query_source`/`query_name`, byte-stable response; carries `Deprecation: true` + `Link: ; rel="successor-version"`) | +| POST | `/graphs/{id}/read` | bearer + `read` | **deprecated** alias of `/query` (legacy field names `query_source`/`query_name`, byte-stable response; carries `Deprecation: true` + `Link: ; rel="successor-version"`) | | POST | `/graphs/{id}/export` | bearer + `export` | NDJSON stream | | POST | `/graphs/{id}/mutate` | bearer + `change` | mutation (canonical; `query`/`name`; accepts legacy `query_source`/`query_name` as serde aliases) | -| POST | `/graphs/{id}/change` | bearer + `change` | **deprecated** alias of `/mutate` (carries `Deprecation: true` + `Link: ; rel="successor-version"`) | +| POST | `/graphs/{id}/change` | bearer + `change` | **deprecated** alias of `/mutate` (carries `Deprecation: true` + `Link: ; rel="successor-version"`) | | GET | `/graphs/{id}/queries` | bearer + `read` | list the `mcp.expose` stored queries as a typed tool catalog | | POST | `/graphs/{id}/queries/{name}` | bearer + `invoke_query` (+ `change` for a stored mutation) | invoke a named query from the `queries:` registry; deny == 404 | | GET | `/graphs/{id}/schema` | bearer + `read` | get current `.pg` source | -| POST | `/graphs/{id}/schema/apply` | bearer + `schema_apply` (target=`main`) | migrate | +| POST | `/graphs/{id}/schema/apply` | bearer + `schema_apply` (target=`main`) | disabled for cluster-backed serving; returns 409 and points operators at `omnigraph cluster apply` + restart | | POST | `/graphs/{id}/load` | bearer + `branch_create` (only when `from` is set and the branch is created) + `change` | bulk load (canonical); branch creation is opt-in via `from` — without it a missing `branch` is a 404, never an implicit fork (32 MB body limit) | -| POST | `/graphs/{id}/ingest` | bearer + `branch_create` (only when `from` is set and the branch is created) + `change` | **deprecated** alias of `/load` (carries `Deprecation: true` + `Link: ; rel="successor-version"`) (32 MB body limit) | +| POST | `/graphs/{id}/ingest` | bearer + `branch_create` (only when `from` is set and the branch is created) + `change` | **deprecated** alias of `/load` (carries `Deprecation: true` + `Link: ; rel="successor-version"`) (32 MB body limit) | | GET | `/graphs/{id}/branches` | bearer + `read` | list branches | | POST | `/graphs/{id}/branches` | bearer + `branch_create` | create | | DELETE | `/graphs/{id}/branches/{branch}` | bearer + `branch_delete` | delete | @@ -131,8 +131,8 @@ channels: - **Response headers (RFC 9745)**: every response carries `Deprecation: true`. - **Response headers (RFC 8288)**: every response carries a `Link` header pointing at the canonical successor: - `Link: ; rel="successor-version"` for `/read`, and - `Link: ; rel="successor-version"` for `/change`. SDKs and HTTP + `Link: ; rel="successor-version"` for `/read`, and + `Link: ; rel="successor-version"` for `/change`. SDKs and HTTP proxies can pick the successor up automatically. Migration is purely cosmetic on the client side — swap the URL path, leave diff --git a/openapi.json b/openapi.json index ce39fcf..fb76fae 100644 --- a/openapi.json +++ b/openapi.json @@ -412,7 +412,7 @@ "mutations" ], "summary": "**Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead.", - "description": "Apply a GQ mutation to a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /mutate`, which has identical semantics and a name that pairs\ncleanly with `POST /query`. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", + "description": "Apply a GQ mutation to a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /mutate`, which has identical semantics and a name that pairs\ncleanly with `POST /query`. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", "operationId": "cluster_change", "parameters": [ { @@ -437,7 +437,7 @@ }, "responses": { "200": { - "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", + "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { @@ -731,7 +731,7 @@ "mutations" ], "summary": "**Deprecated** — use [`POST /load`](#tag/mutations/operation/load) instead.", - "description": "Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /load`, which has identical semantics. Responses from this route\ninclude `Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal.", + "description": "Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /load`, which has identical semantics. Responses from this route\ninclude `Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal.", "operationId": "cluster_ingest", "parameters": [ { @@ -756,7 +756,7 @@ }, "responses": { "200": { - "description": "Load results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", + "description": "Load results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { @@ -1275,7 +1275,7 @@ "queries" ], "summary": "**Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead.", - "description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", + "description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", "operationId": "cluster_read", "parameters": [ { @@ -1300,7 +1300,7 @@ }, "responses": { "200": { - "description": "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", + "description": "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { @@ -1412,7 +1412,7 @@ "mutations" ], "summary": "Apply a schema migration.", - "description": "Diffs `schema_source` against the current schema and applies the resulting\nmigration steps (add/drop type, add/drop column, etc.). **Destructive**:\nsome steps drop data. Returns the list of steps applied; if `applied` is\nfalse the diff was unsupported and no changes were made.", + "description": "Cluster-backed servers reject this route with `409 Conflict`; operators\nmust apply schema changes through `omnigraph cluster apply` and restart.\n\nDiffs `schema_source` against the current schema and applies the resulting\nmigration steps (add/drop type, add/drop column, etc.). **Destructive**:\nsome steps drop data. Returns the list of steps applied; if `applied` is\nfalse the diff was unsupported and no changes were made.", "operationId": "cluster_applySchema", "parameters": [ { @@ -1476,6 +1476,16 @@ } } }, + "409": { + "description": "Schema apply is disabled for cluster-backed serving; use `omnigraph cluster apply` and restart", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, "429": { "description": "Per-actor admission cap exceeded; honor `Retry-After` header", "content": {