From e1697b265fc56c8c8326e83ce9c053534f817b9a Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Mon, 5 Oct 2026 12:21:51 +0300 Subject: [PATCH 01/12] feat(cluster): restore live configuration and explicit graph lifecycle --- .../cluster-lifecycle-completion.added.md | 3 + changelog.d/release.json | 4 +- crates/omnigraph-api-types/src/lib.rs | 2 +- crates/omnigraph-cli/src/cli.rs | 11 +- crates/omnigraph-cli/src/cluster_remote.rs | 9 +- crates/omnigraph-cli/src/helpers.rs | 26 + crates/omnigraph-cli/src/main.rs | 31 +- crates/omnigraph-cli/src/managed.rs | 9 + crates/omnigraph-cli/src/output.rs | 15 + crates/omnigraph-cli/src/planes.rs | 16 +- crates/omnigraph-cli/tests/cli_cluster.rs | 12 + crates/omnigraph-cli/tests/cli_cluster_e2e.rs | 212 +++- .../omnigraph-cli/tests/cli_schema_config.rs | 2 +- crates/omnigraph-cluster/src/admission.rs | 3 +- crates/omnigraph-cluster/src/authorization.rs | 19 +- crates/omnigraph-cluster/src/deployment.rs | 85 +- .../src/deployment/execution.rs | 1064 ++++++++++++----- .../omnigraph-cluster/src/deployment/tests.rs | 545 ++++++++- crates/omnigraph-cluster/src/diff.rs | 5 + crates/omnigraph-cluster/src/lib.rs | 140 ++- crates/omnigraph-cluster/src/serve.rs | 46 +- crates/omnigraph-cluster/src/store.rs | 24 + crates/omnigraph-cluster/src/tests.rs | 113 +- crates/omnigraph-cluster/src/types.rs | 15 +- crates/omnigraph-cluster/tests/failpoints.rs | 304 ++++- crates/omnigraph-server/src/deployment.rs | 281 ++++- crates/omnigraph-server/src/handlers.rs | 72 +- crates/omnigraph-server/src/lib.rs | 15 +- crates/omnigraph-server/src/registry.rs | 409 ++++++- crates/omnigraph-server/src/serving.rs | 39 +- crates/omnigraph-server/src/settings.rs | 33 +- .../omnigraph-server/tests/boot_settings.rs | 383 +++++- crates/omnigraph/src/db/omnigraph.rs | 43 + crates/omnigraph/tests/end_to_end.rs | 20 +- .../omnigraph/tests/policy_engine_chassis.rs | 62 + crates/omnigraph/tests/search.rs | 39 + docs/dev/control-plane.md | 34 +- docs/dev/invariants.md | 8 +- docs/rfcs/0049-control-plane-seams.md | 2 +- ...29-server-runtime-and-online-deployment.md | 124 +- docs/user/cli/index.md | 9 +- docs/user/cli/managed-data.md | 4 +- docs/user/clusters/config.md | 7 +- docs/user/clusters/index.md | 71 +- docs/user/deployment.md | 2 +- docs/user/operations/policy.md | 13 +- docs/user/operations/server.md | 14 +- docs/user/operations/troubleshooting.md | 16 +- docs/user/operations/upgrade.md | 4 +- openapi.json | 2 +- 50 files changed, 3798 insertions(+), 623 deletions(-) create mode 100644 changelog.d/cluster-lifecycle-completion.added.md diff --git a/changelog.d/cluster-lifecycle-completion.added.md b/changelog.d/cluster-lifecycle-completion.added.md new file mode 100644 index 000000000..9117156b3 --- /dev/null +++ b/changelog.d/cluster-lifecycle-completion.added.md @@ -0,0 +1,3 @@ +- Cluster apply supports policy grants/revocations and rebinding, embedding-provider changes, and external-Blob rules on existing graphs without restarting the server. Explicit lifecycle input supports graph removal with retained storage, exact adoption, missing-root recreation and targeted catalog repair; plan performs the same effect-free checks as apply, and unrelated unavailable graphs do not block independent changes. See [cluster operations][cluster-lifecycle-completion]. + +[cluster-lifecycle-completion]: ../docs/user/clusters/index.md diff --git a/changelog.d/release.json b/changelog.d/release.json index 788c18fb1..7079c1508 100644 --- a/changelog.d/release.json +++ b/changelog.d/release.json @@ -1,5 +1,5 @@ { - "version": "v0.12.0", - "base": "refs/tags/v0.11.0", + "version": "v0.12.1", + "base": "refs/tags/v0.12.0", "legacy": null } diff --git a/crates/omnigraph-api-types/src/lib.rs b/crates/omnigraph-api-types/src/lib.rs index fa7d72601..c621670c2 100644 --- a/crates/omnigraph-api-types/src/lib.rs +++ b/crates/omnigraph-api-types/src/lib.rs @@ -2206,7 +2206,7 @@ pub enum GraphAvailabilityAction { None, WaitForStartup, WaitForTransition, - RestartAfterCorrection, + ApplyCorrectionOrRestart, WaitForRestart, } diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index ef9089bc1..2a525cffc 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -645,6 +645,12 @@ pub(crate) enum ClusterCommand { /// any lock instead of refusing, and label the output `observed`. #[arg(long)] observe: bool, + /// Core: exact lifecycle confirmations and repair options as JSON (at most 16 MiB). + #[arg(long)] + lifecycle: Option, + /// Core: exact observed schema contracts to correct. + #[arg(long, conflicts_with = "lifecycle")] + schema_correction: Option, /// Managed: select a pushed revision; omission uses the bound head. #[arg(long = "rev", alias = "revision")] revision: Option, @@ -670,8 +676,11 @@ pub(crate) enum ClusterCommand { deployment_id: Option, /// Core config apply only: JSON map from graph id to the exact observed /// SchemaContractDigest being corrected (at most 16 MiB). - #[arg(long, conflicts_with = "plan")] + #[arg(long, conflicts_with_all = ["plan", "lifecycle"])] schema_correction: Option, + /// Core: exact graph deletion/adoption/recreation confirmations and catalog repair options. + #[arg(long, conflicts_with = "plan")] + lifecycle: Option, /// Attest prior writers and accepted graph/control I/O are quiescent. #[arg(long, requires = "deployment_id")] writers_stopped: bool, diff --git a/crates/omnigraph-cli/src/cluster_remote.rs b/crates/omnigraph-cli/src/cluster_remote.rs index f3122ac4f..77f62e681 100644 --- a/crates/omnigraph-cli/src/cluster_remote.rs +++ b/crates/omnigraph-cli/src/cluster_remote.rs @@ -77,9 +77,10 @@ impl RemoteCluster { config: &Path, requested_id: Option<&str>, correction: Option<&Path>, + lifecycle: Option<&Path>, json_output: bool, ) -> Result<()> { - let corrections = crate::read_schema_corrections(correction)?; + let options = crate::read_deployment_options(lifecycle, correction)?; let status: StatusResponse = self .request(Method::GET, &["cluster", "deployments"], None) .await?; @@ -87,9 +88,9 @@ impl RemoteCluster { // captured bytes to the authenticated server's root without opening // that storage root or resolving it on the client. let deployment = crate::core_deployment_result( - omnigraph_cluster::capture_deployment_for_server( + omnigraph_cluster::capture_deployment_for_server_with_options( config, - &corrections, + &options, &status.status.canonical_root, ), json_output, @@ -215,6 +216,7 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { config, deployment_id, schema_correction, + lifecycle, json, .. } => { @@ -223,6 +225,7 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { config, deployment_id.as_deref(), schema_correction.as_deref(), + lifecycle.as_deref(), *json, ) .await?; diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index 10dc3c72a..be7af7778 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -35,6 +35,32 @@ pub(crate) fn read_schema_corrections( .map_err(|error| color_eyre::eyre::eyre!("invalid schema correction JSON: {error}")) } +pub(crate) fn read_deployment_options( + lifecycle: Option<&std::path::Path>, + correction: Option<&std::path::Path>, +) -> Result { + use std::io::Read; + if let Some(path) = lifecycle { + if correction.is_some() { + bail!("--lifecycle and --schema-correction cannot be combined"); + } + let limit = omnigraph_cluster::MAX_BUNDLE_BYTES; + let mut bytes = Vec::new(); + fs::File::open(path)? + .take(limit as u64 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + bail!("lifecycle file exceeds the {limit}-byte deployment input limit"); + } + return serde_json::from_slice(&bytes) + .map_err(|error| color_eyre::eyre::eyre!("invalid lifecycle JSON: {error}")); + } + Ok(omnigraph_cluster::DeploymentOptions { + schema_corrections: read_schema_corrections(correction)?, + ..Default::default() + }) +} + pub(crate) fn ensure_local_graph_parent(uri: &str) -> Result<()> { if !uri.contains("://") { fs::create_dir_all(uri)?; diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 8cf82fc4d..a63170ae1 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -11,8 +11,8 @@ use omnigraph_api_types::{ }; use omnigraph_cluster::{ DiagnosticSeverity, ForceUnlockOutput, PlanOptions, PlanOutput, StateSyncOutput, StatusOutput, - ValidateOutput, force_unlock_config_dir, observe_config_dir, plan_config_dir_with_options, - status_config_dir, validate_config_dir, + ValidateOutput, force_unlock_config_dir, observe_config_dir, status_config_dir, + validate_config_dir, }; use omnigraph_compiler::query::ast::{ BranchStmt, BranchWrite, EmptyFile, FileBody, QueryFile, SettingStmt, @@ -264,12 +264,15 @@ fn validate_core_root_arguments( ClusterCommand::Apply { schema_correction: Some(_), .. + } | ClusterCommand::Apply { + lifecycle: Some(_), + .. } ) { return Err(Cli::command().error( clap::error::ErrorKind::ArgumentConflict, - "--schema-correction requires config-addressed apply; root reconciliation uses only the original captured input", + "--schema-correction and --lifecycle require config-addressed apply; root reconciliation uses only the original captured input", )); } if cli.cluster.is_some() @@ -1975,9 +1978,19 @@ async fn run(cli: Cli) -> Result<()> { config, json, observe, + lifecycle, + schema_correction, .. } => { - let output = plan_config_dir_with_options(config, PlanOptions { observe }).await; + let options = + read_deployment_options(lifecycle.as_deref(), schema_correction.as_deref())?; + let output = omnigraph_cluster::plan_config_dir_with_deployment_options( + config, + PlanOptions { observe }, + &options, + resolve_cluster_actor(cli.as_actor.as_deref())?, + ) + .await; finish_cluster_plan(&output, json)?; } ClusterCommand::Observe { config, json } => { @@ -1989,6 +2002,7 @@ async fn run(cli: Cli) -> Result<()> { json, deployment_id, schema_correction, + lifecycle, writers_stopped, .. } => { @@ -2006,12 +2020,15 @@ async fn run(cli: Cli) -> Result<()> { } finish_core_deployment(result, json)?; } else { - let corrections = read_schema_corrections(schema_correction.as_deref())?; - let result = omnigraph_cluster::apply_deployment( + let options = read_deployment_options( + lifecycle.as_deref(), + schema_correction.as_deref(), + )?; + let result = omnigraph_cluster::apply_deployment_with_options( &config, deployment_id.as_deref(), &caller, - &corrections, + &options, |id, root, lock| { let root = omnigraph::storage::redacted_storage_uri(root); eprintln!( diff --git a/crates/omnigraph-cli/src/managed.rs b/crates/omnigraph-cli/src/managed.rs index dd2f666bb..249ec9532 100644 --- a/crates/omnigraph-cli/src/managed.rs +++ b/crates/omnigraph-cli/src/managed.rs @@ -574,6 +574,15 @@ async fn cluster_command( } | ClusterCommand::Apply { schema_correction: Some(_), .. + } | ClusterCommand::Apply { + lifecycle: Some(_), + .. + } | ClusterCommand::Plan { + lifecycle: Some(_), + .. + } | ClusterCommand::Plan { + schema_correction: Some(_), + .. } | ClusterCommand::Status { deployment_id: Some(_), .. diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs index 97bf3d748..165806929 100644 --- a/crates/omnigraph-cli/src/output.rs +++ b/crates/omnigraph-cli/src/output.rs @@ -228,6 +228,21 @@ pub(crate) fn print_cluster_plan_human(output: &PlanOutput) { "" }; println!(" {:?} {}{bindings}", change.operation, change.resource); + if let Some(action) = change.lifecycle { + use omnigraph_cluster::PlanLifecycleAction; + let description = match action { + PlanLifecycleAction::RemoveRetainingStorage => { + "remove from cluster; retain storage" + } + PlanLifecycleAction::Adopt => "adopt the confirmed existing graph", + PlanLifecycleAction::Recreate => "create an empty graph with a new identity", + PlanLifecycleAction::RepairCatalog => "restore the verified catalog payload", + PlanLifecycleAction::CorrectSchemaContract => { + "accept the confirmed observed schema contract" + } + }; + println!(" {description}"); + } if let Some(migration) = &change.migration { if !migration.supported { println!(" migration UNSUPPORTED:"); diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index 6094240fe..54ae067f0 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -184,8 +184,8 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool // (rejected downstream with its own message). On `direct`, full-text // rebuild attributes its graph publication; other maintenance verbs // record no actor. `control` refines per command: - // `cluster apply`/`upgrade-ledger` attribute an actor — the other read-only - // control verbs (status/plan/validate, policy, queries) never read it. + // Apply/upgrade attribute an actor; plan checks the intended actor + // against execution preflight. Other read-only control verbs do not. ScopeFlag::As => match capability { // `--as` names an actor for a direct/`--store` WRITE; a served write // resolves the actor from its token. The read commands (`query`, @@ -201,7 +201,9 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool Control => matches!( cmd, Command::Cluster { - command: ClusterCommand::Apply { .. } | ClusterCommand::UpgradeLedger { .. }, + command: ClusterCommand::Plan { .. } + | ClusterCommand::Apply { .. } + | ClusterCommand::UpgradeLedger { .. }, .. } ), @@ -521,8 +523,8 @@ mod tests { parse(&["omnigraph", "init", "--schema", "s.pg", "g.omni"]), [false, false, false, false, false, false], ), - // Read-only control verbs never read the actor; `cluster - // apply`/`upgrade-ledger` do. The cluster family accepts + // Plan checks the intended actor; other read-only control verbs + // do not. Apply/upgrade attribute it. The cluster family accepts // root addressing for durable deployment/recovery, without graph // selection or profile scope. ( @@ -533,6 +535,10 @@ mod tests { parse(&["omnigraph", "cluster", "status", "--config", "."]), [true, true, false, false, false, false], ), + ( + parse(&["omnigraph", "cluster", "plan", "--config", "."]), + [false, false, false, false, true, false], + ), ( parse(&["omnigraph", "cluster", "apply", "--config", "."]), [true, true, false, false, true, false], diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index 8df954d7c..18edbc480 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -91,6 +91,12 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { "lock_id":"server-owner", "outstanding_id":null, "lookup":null}, "active":false }); + let lifecycle = temp.path().join("lifecycle.json"); + fs::write( + &lifecycle, + r#"{"repair_catalog":["query.knowledge.find_person"]}"#, + ) + .unwrap(); let schema = fs::read_to_string(temp.path().join("people.pg")).unwrap(); for declared_storage in [Some(remote_root.as_str()), None] { let captured_config = match declared_storage { @@ -120,6 +126,8 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { "--config", ]) .arg(temp.path()) + .arg("--lifecycle") + .arg(&lifecycle) .arg("--json"), ); assert_eq!( @@ -144,6 +152,10 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { ); } assert_eq!(requests[1].body["deployment_id"], id); + assert_eq!( + requests[1].body["deployment"]["options"]["repair_catalog"], + serde_json::json!(["query.knowledge.find_person"]) + ); assert_eq!( requests[1].body["deployment"]["canonical_root"], remote_root diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs index dc14f304a..e960d0f16 100644 --- a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -300,6 +300,81 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root "{mismatched:?}" ); assert_eq!(server.id(), original_pid); + if storage_root.is_none() { + // Simulate missing storage while its owner is stopped, then prove an + // unrelated deployment and explicit recovery both work in one new PID. + server.stop(); + unlock(); + let state: serde_json::Value = + serde_json::from_slice(&fs::read(temp.path().join("__cluster/state.json")).unwrap()) + .unwrap(); + let prior_contract = state["applied_revision"]["schema_contracts"]["peer"].clone(); + fs::remove_dir_all(temp.path().join("graphs/peer.omni")).unwrap(); + let recovering = spawn_server_with_cluster_env( + temp.path(), + &[( + "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", + r#"{"act-live":"live-deployment-token"}"#, + )], + ); + let recovery_pid = recovering.id(); + let read_peer = || { + client.post(format!("{}/graphs/peer/query", recovering.base_url)) + .bearer_auth("live-deployment-token") + .json(&serde_json::json!({"query":"query people() { match { $p: Person } return { $p.name } }", "name":"people", "params":{}})) + .send().unwrap() + }; + assert_eq!(read_peer().status(), 503); + // The tools query differs from the last accepted bytes; peer is unchanged. + let unrelated = parse_stdout_json(&output_success( + cli() + .env("OMNIGRAPH_BEARER_TOKEN", "live-deployment-token") + .args([ + "cluster", + "apply", + "--server", + &recovering.base_url, + "--config", + ]) + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(unrelated["active"], true, "{unrelated}"); + assert_eq!(read_peer().status(), 503); + let lifecycle = temp.path().join("lifecycle.json"); + fs::write( + &lifecycle, + serde_json::to_vec(&serde_json::json!({"recreate_graphs":{"peer":prior_contract}})) + .unwrap(), + ) + .unwrap(); + let repaired = parse_stdout_json(&output_success( + cli() + .env("OMNIGRAPH_BEARER_TOKEN", "live-deployment-token") + .args([ + "cluster", + "apply", + "--server", + &recovering.base_url, + "--config", + ]) + .arg(temp.path()) + .arg("--lifecycle") + .arg(&lifecycle) + .arg("--json"), + )); + assert_eq!(repaired["active"], true, "{repaired}"); + assert_eq!( + repaired["deployment"]["result"]["graphs"]["peer"]["outcome"], + "created" + ); + assert_ne!( + repaired["deployment"]["result"]["graphs"]["peer"]["contract"], + prior_contract + ); + assert_eq!(read_peer().status(), 200); + assert_eq!(recovering.id(), recovery_pid); + } } #[test] @@ -454,7 +529,7 @@ fn cluster_e2e_offline_deployment_has_root_only_receipts_and_explicit_unlock() { ])); assert_eq!(refused.status.code(), Some(2)); assert!( - String::from_utf8_lossy(&refused.stderr).contains("requires config-addressed apply") + String::from_utf8_lossy(&refused.stderr).contains("require config-addressed apply") ); for args in [ vec!["cluster", "upgrade-ledger", "--writers-stopped"], @@ -596,7 +671,7 @@ fn cluster_e2e_force_unlock_unblocks_apply() { fn cluster_e2e_lost_ledger_does_not_adopt_existing_graphs() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); - apply_cluster_fixture(temp.path()); + let initial = apply_cluster_fixture(temp.path()); let graph = temp.path().join("graphs/knowledge.omni"); let before = manifest_dataset_version(&graph); fs::remove_file(temp.path().join("__cluster/state.json")).unwrap(); @@ -607,11 +682,22 @@ fn cluster_e2e_lost_ledger_does_not_adopt_existing_graphs() { .arg("--json"), ); assert!( - String::from_utf8_lossy(&refused.stderr).contains("already exists"), + parse_stdout_json(&refused)["diagnostics"][0]["code"] == "graph_adoption_required", "{refused:?}" ); assert_eq!(manifest_dataset_version(&graph), before); assert!(!temp.path().join("__cluster/lock.json").exists()); + let created = &initial["result"]["graphs"]["knowledge"]; + let lifecycle = temp.path().join("lifecycle.json"); + fs::write(&lifecycle, serde_json::to_vec(&serde_json::json!({"adopt_graphs": {"knowledge": { + "contract": created["contract"], "graph_manifest_version": created["graph_manifest_version"] + }}})).unwrap()).unwrap(); + let adopted = apply_lifecycle_fixture(temp.path(), &lifecycle); + assert_eq!( + adopted["result"]["graphs"]["knowledge"]["outcome"], + "adopted" + ); + assert_eq!(manifest_dataset_version(&graph), before); } #[test] @@ -621,7 +707,20 @@ fn cluster_e2e_destroyed_graph_is_not_silently_recreated() { apply_cluster_fixture(temp.path()); let graph = temp.path().join("graphs/knowledge.omni"); let before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let state: serde_json::Value = serde_json::from_slice(&before).unwrap(); + let contract = &state["applied_revision"]["schema_contracts"]["knowledge"]; fs::remove_dir_all(&graph).unwrap(); + // A query change makes this graph affected. An unrelated/no-op deployment + // must not open every declared graph merely to check its availability. + fs::write( + temp.path().join("people.gq"), + "query find_person($name: String) { + match { $p: Person { name: $name } } + return { $p.name } +} +", + ) + .unwrap(); let refused = output_failure( cli() .args(["cluster", "apply", "--config"]) @@ -638,10 +737,26 @@ fn cluster_e2e_destroyed_graph_is_not_silently_recreated() { ); assert!(!graph.exists()); assert!(!temp.path().join("__cluster/lock.json").exists()); + let lifecycle = temp.path().join("lifecycle.json"); + fs::write( + &lifecycle, + serde_json::to_vec(&serde_json::json!({"recreate_graphs":{"knowledge":contract}})).unwrap(), + ) + .unwrap(); + let recreated = apply_lifecycle_fixture(temp.path(), &lifecycle); + assert_eq!( + recreated["result"]["graphs"]["knowledge"]["outcome"], + "created" + ); + assert_ne!( + recreated["result"]["graphs"]["knowledge"]["contract"], + *contract + ); + assert!(graph.join("__manifest").exists()); } #[test] -fn cluster_e2e_declared_graph_created_by_apply_and_deletion_refused() { +fn cluster_e2e_declared_graph_requires_exact_removal_and_adoption() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); apply_cluster_fixture(temp.path()); @@ -667,7 +782,7 @@ fn cluster_e2e_declared_graph_created_by_apply_and_deletion_refused() { .join("graphs/engineering.omni/__manifest") .exists() ); - fs::write(&config, source).unwrap(); + fs::write(&config, &source).unwrap(); let before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); let refused = output_failure( cli() @@ -677,7 +792,7 @@ fn cluster_e2e_declared_graph_created_by_apply_and_deletion_refused() { ); assert_eq!( parse_stdout_json(&refused)["diagnostics"][0]["code"], - "deployment_scope" + "graph_delete_confirmation_required" ); assert_eq!( fs::read(temp.path().join("__cluster/state.json")).unwrap(), @@ -689,6 +804,42 @@ fn cluster_e2e_declared_graph_created_by_apply_and_deletion_refused() { .exists() ); assert!(!temp.path().join("__cluster/lock.json").exists()); + let graph = temp.path().join("graphs/engineering.omni"); + let version = manifest_dataset_version(&graph); + let confirmation = serde_json::json!({ + "contract": created["result"]["graphs"]["engineering"]["contract"], + "graph_manifest_version": version + }); + let lifecycle = temp.path().join("lifecycle.json"); + fs::write( + &lifecycle, + serde_json::to_vec(&serde_json::json!({"delete_graphs":{"engineering":confirmation}})) + .unwrap(), + ) + .unwrap(); + let deleted = apply_lifecycle_fixture(temp.path(), &lifecycle); + assert_eq!( + deleted["result"]["graphs"]["engineering"]["outcome"], + "deleted" + ); + assert_eq!( + deleted["result"]["graphs"]["engineering"]["retained_storage"], + true + ); + assert_eq!(manifest_dataset_version(&graph), version); + fs::write(&config, &expanded).unwrap(); + fs::write( + &lifecycle, + serde_json::to_vec(&serde_json::json!({"adopt_graphs":{"engineering":confirmation}})) + .unwrap(), + ) + .unwrap(); + let adopted = apply_lifecycle_fixture(temp.path(), &lifecycle); + assert_eq!( + adopted["result"]["graphs"]["engineering"]["outcome"], + "adopted" + ); + assert_eq!(manifest_dataset_version(&graph), version); } #[test] @@ -708,7 +859,7 @@ fn cluster_e2e_removed_v1_commands_are_not_executable() { } #[test] -fn cluster_e2e_payload_drift_self_heals() { +fn cluster_e2e_payload_drift_requires_targeted_repair() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); apply_cluster_fixture(temp.path()); @@ -734,6 +885,33 @@ fn cluster_e2e_payload_drift_self_heals() { "{status}" ); apply_cluster_fixture(temp.path()); + assert!( + !blob.exists(), + "unchanged configuration must not repair untargeted payloads" + ); + let lifecycle = temp.path().join("lifecycle.json"); + fs::write( + &lifecycle, + r#"{"repair_catalog":["query.knowledge.find_person"]}"#, + ) + .unwrap(); + let plan = parse_stdout_json(&output_success( + cli() + .args(["--as", "act-cluster-test", "cluster", "plan", "--config"]) + .arg(temp.path()) + .arg("--lifecycle") + .arg(&lifecycle) + .arg("--json"), + )); + assert!( + plan["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| change["lifecycle"] == "repair_catalog") + ); + assert!(!blob.exists(), "plan must remain effect-free"); + apply_lifecycle_fixture(temp.path(), &lifecycle); assert_eq!(fs::read(&blob).unwrap(), expected); let clean = cluster_json(temp.path(), "status"); assert!( @@ -748,3 +926,23 @@ fn cluster_e2e_payload_drift_self_heals() { "{clean}" ); } + +/// Exercise lifecycle JSON through the same direct owner/unlock discipline as +/// apply_cluster_fixture; no raw ledger or graph mutation bypasses the command. +fn apply_lifecycle_fixture( + root: &std::path::Path, + lifecycle: &std::path::Path, +) -> serde_json::Value { + let result = parse_stdout_json(&output_success( + cli() + .args(["--as", "act-cluster-test", "cluster", "apply", "--config"]) + .arg(root) + .arg("--lifecycle") + .arg(lifecycle) + .arg("--json"), + )); + assert_eq!(result["status"], "complete", "{result}"); + assert_eq!(result["result"]["converged"], true, "{result}"); + unlock_cluster_fixture(root); + result +} diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index b3f098f43..e0fe44cc8 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -662,7 +662,7 @@ fn explicit_graph_discovery_preserves_jwt_shaped_static_catalog_and_skips_contex let reply = if discovery { serde_json::json!({"graphs":[{"graph_id":"alpha","display_name":"alpha"}]}) } else { - serde_json::json!({"graphs":[{"graph_id":"alpha","uri":"file:///private/alpha","state":"ready","read_available":true,"write_available":true,"action":"none"},{"graph_id":"beta","uri":"file:///private/beta","state":"blocked","read_available":false,"write_available":false,"failure":"open_failed","action":"restart_after_correction"}]}) + serde_json::json!({"graphs":[{"graph_id":"alpha","uri":"file:///private/alpha","state":"ready","read_available":true,"write_available":true,"action":"none"},{"graph_id":"beta","uri":"file:///private/beta","state":"blocked","read_available":false,"write_available":false,"failure":"open_failed","action":"apply_correction_or_restart"}]}) }; let server = IntentApiFixture::graph(vec![IntentReply::json(200, reply.clone())]); let mut command = cli(); diff --git a/crates/omnigraph-cluster/src/admission.rs b/crates/omnigraph-cluster/src/admission.rs index d5eaa7da8..08150e91c 100644 --- a/crates/omnigraph-cluster/src/admission.rs +++ b/crates/omnigraph-cluster/src/admission.rs @@ -313,7 +313,8 @@ pub(crate) async fn acquire_with_store( })))) } -pub(crate) fn canonical_graph_uri(graph_uri: &str) -> Result { +/// Canonical process-owner identity for a graph URI, including local aliases. +pub fn canonical_graph_uri(graph_uri: &str) -> Result { omnigraph_storage::normalize_root_uri(graph_uri) .and_then(|root| omnigraph_storage::write_queue_root_identity(&root)) .map_err(|error| { diff --git a/crates/omnigraph-cluster/src/authorization.rs b/crates/omnigraph-cluster/src/authorization.rs index ab8fa22de..b98cd6a06 100644 --- a/crates/omnigraph-cluster/src/authorization.rs +++ b/crates/omnigraph-cluster/src/authorization.rs @@ -219,6 +219,17 @@ impl AppliedPolicies { pub(crate) async fn load_optional( backend: &ClusterStore, state: &ClusterState, + ) -> Result { + Self::load_optional_with_sources(backend, state, &BTreeMap::new()).await + } + + /// Repair authority is restricted to a storage owner. Replacement source + /// must hash to the currently applied policy; candidate policy cannot grant + /// authority to repair itself. This method does not write catalog bytes. + pub(crate) async fn load_optional_with_sources( + backend: &ClusterStore, + state: &ClusterState, + repair_sources: &BTreeMap, ) -> Result { if state.applied_revision.resources.len() > MAX_AUTHORIZATION_RESOURCES { return Err(refusal( @@ -250,7 +261,13 @@ impl AppliedPolicies { address, MAX_POLICY_BYTES.min(MAX_POLICY_TOTAL_BYTES.saturating_sub(total_bytes)), ) - .await?; + .await + .or_else(|error| match repair_sources.get(&entry.digest) { + Some(source) if sha256_hex(source.as_bytes()) == entry.digest => { + Ok(source.clone()) + } + _ => Err(error), + })?; total_bytes = total_bytes.saturating_add(source.len()); if source.len() > MAX_POLICY_BYTES || total_bytes > MAX_POLICY_TOTAL_BYTES { return Err(refusal( diff --git a/crates/omnigraph-cluster/src/deployment.rs b/crates/omnigraph-cluster/src/deployment.rs index 3cf73ad70..fb7ff2350 100644 --- a/crates/omnigraph-cluster/src/deployment.rs +++ b/crates/omnigraph-cluster/src/deployment.rs @@ -1,4 +1,4 @@ -//! Durable schema/query and graph-creation deployments in the cluster ledger. +//! Durable graph lifecycle and runtime configuration in the cluster ledger. //! //! Graph publication remains engine authority. This module stores immutable //! input and the engine's exact outcomes; it never reconstructs a receipt from @@ -97,6 +97,27 @@ pub(crate) struct DeploymentAuthorization { pub effects: Vec, } +/// Explicit acknowledgements for operations that cannot be inferred safely from +/// a config diff. These bytes are captured into the original deployment input. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct DeploymentOptions { + pub schema_corrections: BTreeMap, + pub delete_graphs: BTreeMap, + pub adopt_graphs: BTreeMap, + pub recreate_graphs: BTreeMap, + pub repair_catalog: BTreeSet, +} + +/// Exact accepted graph incarnation and observed main manifest. A reused name +/// or equal schema text is insufficient confirmation for adoption or removal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GraphLifecycleConfirmation { + pub contract: omnigraph::db::SchemaContractDigest, + pub graph_manifest_version: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CapturedDeployment { @@ -106,14 +127,14 @@ pub struct CapturedDeployment { pub(crate) config_semantics: String, pub(crate) resources: BTreeMap, pub(crate) sources: BTreeMap, - /// Exact observed contracts explicitly acknowledged for correction. Empty - /// is the ordinary deployment form; the map is part of immutable input. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) schema_corrections: BTreeMap, + pub(crate) options: DeploymentOptions, } /// Frozen, bounded source input. The server never opens caller-supplied paths. impl CapturedDeployment { + pub fn options(&self) -> &DeploymentOptions { + &self.options + } pub fn canonical_root(&self) -> &str { &self.canonical_root } @@ -147,6 +168,10 @@ pub(crate) struct OutstandingDeployment { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct GraphDeployment { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adopt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delete: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub create: Option, pub intent: Option, @@ -167,6 +192,15 @@ pub(crate) enum GraphDeploymentState { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] pub enum GraphDeploymentResult { + Adopted { + graph_manifest_version: u64, + contract: omnigraph::db::SchemaContractDigest, + }, + Deleted { + graph_manifest_version: u64, + contract: omnigraph::db::SchemaContractDigest, + retained_storage: bool, + }, Created { graph_manifest_version: u64, contract: omnigraph::db::SchemaContractDigest, @@ -193,6 +227,8 @@ impl GraphDeploymentResult { | SchemaApplySettlement::NoOp { .. } } | Self::QueryOnly { .. } | Self::Created { .. } + | Self::Adopted { .. } + | Self::Deleted { .. } ) } @@ -607,9 +643,22 @@ pub(crate) fn validate_state(state: &ClusterState) -> Result<(), Diagnostic> { .bytes() .all(|byte| byte.is_ascii_lowercase() || byte == b'_') } + GraphDeploymentResult::Deleted { + retained_storage: false, + .. + } => true, GraphDeploymentResult::Created { graph_manifest_version, contract, + } + | GraphDeploymentResult::Adopted { + graph_manifest_version, + contract, + } + | GraphDeploymentResult::Deleted { + graph_manifest_version, + contract, + .. } => *graph_manifest_version == 0 || !valid_contract(contract), GraphDeploymentResult::QueryOnly { graph_manifest_version, @@ -677,6 +726,7 @@ pub(crate) fn validate_state(state: &ClusterState) -> Result<(), Diagnostic> { || pending.graphs.iter().any(|(graph, entry)| { !valid_resource_name(graph) || (entry.create.is_none() + && entry.adopt.is_none() && !state .applied_revision .resources @@ -686,12 +736,25 @@ pub(crate) fn validate_state(state: &ClusterState) -> Result<(), Diagnostic> { .as_ref() .is_some_and(|create| create.validate().is_err()) || (entry.create.is_some() - && (entry.intent.is_some() - || entry.observed_manifest_version != 0 - || state - .applied_revision - .resources - .contains_key(&graph_address(graph)))) + && (entry.intent.is_some() || entry.observed_manifest_version != 0)) + || [ + entry.create.is_some(), + entry.intent.is_some(), + entry.adopt.is_some(), + entry.delete.is_some(), + ] + .into_iter() + .filter(|present| *present) + .count() + > 1 + || entry + .adopt + .iter() + .chain(entry.delete.iter()) + .any(|confirmation| { + confirmation.graph_manifest_version != entry.observed_manifest_version + || !valid_contract(&confirmation.contract) + }) || (entry.create.is_none() && entry.observed_manifest_version == 0) || entry.intent.as_ref().is_some_and(|intent| { intent.actor() != pending.authorization.authority.actor.as_deref() diff --git a/crates/omnigraph-cluster/src/deployment/execution.rs b/crates/omnigraph-cluster/src/deployment/execution.rs index 6f84a407e..9bc5a9533 100644 --- a/crates/omnigraph-cluster/src/deployment/execution.rs +++ b/crates/omnigraph-cluster/src/deployment/execution.rs @@ -3,7 +3,9 @@ use crate::authorization::AppliedPolicies; use omnigraph_policy::PolicyAction; use omnigraph_seams::decide_seam; -async fn read_existing(store: &ClusterStore) -> Result<(ClusterState, String), Diagnostic> { +pub(super) async fn read_existing( + store: &ClusterStore, +) -> Result<(ClusterState, String), Diagnostic> { let snapshot = store.read_state(&mut store.observations()).await?; Ok(( snapshot @@ -29,7 +31,6 @@ async fn policies( store: &ClusterStore, state: &ClusterState, caller: &DeploymentCaller, - manage: bool, ) -> Result { caller.authority()?; let policies = match caller { @@ -38,14 +39,10 @@ async fn policies( } DeploymentCaller::AuthenticatedIdentity(identity) => { let policies = AppliedPolicies::load(store, state).await?; - if manage { - policies.check_cluster(identity.actor())?; - } - for address in state.applied_revision.resources.keys() { - if let ResourceKind::Graph(graph) = resource_kind(address) { - policies.check_graph(identity.actor(), &graph, PolicyAction::Read)?; - } - } + // Configuration management includes ledger/receipt metadata. Graph + // Read gates graph data, not unrelated cluster inventory; schema + // effects enforce their graph actions separately during preflight. + policies.check_cluster(identity.actor())?; policies } }; @@ -101,6 +98,25 @@ fn authorize_bootstrap_bundle( Ok(()) } +fn repair_policy_sources( + state: &ClusterState, + bundle: &DeploymentBundle, +) -> BTreeMap { + bundle + .options + .repair_catalog + .iter() + .filter_map(|address| { + if !matches!(resource_kind(address), ResourceKind::Policy(_)) { + return None; + } + let entry = state.applied_revision.resources.get(address)?; + let source = bundle.sources.get(&entry.digest)?; + Some((entry.digest.clone(), source.clone())) + }) + .collect() +} + async fn deployment_policies( store: &ClusterStore, state: &ClusterState, @@ -125,7 +141,20 @@ async fn deployment_policies( return AppliedPolicies::load_optional(store, state).await; } } - policies(store, state, caller, true).await + if let DeploymentCaller::StorageOwner { .. } = caller { + if !bundle.options.repair_catalog.is_empty() { + return AppliedPolicies::load_optional_with_sources( + store, + state, + &repair_policy_sources(state, bundle), + ) + .await; + } + return AppliedPolicies::load_optional(store, state).await; + } + let applied = AppliedPolicies::load(store, state).await?; + applied.check_cluster(caller.actor().unwrap())?; + Ok(applied) } // Before first convergence there is no applied management policy. Only the @@ -137,6 +166,24 @@ async fn lookup_policies( caller: &DeploymentCaller, id: Option<&str>, ) -> Result { + if matches!(caller, DeploymentCaller::StorageOwner { .. }) { + if let Some(pending) = state + .outstanding + .as_ref() + .filter(|pending| Some(pending.id.as_str()) == id) + { + let bundle = store.read_deployment_bundle(&pending.input_digest).await?; + validate_bundle(&bundle, &store.canonical_root()?)?; + if !bundle.options.repair_catalog.is_empty() { + return AppliedPolicies::load_optional_with_sources( + store, + state, + &repair_policy_sources(state, &bundle), + ) + .await; + } + } + } if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { if identity.has_bootstrap_authority() && state.applied_revision.resources.is_empty() { let pending = state @@ -160,7 +207,7 @@ async fn lookup_policies( return AppliedPolicies::load_optional(store, state).await; } } - policies(store, state, caller, true).await + policies(store, state, caller).await } fn graph_ids(state: &ClusterState) -> Vec { @@ -322,7 +369,7 @@ pub async fn upgrade_deployment_ledger( } // Refuse unauthorized conversion before acquiring durable admission; a // denied caller must not strand a lock. Recheck current policy under it. - policies(&store, &before, caller, true).await?; + policies(&store, &before, caller).await?; let mut observations = store.observations(); let mut guard = store .acquire_lock("upgrade_ledger", &mut observations) @@ -339,7 +386,7 @@ pub async fn upgrade_deployment_ledger( "ledger changed during conversion; inspect original root", )); } - policies(&store, &state, caller, true).await?; + policies(&store, &state, caller).await?; authorization::refuse_pending_recovery(&store).await?; serve::read_snapshot_for_ledger_upgrade(&store) .await @@ -431,28 +478,34 @@ async fn bootstrap_ledger( "cluster initialized while acquiring bootstrap ownership", )); } - authorization::refuse_pending_recovery(store).await?; - for graph in bundle.graph_ids() { - if store - .graph_root_exists(&store.graph_root(&graph)) - .await - .map_err(|error| refusal("graph_unavailable", error.to_string()))? - { - return Err(refusal( - "graph_already_exists", - format!( - "graph {graph} already exists; bootstrap never adopts an unregistered graph" - ), - )); - } - } - Ok::<_, Diagnostic>(()) + // Validate the same captured lifecycle input before the first ledger + // write. Existing roots require exact adoption confirmation, including + // after a lost ledger; failed confirmation leaves no initialized state. + let state = empty_ledger(); + let id = format!("{}:1:{}", state.ledger_id.as_ref().unwrap(), Ulid::new()); + let input_digest = sha256_hex( + &serde_json::to_vec(bundle) + .map_err(|error| refusal("deployment_encode", error.to_string()))?, + ); + Box::pin(prepare_deployment( + store, + bundle, + caller, + Some((state.clone(), format!("sha256:{}", sha256_hex(b"")))), + &BTreeMap::new(), + &id, + &input_digest, + )) + .await?; + Ok::<_, Diagnostic>(state) } .await; - if let Err(error) = checked { - return Err(admission::release_refused_preflight(store, guard.lock_id(), error).await); - } - let state = empty_ledger(); + let state = match checked { + Ok(state) => state, + Err(error) => { + return Err(admission::release_refused_preflight(store, guard.lock_id(), error).await); + } + }; store .write_state(&state, None, &mut observations) .await @@ -531,7 +584,7 @@ fn bundle_from_capture( config_digest: desired.config_digest.clone(), config_semantics: desired.config_semantics.clone(), resources, - schema_corrections: BTreeMap::new(), + options: DeploymentOptions::default(), sources: sources .into_iter() .map(|(digest, source)| (digest, source.to_string())) @@ -554,12 +607,41 @@ fn validate_bundle_with_root( || bundle.canonical_root != root || root.len() > 4096 || bundle.resources.len() > MAX_RESOURCES - || bundle.schema_corrections.len() > MAX_RESOURCES - || bundle.schema_corrections.iter().any(|(graph, contract)| { - !valid_resource_name(graph) - || !valid_contract(contract) - || !bundle.resources.contains_key(&graph_address(graph)) - }) + || bundle.options.repair_catalog.len() > MAX_RESOURCES + || bundle + .options + .repair_catalog + .iter() + .any(|address| !valid_address(address)) + || bundle.options.delete_graphs.len() + + bundle.options.adopt_graphs.len() + + bundle.options.recreate_graphs.len() + > MAX_RESOURCES + || bundle + .options + .delete_graphs + .iter() + .chain(bundle.options.adopt_graphs.iter()) + .any(|(graph, confirmation)| { + !valid_resource_name(graph) + || !valid_contract(&confirmation.contract) + || confirmation.graph_manifest_version == 0 + }) + || bundle + .options + .recreate_graphs + .iter() + .any(|(graph, contract)| !valid_resource_name(graph) || !valid_contract(contract)) + || bundle.options.schema_corrections.len() > MAX_RESOURCES + || bundle + .options + .schema_corrections + .iter() + .any(|(graph, contract)| { + !valid_resource_name(graph) + || !valid_contract(contract) + || !bundle.resources.contains_key(&graph_address(graph)) + }) || encoded_size(bundle)? > MAX_BUNDLE_BYTES || bundle .sources @@ -775,75 +857,87 @@ pub(crate) fn preview_deployment_scope( bundle: &DeploymentBundle, ) -> Result, Diagnostic> { let before = &state.applied_revision.resources; + let options = &bundle.options; + for address in &options.repair_catalog { + if !bundle.resources.contains_key(address) + || !matches!( + resource_kind(address), + ResourceKind::Query { .. } | ResourceKind::Policy(_) + ) + { + return Err(refusal( + "catalog_repair_target_invalid", + format!("{address} must name a desired policy or stored-query payload"), + )); + } + } + for graph in options.delete_graphs.keys() { + if !before.contains_key(&graph_address(graph)) + || bundle.resources.contains_key(&graph_address(graph)) + { + return Err(refusal( + "graph_delete_confirmation_unused", + format!( + "graph {graph} must be currently managed and absent from desired configuration" + ), + )); + } + } + for graph in options.adopt_graphs.keys() { + if before.contains_key(&graph_address(graph)) + || !bundle.resources.contains_key(&graph_address(graph)) + { + return Err(refusal( + "graph_adopt_confirmation_unused", + format!("graph {graph} must be unmanaged and declared in desired configuration"), + )); + } + } + for (graph, contract) in &options.recreate_graphs { + if !bundle.resources.contains_key(&graph_address(graph)) + || state + .applied_revision + .schema_contracts + .as_ref() + .and_then(|contracts| contracts.get(graph)) + != Some(contract) + { + return Err(refusal( + "graph_recreate_confirmation_mismatch", + format!("graph {graph} recreation must acknowledge its exact achieved contract"), + )); + } + } + for graph in options.schema_corrections.keys() { + if !before.contains_key(&graph_address(graph)) + || !bundle.resources.contains_key(&graph_address(graph)) + || options.recreate_graphs.contains_key(graph) + { + return Err(refusal( + "schema_correction_unneeded", + format!("graph {graph} has no existing contract to correct in this deployment"), + )); + } + } let addresses: BTreeSet<_> = before.keys().chain(bundle.resources.keys()).collect(); let mut effects = Vec::new(); for address in addresses { let old = before.get(address); let new = bundle.resources.get(address); let unchanged = serde_json::to_value(old).unwrap() == serde_json::to_value(new).unwrap(); - let correction = matches!(resource_kind(address), ResourceKind::Schema(graph) if bundle.schema_corrections.contains_key(&graph)); - if unchanged && !correction { + let explicit = options.repair_catalog.contains(address) + || match resource_kind(address) { + ResourceKind::Schema(graph) => options.schema_corrections.contains_key(&graph), + ResourceKind::Graph(graph) => options.recreate_graphs.contains_key(&graph), + _ => false, + }; + if unchanged && !explicit { continue; } - match resource_kind(address) { - ResourceKind::Schema(graph) - if new.is_some() && bundle.resources.contains_key(&graph_address(&graph)) => {} - ResourceKind::Query { graph, .. } - if bundle.resources.contains_key(&graph_address(&graph)) => {} - ResourceKind::Graph(_) if old.is_none() && new.is_some() => {} - ResourceKind::EmbeddingProvider(_) if old.is_none() && new.is_some() => {} - ResourceKind::Policy(_) - if new.is_some() - && (before.is_empty() - || old.is_none() - || old.zip(new).is_some_and(|(old, new)| { - old.digest == new.digest - && old.applies_to.as_ref().is_some_and(|old_bindings| { - new.applies_to.as_ref().is_some_and(|new_bindings| { - old_bindings - .iter() - .all(|binding| new_bindings.contains(binding)) - && new_bindings - .iter() - .filter(|binding| !old_bindings.contains(binding)) - .all(|binding| match resource_kind(binding) { - ResourceKind::Graph(graph) => { - !before.contains_key(&graph_address(&graph)) - } - _ => false, - }) - }) - }) - })) => {} - ResourceKind::Graph(_) - if old.zip(new).is_some_and(|(old, new)| { - old.embedding_provider == new.embedding_provider - && old.external_blob_policy == new.external_blob_policy - && old.applies_to == new.applies_to - && old.embedding_profile == new.embedding_profile - }) => {} - _ => { - return Err(refusal( - "deployment_scope", - format!( - "{address} changes unsupported graph removal or existing runtime bindings" - ), - )); - } - } - if matches!(resource_kind(address), ResourceKind::Policy(_)) - && old.is_none() - && !before.is_empty() - && new.unwrap().applies_to.as_ref().is_none_or(|bindings| { - bindings.iter().any(|binding| match resource_kind(binding) { - ResourceKind::Graph(graph) => before.contains_key(&graph_address(&graph)), - _ => true, - }) - }) - { + if matches!(resource_kind(address), ResourceKind::Unknown) { return Err(refusal( "deployment_scope", - "new policies may bind only newly created graphs", + format!("unsupported resource {address}"), )); } effects.push(AuthorizedEffect { @@ -865,6 +959,70 @@ pub(crate) fn preview_deployment_scope( Ok(effects) } +fn affected_graphs( + state: &ClusterState, + bundle: &DeploymentBundle, + effects: &[AuthorizedEffect], +) -> BTreeSet { + let mut graphs = BTreeSet::new(); + for effect in effects { + match resource_kind(&effect.resource) { + ResourceKind::Graph(graph) + | ResourceKind::Schema(graph) + | ResourceKind::Query { graph, .. } => { + graphs.insert(graph); + } + ResourceKind::Policy(_) => { + let old = state.applied_revision.resources.get(&effect.resource); + let new = bundle.resources.get(&effect.resource); + let old_bindings: BTreeSet<_> = old + .and_then(|entry| entry.applies_to.as_ref()) + .into_iter() + .flatten() + .collect(); + let new_bindings: BTreeSet<_> = new + .and_then(|entry| entry.applies_to.as_ref()) + .into_iter() + .flatten() + .collect(); + let source_unchanged = old + .zip(new) + .is_some_and(|(old, new)| old.digest == new.digest) + && !bundle.options.repair_catalog.contains(&effect.resource); + let bindings: BTreeSet<_> = if source_unchanged { + old_bindings + .symmetric_difference(&new_bindings) + .copied() + .collect() + } else { + old_bindings.union(&new_bindings).copied().collect() + }; + for binding in bindings { + if let Some(graph) = binding.strip_prefix("graph.") { + graphs.insert(graph.to_owned()); + } + } + } + ResourceKind::EmbeddingProvider(_) => { + for (address, resource) in state + .applied_revision + .resources + .iter() + .chain(bundle.resources.iter()) + { + if resource.embedding_provider.as_deref() == Some(&effect.resource) { + if let ResourceKind::Graph(graph) = resource_kind(address) { + graphs.insert(graph); + } + } + } + } + ResourceKind::Unknown => {} + } + } + graphs +} + fn source_for<'a>(bundle: &'a DeploymentBundle, address: &str) -> Result<&'a str, Diagnostic> { bundle .resources @@ -1088,7 +1246,21 @@ pub fn capture_deployment( config_dir: impl AsRef, schema_corrections: &BTreeMap, ) -> Result { - capture_deployment_input(config_dir.as_ref(), schema_corrections, None) + capture_deployment_with_options( + config_dir, + &DeploymentOptions { + schema_corrections: schema_corrections.clone(), + ..DeploymentOptions::default() + }, + ) +} + +/// Capture the complete explicit lifecycle input for direct deployment. +pub fn capture_deployment_with_options( + config_dir: impl AsRef, + options: &DeploymentOptions, +) -> Result { + capture_deployment_input(config_dir.as_ref(), options, None) } /// Capture local sources for the root advertised by an authenticated server. @@ -1100,6 +1272,22 @@ pub fn capture_deployment_for_server( config_dir: impl AsRef, schema_corrections: &BTreeMap, server_canonical_root: &str, +) -> Result { + capture_deployment_for_server_with_options( + config_dir, + &DeploymentOptions { + schema_corrections: schema_corrections.clone(), + ..DeploymentOptions::default() + }, + server_canonical_root, + ) +} + +/// Capture explicit lifecycle input without opening the server's storage here. +pub fn capture_deployment_for_server_with_options( + config_dir: impl AsRef, + options: &DeploymentOptions, + server_canonical_root: &str, ) -> Result { if remote_root_identity(server_canonical_root)? != server_canonical_root { return Err(refusal( @@ -1107,11 +1295,7 @@ pub fn capture_deployment_for_server( "the server must advertise a canonical storage root", )); } - capture_deployment_input( - config_dir.as_ref(), - schema_corrections, - Some(server_canonical_root), - ) + capture_deployment_input(config_dir.as_ref(), options, Some(server_canonical_root)) } fn remote_root_identity(root: &str) -> Result { @@ -1137,7 +1321,7 @@ fn remote_root_identity(root: &str) -> Result { fn capture_deployment_input( config_dir: &Path, - schema_corrections: &BTreeMap, + options: &DeploymentOptions, server_canonical_root: Option<&str>, ) -> Result { let captured = config::capture_desired(config_dir); @@ -1166,7 +1350,7 @@ fn capture_deployment_input( } }; let mut bundle = bundle_from_capture(&desired, captured.sources, root.clone()); - bundle.schema_corrections = schema_corrections.clone(); + bundle.options = options.clone(); if server_canonical_root.is_some() { validate_bundle_with_root(&bundle, &root, remote_root_identity)?; } else { @@ -1224,22 +1408,15 @@ pub async fn deployment_affected_graphs( let store = ClusterStore::for_storage_root(admission.canonical_root())?; let (state, _) = read_existing(&store).await?; require_v2(&state)?; - policies(&store, &state, caller, true).await?; + deployment_policies(&store, &state, bundle, caller).await?; if state.outstanding.is_some() { return Err(refusal( "cluster_deployment_outstanding", "original deployment must be reconciled before a new deployment", )); } - Ok(preview_deployment_scope(&state, bundle)? - .into_iter() - .filter_map(|effect| match resource_kind(&effect.resource) { - ResourceKind::Graph(graph) - | ResourceKind::Schema(graph) - | ResourceKind::Query { graph, .. } => Some(graph), - _ => None, - }) - .collect::>() + let effects = preview_deployment_scope(&state, bundle)?; + Ok(affected_graphs(&state, bundle, &effects) .into_iter() .collect()) } @@ -1252,9 +1429,9 @@ pub async fn preview_deployment_serving_snapshot( admission: &ClusterAdmission, caller: &DeploymentCaller, ) -> Result { - deployment_affected_graphs(bundle, admission, caller).await?; + let affected = deployment_affected_graphs(bundle, admission, caller).await?; let store = ClusterStore::for_storage_root(admission.canonical_root())?; - serve::preview_snapshot_with_store(&store, bundle) + serve::preview_snapshot_with_store(&store, bundle, &affected) .await .map_err(|mut diagnostics| diagnostics.remove(0)) } @@ -1269,7 +1446,28 @@ pub async fn apply_deployment( schema_corrections: &BTreeMap, report_id: impl FnOnce(&str, &str, &str), ) -> Result { - let bundle = capture_deployment(config_dir, schema_corrections)?; + apply_deployment_with_options( + config_dir, + requested_id, + caller, + &DeploymentOptions { + schema_corrections: schema_corrections.clone(), + ..DeploymentOptions::default() + }, + report_id, + ) + .await +} + +/// Direct deployment including explicit lifecycle and repair confirmations. +pub async fn apply_deployment_with_options( + config_dir: impl AsRef, + requested_id: Option<&str>, + caller: &DeploymentCaller, + options: &DeploymentOptions, + report_id: impl FnOnce(&str, &str, &str), +) -> Result { + let bundle = capture_deployment_with_options(config_dir, options)?; let store = ClusterStore::for_storage_root(bundle.canonical_root())?; bootstrap_ledger(&store, &bundle, caller).await?; if let Some(id) = requested_id { @@ -1407,7 +1605,7 @@ async fn execute_captured_deployment( &store, bundle, caller, - admission, + None, live_graphs, &id, &input_digest, @@ -1426,22 +1624,23 @@ async fn execute_captured_deployment( "bundle encoding identity changed", )); } - // Catalog query payloads are immutable and unreachable until the final - // achieved-projection CAS. Execution never reopens source files. - for (address, resource) in &bundle.resources { - let kind = resource_kind(address); - if matches!(kind, ResourceKind::Query { .. } | ResourceKind::Policy(_)) { - store - .write_payload(&kind, &resource.digest, source_for(bundle, address)?) - .await - .map_err(|error| refusal("resource_payload_write_error", error))?; - } - } seams::fail(&DEPLOYMENT_BEFORE_ACCEPTANCE)?; cas = replace(&store, &mut state, &cas).await?; seams::fail(&DEPLOYMENT_AFTER_ACCEPTANCE)?; + install_catalog_payloads(&store, &state, bundle).await?; for (graph, entry) in state.outstanding.as_ref().unwrap().graphs.clone() { - let result = if let Some(create) = &entry.create { + let result = if let Some(confirmation) = &entry.delete { + GraphDeploymentResult::Deleted { + graph_manifest_version: confirmation.graph_manifest_version, + contract: confirmation.contract.clone(), + retained_storage: true, + } + } else if let Some(confirmation) = &entry.adopt { + GraphDeploymentResult::Adopted { + graph_manifest_version: confirmation.graph_manifest_version, + contract: confirmation.contract.clone(), + } + } else if let Some(create) = &entry.create { state .outstanding .as_mut() @@ -1529,12 +1728,15 @@ async fn prepare_deployment( store: &ClusterStore, bundle: &DeploymentBundle, caller: &DeploymentCaller, - admission: &ClusterAdmission, + captured_state: Option<(ClusterState, String)>, live_graphs: &BTreeMap>, id: &str, input_digest: &str, ) -> Result<(ClusterState, String, AppliedPolicies), Diagnostic> { - let (mut state, cas) = read_existing(store).await?; + let (mut state, cas) = match captured_state { + Some(snapshot) => snapshot, + None => read_existing(store).await?, + }; require_v2(&state)?; let policy = deployment_policies(store, &state, bundle, caller).await?; if state.outstanding.is_some() { @@ -1558,55 +1760,296 @@ async fn prepare_deployment( "new deployment must use this ledger's exact next sequence", )); } - let mut graphs = BTreeMap::new(); - for graph in graph_ids(&state) { - if store.canonical_root()? != admission.canonical_root() { + let graphs = Box::pin(prepare_graphs( + store, + &state, + bundle, + caller, + &policy, + live_graphs, + &effects, + )) + .await?; + let authorization = DeploymentAuthorization { + version: 2, + ledger_id: state.ledger_id.clone().unwrap(), + authority: caller.authority()?, + base: AchievedDeploymentBase { + result_revision: state.applied_revision.result_revision.unwrap(), + resource_digests: state_resource_digests(&state), + schema_contracts: state.applied_revision.schema_contracts.clone().unwrap(), + capture_cas: cas.clone(), + }, + input_digest: input_digest.to_owned(), + policy_digests: policy.digests.clone(), + effects, + }; + state.next_sequence = Some(next); + state.outstanding = Some(OutstandingDeployment { + id: id.to_owned(), + input_digest: input_digest.to_owned(), + authorization, + graphs, + reserved_ledger_bytes: 0, + reserved_result_bytes: 0, + }); + reserve_completion(&mut state, bundle)?; + validate_state(&state)?; + Ok((state, cas, policy)) +} + +/// Full read-only deployment preflight. It acquires no writer admission and +/// never grants authority to execute; apply repeats it under the sole writer. +pub async fn preflight_deployment( + bundle: &CapturedDeployment, + caller: &DeploymentCaller, +) -> Result<(), Diagnostic> { + let store = ClusterStore::for_storage_root(bundle.canonical_root())?; + validate_bundle(bundle, &store.canonical_root()?)?; + let snapshot = store.read_state(&mut store.observations()).await?; + let state = snapshot.state.unwrap_or_else(empty_ledger); + require_v2(&state)?; + let id = format!( + "{}:{}:{}", + state.ledger_id.as_ref().unwrap(), + state.next_sequence.unwrap(), + Ulid::new() + ); + let cas = snapshot + .state_cas + .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b""))); + let input_digest = sha256_hex( + &serde_json::to_vec(bundle) + .map_err(|error| refusal("deployment_encode", error.to_string()))?, + ); + prepare_deployment( + &store, + bundle, + caller, + Some((state, cas)), + &BTreeMap::new(), + &id, + &input_digest, + ) + .await?; + + Ok(()) +} + +pub(super) async fn graph_confirmation( + db: &Omnigraph, +) -> Result { + let snapshot = db + .snapshot_of(ReadTarget::branch("main")) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string()))?; + Ok(GraphLifecycleConfirmation { + contract: db.schema_contract_digest(), + graph_manifest_version: snapshot.graph_manifest_version(), + }) +} + +fn check_confirmation( + graph: &str, + operation: &str, + expected: &GraphLifecycleConfirmation, + observed: &GraphLifecycleConfirmation, +) -> Result<(), Diagnostic> { + if expected != observed { + let input = serde_json::json!({ operation: { graph: observed } }); + return Err(refusal( + "graph_lifecycle_confirmation_mismatch", + format!( + "graph {graph} no longer matches its exact {operation} confirmation; inspect the graph before using --lifecycle FILE: {input}" + ), + )); + } + Ok(()) +} + +async fn prepare_graphs( + store: &ClusterStore, + state: &ClusterState, + bundle: &DeploymentBundle, + caller: &DeploymentCaller, + policy: &AppliedPolicies, + live_graphs: &BTreeMap>, + effects: &[AuthorizedEffect], +) -> Result, Diagnostic> { + let affected = affected_graphs(state, bundle, effects); + for (address, resource) in &bundle.resources { + let kind = resource_kind(address); + let relevant = match &kind { + ResourceKind::Query { graph, .. } => affected.contains(graph), + ResourceKind::Policy(_) => true, + _ => false, + }; + if !relevant { + continue; + } + // Explicit repair already supplies the single digest-verified value. + // Never read a damaged slot merely to prove that restoring it is safe. + if bundle.options.repair_catalog.contains(address) { + continue; + } + let prior = store + .read_payload(&kind, &resource.digest) + .await + .map_err(|error| refusal("resource_payload_read_error", error))?; + let previously_applied = state + .applied_revision + .resources + .get(address) + .is_some_and(|old| old.digest == resource.digest); + let invalid = prior + .as_ref() + .is_some_and(|source| sha256_hex(source.as_bytes()) != resource.digest) + || (prior.is_none() && previously_applied); + if invalid && !bundle.options.repair_catalog.contains(address) { return Err(refusal( - "cluster_admission_root_mismatch", - "graph belongs to another root", + "catalog_repair_required", + format!( + "{address} is missing or corrupt; verify the desired source and explicitly request repair_catalog in --lifecycle FILE" + ), )); } + } + let mut graphs = BTreeMap::new(); + for graph in affected { + let existing = state + .applied_revision + .resources + .contains_key(&graph_address(&graph)); + let desired = bundle.resources.contains_key(&graph_address(&graph)); let schema_address = schema_address(&graph); - let schema_changed = state.applied_revision.resources[&schema_address].digest - != bundle.resources[&schema_address].digest; - let affected = effects - .iter() - .any(|effect| match resource_kind(&effect.resource) { - ResourceKind::Graph(id) | ResourceKind::Schema(id) => id == graph, - ResourceKind::Query { graph: id, .. } => id == graph, - _ => false, - }); + let mut entry = GraphDeployment { + create: None, + adopt: None, + delete: None, + intent: None, + observed_manifest_version: 0, + state: GraphDeploymentState::NotStarted, + settlement: None, + recovery_executor: None, + }; + if !existing || bundle.options.recreate_graphs.contains_key(&graph) { + let uri = store.graph_root(&graph); + let canonical = admission::canonical_graph_uri(&uri)?; + let expected_uri = format!( + "{}/graphs/{graph}.omni", + bundle.canonical_root.trim_start_matches("file://") + ); + if canonical != expected_uri { + return Err(refusal( + "cluster_graph_root_mismatch", + "graph root is outside the admitted cluster's canonical graph layout", + )); + } + if let Some(expected) = bundle.options.adopt_graphs.get(&graph) { + let db = open_graph(store, &graph, policy, true).await?; + let observed = graph_confirmation(&db).await?; + check_confirmation(&graph, "adopt_graphs", expected, &observed)?; + if observed.contract.source_hash != bundle.resources[&schema_address].digest { + return Err(refusal( + "graph_adopt_schema_mismatch", + format!( + "graph {graph} adoption requires its exact current schema source; apply schema changes after adoption" + ), + )); + } + admission::admitted_graph_id( + &bundle.canonical_root, + &BTreeMap::from([(graph.clone(), observed.contract.clone())]), + db.uri(), + )?; + entry.observed_manifest_version = observed.graph_manifest_version; + entry.adopt = Some(observed); + } else { + let exists = store + .graph_root_exists(&uri) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string()))?; + if exists { + if existing { + return Err(refusal( + "graph_recreate_root_present", + format!( + "graph {graph} recreation requires a fully absent root; existing or partial storage is never overwritten" + ), + )); + } + let detail = match open_graph(store, &graph, policy, true).await { + Ok(db) => serde_json::to_string(&serde_json::json!({ "adopt_graphs": { graph.clone(): graph_confirmation(&db).await? } })).unwrap(), + Err(_) => "existing root cannot be opened; repair or move it explicitly".to_owned(), + }; + return Err(refusal( + "graph_adoption_required", + format!( + "graph {graph} already has storage and requires exact adoption with --lifecycle FILE: {detail}" + ), + )); + } + let create = + Omnigraph::prepare_graph_create(&uri, source_for(bundle, &schema_address)?) + .await + .map_err(|error| { + refusal("graph_create_preflight_failed", error.to_string()) + })?; + admission::admitted_graph_id( + &bundle.canonical_root, + &BTreeMap::from([(graph.clone(), create.desired_contract().clone())]), + create.root(), + )?; + entry.create = Some(create); + } + graphs.insert(graph, entry); + continue; + } + let schema_changed = desired + && state.applied_revision.resources[&schema_address].digest + != bundle.resources[&schema_address].digest; + let correction = bundle.options.schema_corrections.get(&graph); let db = match live_graphs.get(&graph) { Some(db) => db.clone(), - None => std::sync::Arc::new(open_graph(store, &graph, &policy, true).await?), + None => std::sync::Arc::new(open_graph(store, &graph, policy, true).await.map_err(|error| { + if error.code == "graph_unavailable" { + refusal(&error.code, format!("{}; if graph {graph} is entirely absent, explicitly acknowledge its achieved contract using recreate_graphs in --lifecycle FILE: {}", error.message, serde_json::to_string(&state.applied_revision.schema_contracts.as_ref().unwrap()[&graph]).unwrap())) + } else { error } + })?), }; - if admission::admitted_graph_id( - admission.canonical_root(), + admission::admitted_graph_id( + &bundle.canonical_root, state.applied_revision.schema_contracts.as_ref().unwrap(), db.uri(), - )? != graph - { - return Err(refusal( - "cluster_graph_root_mismatch", - "live engine does not belong to the affected graph", - )); - } - let snapshot = db - .snapshot_of(ReadTarget::branch("main")) - .await - .map_err(|error| refusal("graph_unavailable", error.to_string()))?; - let observed = db.schema_contract_digest(); + )?; + let observed = graph_confirmation(&db).await?; let achieved = &state.applied_revision.schema_contracts.as_ref().unwrap()[&graph]; - let correction = bundle.schema_corrections.get(&graph); - if &observed != achieved { - if correction != Some(&observed) { - let observed_input = - serde_json::to_string(&BTreeMap::from([(graph.clone(), observed)])) - .map_err(|error| refusal("deployment_encode", error.to_string()))?; + if !desired { + let expected = bundle.options.delete_graphs.get(&graph).ok_or_else(|| refusal( + "graph_delete_confirmation_required", + format!("removing graph {graph} retains its storage and requires this exact inspected confirmation in --lifecycle FILE: {}", serde_json::json!({ "delete_graphs": { graph.clone(): observed.clone() } })), + ))?; + check_confirmation(&graph, "delete_graphs", expected, &observed)?; + if &observed.contract != achieved { + return Err(refusal( + "applied_schema_drift", + format!("graph {graph} removal cannot adopt a different incarnation"), + )); + } + entry.observed_manifest_version = observed.graph_manifest_version; + entry.delete = Some(observed); + graphs.insert(graph, entry); + continue; + } + if &observed.contract != achieved { + if correction != Some(&observed.contract) { + let input = + serde_json::to_string(&BTreeMap::from([(graph.clone(), observed.contract)])) + .unwrap(); return Err(refusal( "applied_schema_drift", format!( - "graph {graph} differs from its achieved contract; inspect the graph and explicitly acknowledge this exact observed contract with --schema-correction FILE: {observed_input}" + "graph {graph} differs from its achieved contract; inspect it and acknowledge the exact observed contract with --schema-correction FILE: {input}" ), )); } @@ -1618,11 +2061,9 @@ async fn prepare_deployment( ), )); } - if !affected { - continue; - } - let intent = if schema_changed || correction.is_some() { + if schema_changed || correction.is_some() { if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { + policy.check_graph(identity.actor(), &graph, PolicyAction::Read)?; policy.check_graph(identity.actor(), &graph, PolicyAction::SchemaApply)?; } let intent = db @@ -1635,87 +2076,43 @@ async fn prepare_deployment( format!("graph {graph} changed during schema preparation"), )); } - Some(intent) + entry.observed_manifest_version = intent.base_manifest_version(); + entry.intent = Some(intent); } else { - None - }; - graphs.insert( - graph.clone(), - GraphDeployment { - create: None, - observed_manifest_version: intent.as_ref().map_or( - snapshot.graph_manifest_version(), - PreparedSchemaApply::base_manifest_version, - ), - intent, - state: GraphDeploymentState::NotStarted, - settlement: None, - recovery_executor: None, - }, - ); - } - for graph in bundle.graph_ids() { - if state - .applied_revision - .resources - .contains_key(&graph_address(&graph)) - { - continue; + entry.observed_manifest_version = observed.graph_manifest_version; } - if bundle.schema_corrections.contains_key(&graph) { - return Err(refusal( - "schema_correction_unneeded", - "new graph has no achieved contract to correct", - )); + graphs.insert(graph, entry); + } + Ok(graphs) +} + +async fn install_catalog_payloads( + store: &ClusterStore, + state: &ClusterState, + bundle: &DeploymentBundle, +) -> Result<(), Diagnostic> { + for (address, resource) in &bundle.resources { + let kind = resource_kind(address); + if matches!(kind, ResourceKind::Query { .. } | ResourceKind::Policy(_)) { + if !bundle.options.repair_catalog.contains(address) + && state + .applied_revision + .resources + .get(address) + .is_some_and(|old| old.digest == resource.digest) + { + continue; + } + let source = source_for(bundle, address)?; + if bundle.options.repair_catalog.contains(address) { + store.repair_payload(&kind, &resource.digest, source).await + } else { + store.write_payload(&kind, &resource.digest, source).await + } + .map_err(|error| refusal("resource_payload_write_error", error))?; } - let create = Omnigraph::prepare_graph_create( - &store.graph_root(&graph), - source_for(bundle, &schema_address(&graph))?, - ) - .await - .map_err(|error| refusal("graph_create_preflight_failed", error.to_string()))?; - admission::admitted_graph_id( - admission.canonical_root(), - &BTreeMap::from([(graph.clone(), create.desired_contract().clone())]), - create.root(), - )?; - graphs.insert( - graph, - GraphDeployment { - create: Some(create), - intent: None, - observed_manifest_version: 0, - state: GraphDeploymentState::NotStarted, - settlement: None, - recovery_executor: None, - }, - ); } - let authorization = DeploymentAuthorization { - version: 2, - ledger_id: state.ledger_id.clone().unwrap(), - authority: caller.authority()?, - base: AchievedDeploymentBase { - result_revision: state.applied_revision.result_revision.unwrap(), - resource_digests: state_resource_digests(&state), - schema_contracts: state.applied_revision.schema_contracts.clone().unwrap(), - capture_cas: cas.clone(), - }, - input_digest: input_digest.to_owned(), - policy_digests: policy.digests.clone(), - effects, - }; - state.next_sequence = Some(next); - state.outstanding = Some(OutstandingDeployment { - id: id.to_owned(), - input_digest: input_digest.to_owned(), - authorization, - graphs, - reserved_ledger_bytes: 0, - reserved_result_bytes: 0, - }); - reserve_completion(&mut state, bundle)?; - Ok((state, cas, policy)) + Ok(()) } fn retained_error(error: Diagnostic, lock_id: &str) -> Diagnostic { @@ -1734,15 +2131,7 @@ fn validate_pending_input( bundle: &DeploymentBundle, ) -> Result<(), Diagnostic> { let effects = preview_deployment_scope(state, bundle)?; - let affected: BTreeSet<_> = effects - .iter() - .filter_map(|effect| match resource_kind(&effect.resource) { - ResourceKind::Graph(graph) - | ResourceKind::Schema(graph) - | ResourceKind::Query { graph, .. } => Some(graph), - _ => None, - }) - .collect(); + let affected = affected_graphs(state, bundle, &effects); if effects != pending.authorization.effects || affected != pending.graphs.keys().cloned().collect() { @@ -1771,6 +2160,7 @@ fn validate_pending_input( .base .resource_digests .contains_key(&graph_address(graph)) + && !bundle.options.recreate_graphs.contains_key(graph) { return Err(refusal( "deployment_authority_changed", @@ -1779,14 +2169,43 @@ fn validate_pending_input( } continue; } + if let Some(confirmation) = &entry.adopt { + if bundle.options.adopt_graphs.get(graph) != Some(confirmation) + || confirmation.contract.source_hash != bundle.resources[&address].digest + || state + .applied_revision + .resources + .contains_key(&graph_address(graph)) + { + return Err(refusal( + "deployment_authority_changed", + "adoption differs from its exact immutable confirmation", + )); + } + continue; + } + if let Some(confirmation) = &entry.delete { + if bundle.options.delete_graphs.get(graph) != Some(confirmation) + || pending.authorization.base.schema_contracts.get(graph) + != Some(&confirmation.contract) + || bundle.resources.contains_key(&graph_address(graph)) + { + return Err(refusal( + "deployment_authority_changed", + "removal differs from its exact immutable confirmation", + )); + } + continue; + } let schema_changed = state.applied_revision.resources[&address].digest != bundle.resources[&address].digest; - if (schema_changed || bundle.schema_corrections.contains_key(graph)) + if (schema_changed || bundle.options.schema_corrections.contains_key(graph)) != entry.intent.is_some() || entry.intent.as_ref().is_some_and(|intent| { intent.actor() != pending.authorization.authority.actor.as_deref() || intent.base_contract() != bundle + .options .schema_corrections .get(graph) .unwrap_or(&pending.authorization.base.schema_contracts[graph]) @@ -1820,6 +2239,7 @@ async fn finish( if pending.graphs.values().any(|entry| !matches!(&entry.state, GraphDeploymentState::Settled { result } if result.terminal())) { return Err(refusal("deployment_outcome_unknown", "deployment has unresolved graph effects")); } + let before_resources = serde_json::to_value(&state.applied_revision.resources).unwrap(); let mut result = result_from_pending(state, &pending); for (graph, outcome) in &result.graphs { if !outcome.accepted() { @@ -1830,7 +2250,8 @@ async fn finish( SchemaApplySettlement::Committed { contract, .. } | SchemaApplySettlement::NoOp { contract, .. }, } - | GraphDeploymentResult::Created { contract, .. } = outcome + | GraphDeploymentResult::Created { contract, .. } + | GraphDeploymentResult::Adopted { contract, .. } = outcome { state .applied_revision @@ -1839,6 +2260,14 @@ async fn finish( .unwrap() .insert(graph.clone(), contract.clone()); } + if matches!(outcome, GraphDeploymentResult::Deleted { .. }) { + state + .applied_revision + .schema_contracts + .as_mut() + .unwrap() + .remove(graph); + } let belongs = |address: &str| match resource_kind(address) { ResourceKind::Graph(id) | ResourceKind::Schema(id) => id == *graph, ResourceKind::Query { graph: id, .. } => id == *graph, @@ -1861,10 +2290,34 @@ async fn finish( } } } - // Keep every achieved graph's policy/provider bindings complete, even if - // another graph was not attempted. Restrict a policy's target list to the - // achieved inventory; a corrective successor may add its new graph targets. + // Runtime configuration has no engine publication. Once every graph effect + // is terminal, publish its desired projection in this same ledger CAS. + // A failed schema retains its old schema/query catalog; runtime bindings + // still converge for that surviving graph. Never bind a not-created graph. let achieved_graphs = graph_ids(state); + for graph in &achieved_graphs { + if let Some(desired) = bundle.resources.get(&graph_address(graph)) { + let resource = state + .applied_revision + .resources + .get_mut(&graph_address(graph)) + .unwrap(); + resource.embedding_provider = desired.embedding_provider.clone(); + resource.external_blob_policy = desired.external_blob_policy.clone(); + } + } + state.applied_revision.resources.retain(|address, _| { + !matches!( + resource_kind(address), + ResourceKind::Policy(_) | ResourceKind::EmbeddingProvider(_) + ) + }); + state.resource_statuses.retain(|address, _| { + !matches!( + resource_kind(address), + ResourceKind::Policy(_) | ResourceKind::EmbeddingProvider(_) + ) + }); for (address, resource) in &bundle.resources { match resource_kind(address) { ResourceKind::EmbeddingProvider(_) => { @@ -1896,10 +2349,24 @@ async fn finish( _ => {} } } - let digests = state_resource_digests(state); - result.converged = serde_json::to_value(&state.applied_revision.resources).unwrap() - == serde_json::to_value(&bundle.resources).unwrap(); - if digests != pending.authorization.base.resource_digests + for graph in &achieved_graphs { + let address = graph_address(graph); + let digest = expected_state_graph_resource_digest( + state, + graph, + &state.applied_revision.resources[&address], + ); + state + .applied_revision + .resources + .get_mut(&address) + .unwrap() + .digest = digest; + } + let achieved_resources = serde_json::to_value(&state.applied_revision.resources).unwrap(); + result.converged = result.graphs.values().all(GraphDeploymentResult::accepted) + && achieved_resources == serde_json::to_value(&bundle.resources).unwrap(); + if achieved_resources != before_resources || state.applied_revision.schema_contracts.as_ref() != Some(&pending.authorization.base.schema_contracts) || (result.converged @@ -2086,9 +2553,63 @@ pub async fn reconcile_deployment( let bundle = store.read_deployment_bundle(&pending.input_digest).await?; validate_bundle(&bundle, admission.canonical_root())?; validate_pending_input(&state, &pending, &bundle)?; + install_catalog_payloads(&store, &state, &bundle).await?; for (graph, entry) in pending.graphs { let result = match entry.state { GraphDeploymentState::Settled { .. } => continue, + GraphDeploymentState::NotStarted if entry.delete.is_some() || entry.adopt.is_some() => { + let confirmation = entry.delete.as_ref().or(entry.adopt.as_ref()).unwrap(); + admission::admitted_graph_id( + admission.canonical_root(), + &BTreeMap::from([(graph.clone(), confirmation.contract.clone())]), + &store.graph_root(&graph), + )?; + let db = open_graph(&store, &graph, &policy, true).await?; + let observed = graph_confirmation(&db).await?; + check_confirmation( + &graph, + if entry.delete.is_some() { + "delete_graphs" + } else { + "adopt_graphs" + }, + confirmation, + &observed, + )?; + if entry.delete.is_some() { + GraphDeploymentResult::Deleted { + graph_manifest_version: observed.graph_manifest_version, + contract: observed.contract, + retained_storage: true, + } + } else { + GraphDeploymentResult::Adopted { + graph_manifest_version: observed.graph_manifest_version, + contract: observed.contract, + } + } + } + GraphDeploymentState::NotStarted + if entry.create.is_none() && entry.intent.is_none() => + { + admission + .validate_graph_uri(&store.graph_root(&graph)) + .await?; + let db = open_graph(&store, &graph, &policy, true).await?; + let observed = graph_confirmation(&db).await?; + if pending.authorization.base.schema_contracts.get(&graph) + != Some(&observed.contract) + { + return Err(refusal( + "deployment_outcome_unknown", + "graph identity changed before original catalog settlement", + )); + } + GraphDeploymentResult::QueryOnly { + graph_manifest_version: observed.graph_manifest_version, + schema_digest: bundle.resources[&schema_address(&graph)].digest.clone(), + } + } GraphDeploymentState::NotStarted => GraphDeploymentResult::NotAttempted, GraphDeploymentState::Started if entry.create.is_some() => { state @@ -2132,6 +2653,7 @@ pub async fn reconcile_deployment( ) })?; if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { + policy.check_graph(identity.actor(), &graph, PolicyAction::Read)?; policy.check_graph(identity.actor(), &graph, PolicyAction::SchemaApply)?; } admission diff --git a/crates/omnigraph-cluster/src/deployment/tests.rs b/crates/omnigraph-cluster/src/deployment/tests.rs index 7ffc04f1a..985b0dd43 100644 --- a/crates/omnigraph-cluster/src/deployment/tests.rs +++ b/crates/omnigraph-cluster/src/deployment/tests.rs @@ -103,7 +103,14 @@ async fn bootstrap_and_add_graph_use_one_v2_protocol_without_reset() { .await .unwrap() .unwrap(); - let captured = capture_deployment(dir.path(), &BTreeMap::new()).unwrap(); + let captured = capture_deployment_with_options( + dir.path(), + &DeploymentOptions { + repair_catalog: BTreeSet::from(["policy.base".into()]), + ..DeploymentOptions::default() + }, + ) + .unwrap(); let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); let preview = preview_deployment_serving_snapshot(&captured, &admission, &owner()) .await @@ -294,7 +301,7 @@ async fn graph_creation_refuses_foreign_root_without_holding_new_admission() { let error = apply_deployment(dir.path(), None, &owner(), &BTreeMap::new(), |_, _, _| {}) .await .unwrap_err(); - assert_eq!(error.code, "graph_create_preflight_failed"); + assert_eq!(error.code, "graph_adoption_required"); assert!( deployment_status(root, None, &owner()) .await @@ -302,6 +309,48 @@ async fn graph_creation_refuses_foreign_root_without_holding_new_admission() { .lock_id .is_none() ); + + // Loss of the ledger is still an explicit adoption, never an implicit + // replacement. A stale confirmation must not create an empty new ledger. + let knowledge = confirmation(dir.path(), "knowledge").await; + let second = confirmation(dir.path(), "second").await; + fs::remove_file(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let mut stale = second.clone(); + stale.graph_manifest_version += 1; + let mut options = DeploymentOptions { + adopt_graphs: BTreeMap::from([ + ("knowledge".into(), knowledge.clone()), + ("second".into(), stale), + ]), + ..DeploymentOptions::default() + }; + let captured = capture_deployment_with_options(dir.path(), &options).unwrap(); + assert_eq!( + preflight_deployment(&captured, &owner()) + .await + .unwrap_err() + .code, + "graph_lifecycle_confirmation_mismatch" + ); + assert_eq!( + apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) + .await + .unwrap_err() + .code, + "graph_lifecycle_confirmation_mismatch" + ); + assert!(!dir.path().join(CLUSTER_STATE_FILE).exists()); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + options.adopt_graphs.insert("second".into(), second.clone()); + let adopted = apply_options(dir.path(), &options).await; + assert!( + adopted + .graphs + .values() + .all(|result| matches!(result, GraphDeploymentResult::Adopted { .. })) + ); + assert_eq!(confirmation(dir.path(), "knowledge").await, knowledge); + assert_eq!(confirmation(dir.path(), "second").await, second); } fn identity(actor: &str) -> DeploymentCaller { @@ -606,3 +655,495 @@ fn remote_capture_does_not_open_server_storage() { ); } } + +fn edit_config(dir: &Path, edit: impl FnOnce(&mut serde_yaml::Value)) { + let path = dir.join(CLUSTER_CONFIG_FILE); + let mut value = serde_yaml::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + edit(&mut value); + fs::write(path, serde_yaml::to_string(&value).unwrap()).unwrap(); +} + +async fn confirmation(dir: &Path, graph: &str) -> GraphLifecycleConfirmation { + let db = Omnigraph::open_read_only(dir.join(format!("graphs/{graph}.omni")).to_str().unwrap()) + .await + .unwrap(); + execution::graph_confirmation(&db).await.unwrap() +} + +async fn apply_options(dir: &Path, options: &DeploymentOptions) -> DeploymentResult { + let applied = apply_deployment_with_options(dir, None, &owner(), options, |_, _, _| {}) + .await + .unwrap(); + let DeploymentLookup::Complete { result } = applied else { + panic!("{applied:?}") + }; + assert!(result.converged, "{result:?}"); + result +} + +#[tokio::test] +async fn current_policy_authorizes_policy_edits_rebinding_and_removal() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + bootstrap(dir.path()).await; + unlock(root).await; + let original = confirmation(dir.path(), "knowledge").await; + let graph_policy = dir.path().join("base.policy.yaml"); + let source = fs::read_to_string(&graph_policy) + .unwrap() + .replace("principal:reader]", "principal:reader, principal:alice]"); + fs::write(&graph_policy, &source).unwrap(); + let applied = apply_deployment( + dir.path(), + None, + &identity("principal:owner"), + &BTreeMap::new(), + |_, _, _| {}, + ) + .await + .unwrap(); + assert!(matches!(applied, DeploymentLookup::Complete { result } if result.converged)); + let store = ClusterStore::for_config_dir(dir.path()); + let (state, _) = execution::read_existing(&store).await.unwrap(); + let policies = crate::authorization::AppliedPolicies::load(&store, &state) + .await + .unwrap(); + policies + .check_graph( + "principal:alice", + "knowledge", + omnigraph_policy::PolicyAction::Read, + ) + .unwrap(); + assert_eq!(confirmation(dir.path(), "knowledge").await, original); + unlock(root).await; + fs::write(dir.path().join("replacement.policy.yaml"), source).unwrap(); + edit_config(dir.path(), |config| { + config["policies"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("base")); + config["policies"]["replacement"] = + serde_yaml::from_str("file: ./replacement.policy.yaml\napplies_to: [knowledge]\n") + .unwrap(); + }); + apply_options(dir.path(), &DeploymentOptions::default()).await; + unlock(root).await; + edit_config(dir.path(), |config| { + config["policies"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("replacement")); + }); + apply_options(dir.path(), &DeploymentOptions::default()).await; + let (state, _) = execution::read_existing(&store).await.unwrap(); + assert!(!state.applied_revision.resources.contains_key("policy.base")); + assert!( + !state + .applied_revision + .resources + .contains_key("policy.replacement") + ); + assert!( + crate::authorization::AppliedPolicies::load(&store, &state) + .await + .unwrap() + .graph("knowledge") + .is_none() + ); + assert_eq!(confirmation(dir.path(), "knowledge").await, original); + unlock(root).await; + // Current management policy authorizes revoking the author itself. A + // subsequent candidate restoring access cannot authorize its own request. + let management = dir.path().join("management.policy.yaml"); + let source = fs::read_to_string(&management).unwrap(); + fs::write(&management, source.replace("principal:owner, ", "")).unwrap(); + apply_deployment( + dir.path(), + None, + &identity("principal:owner"), + &BTreeMap::new(), + |_, _, _| {}, + ) + .await + .unwrap(); + unlock(root).await; + fs::write(&management, source).unwrap(); + assert_eq!( + apply_deployment( + dir.path(), + None, + &identity("principal:owner"), + &BTreeMap::new(), + |_, _, _| {} + ) + .await + .unwrap_err() + .code, + "policy_denied" + ); +} + +#[tokio::test] +async fn provider_crud_binding_and_blob_updates_preserve_graph_identity() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + bootstrap(dir.path()).await; + unlock(root).await; + let original = confirmation(dir.path(), "knowledge").await; + for model in ["mock:first", "mock:second"] { + edit_config(dir.path(), |config| { + config["providers"] = serde_yaml::from_str(&format!( + "embedding:\n selected:\n kind: mock\n model: {model}\n" + )) + .unwrap(); + config["graphs"]["knowledge"]["embedding_provider"] = "selected".into(); + config["graphs"]["knowledge"]["external_blobs"] = serde_yaml::from_str( + "allow:\n - base: s3://trusted-bucket/data/\n scope: server_safe\n", + ) + .unwrap(); + }); + apply_options(dir.path(), &DeploymentOptions::default()).await; + unlock(root).await; + } + edit_config(dir.path(), |config| { + config + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("providers")); + let graph = config["graphs"]["knowledge"].as_mapping_mut().unwrap(); + graph.remove(serde_yaml::Value::from("embedding_provider")); + graph.remove(serde_yaml::Value::from("external_blobs")); + }); + apply_options(dir.path(), &DeploymentOptions::default()).await; + let store = ClusterStore::for_config_dir(dir.path()); + let (state, _) = execution::read_existing(&store).await.unwrap(); + assert!( + !state + .applied_revision + .resources + .keys() + .any(|address| matches!(resource_kind(address), ResourceKind::EmbeddingProvider(_))) + ); + assert!( + state.applied_revision.resources["graph.knowledge"] + .embedding_provider + .is_none() + ); + assert!( + state.applied_revision.resources["graph.knowledge"] + .external_blob_policy + .is_none() + ); + assert_eq!(confirmation(dir.path(), "knowledge").await, original); +} + +#[tokio::test] +async fn graph_removal_requires_exact_confirmation_retains_storage_and_allows_readoption() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + add_second_graph(dir.path()); + bootstrap(dir.path()).await; + unlock(root).await; + let original = confirmation(dir.path(), "second").await; + let original_config = fs::read_to_string(dir.path().join(CLUSTER_CONFIG_FILE)).unwrap(); + edit_config(dir.path(), |config| { + config["graphs"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("second")); + config["policies"]["base"]["applies_to"] = serde_yaml::from_str("[knowledge]").unwrap(); + }); + let err = apply_deployment(dir.path(), None, &owner(), &BTreeMap::new(), |_, _, _| {}) + .await + .unwrap_err(); + assert_eq!(err.code, "graph_delete_confirmation_required"); + assert!(err.message.contains("graph_manifest_version")); + let correction = DeploymentOptions { + schema_corrections: BTreeMap::from([("second".into(), original.contract.clone())]), + ..DeploymentOptions::default() + }; + assert_eq!( + apply_deployment_with_options(dir.path(), None, &owner(), &correction, |_, _, _| {}) + .await + .unwrap_err() + .code, + "deployment_input_invalid" + ); + let mut options = DeploymentOptions::default(); + let mut stale = original.clone(); + stale.graph_manifest_version += 1; + options.delete_graphs.insert("second".into(), stale); + assert_eq!( + apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) + .await + .unwrap_err() + .code, + "graph_lifecycle_confirmation_mismatch" + ); + options + .delete_graphs + .insert("second".into(), original.clone()); + let removed = apply_options(dir.path(), &options).await; + assert!(matches!( + removed.graphs["second"], + GraphDeploymentResult::Deleted { + retained_storage: true, + .. + } + )); + assert_eq!(confirmation(dir.path(), "second").await, original); + assert_eq!( + apply_deployment_with_options( + dir.path(), + Some(&removed.id), + &owner(), + &DeploymentOptions::default(), + |_, _, _| {} + ) + .await + .unwrap_err() + .code, + "deployment_input_mismatch" + ); + assert_eq!( + read_serving_snapshot_from_storage(root) + .await + .unwrap() + .graphs + .len(), + 1 + ); + unlock(root).await; + fs::write(dir.path().join(CLUSTER_CONFIG_FILE), original_config).unwrap(); + assert_eq!( + apply_deployment(dir.path(), None, &owner(), &BTreeMap::new(), |_, _, _| {}) + .await + .unwrap_err() + .code, + "graph_adoption_required" + ); + let options = DeploymentOptions { + adopt_graphs: BTreeMap::from([("second".into(), original.clone())]), + ..DeploymentOptions::default() + }; + let adopted = apply_options(dir.path(), &options).await; + assert!(matches!( + adopted.graphs["second"], + GraphDeploymentResult::Adopted { .. } + )); + assert_eq!(confirmation(dir.path(), "second").await, original); + assert_eq!( + read_serving_snapshot_from_storage(root) + .await + .unwrap() + .graphs + .len(), + 2 + ); +} + +#[tokio::test] +async fn missing_graph_recreation_requires_achieved_identity_and_never_overwrites_storage() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + bootstrap(dir.path()).await; + unlock(root).await; + let original = confirmation(dir.path(), "knowledge").await; + let options = DeploymentOptions { + recreate_graphs: BTreeMap::from([("knowledge".into(), original.contract.clone())]), + ..DeploymentOptions::default() + }; + assert_eq!( + apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) + .await + .unwrap_err() + .code, + "graph_recreate_root_present" + ); + fs::remove_dir_all(dir.path().join("graphs/knowledge.omni")).unwrap(); + let recreated = apply_options(dir.path(), &options).await; + assert!(matches!( + recreated.graphs["knowledge"], + GraphDeploymentResult::Created { .. } + )); + assert_ne!( + confirmation(dir.path(), "knowledge") + .await + .contract + .schema_identity_domain, + original.contract.schema_identity_domain + ); +} + +#[tokio::test] +async fn unrelated_unavailable_graph_does_not_block_affected_graph_preflight_or_apply() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + add_second_graph(dir.path()); + bootstrap(dir.path()).await; + unlock(root).await; + fs::remove_dir_all(dir.path().join("graphs/second.omni")).unwrap(); + let query_path = dir.path().join("people.gq"); + let source = fs::read_to_string(&query_path).unwrap(); + fs::write( + query_path, + source.replace("return { $p.name, $p.age }", "return { $p.name }"), + ) + .unwrap(); + let captured = capture_deployment(dir.path(), &BTreeMap::new()).unwrap(); + preflight_deployment(&captured, &owner()).await.unwrap(); + let result = apply_options(dir.path(), &DeploymentOptions::default()).await; + assert_eq!(result.graphs.keys().collect::>(), vec!["knowledge"]); + assert!(!dir.path().join("graphs/second.omni").exists()); +} + +#[tokio::test] +async fn targeted_catalog_repair_restores_exact_bytes_without_candidate_self_authorization() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + bootstrap(dir.path()).await; + unlock(root).await; + let original = confirmation(dir.path(), "knowledge").await; + let store = ClusterStore::for_config_dir(dir.path()); + let (state, _) = execution::read_existing(&store).await.unwrap(); + let digest = &state.applied_revision.resources["policy.management"].digest; + let relative = + ClusterStore::payload_relative(&ResourceKind::Policy("management".into()), digest).unwrap(); + fs::write(dir.path().join(relative), [0xff, 0xfe]).unwrap(); + let wrong_target = DeploymentOptions { + repair_catalog: BTreeSet::from(["query.knowledge.find_person".into()]), + ..DeploymentOptions::default() + }; + assert!( + apply_deployment_with_options(dir.path(), None, &owner(), &wrong_target, |_, _, _| {}) + .await + .is_err() + ); + let options = DeploymentOptions { + repair_catalog: BTreeSet::from(["policy.management".into()]), + ..DeploymentOptions::default() + }; + let management_path = dir.path().join("management.policy.yaml"); + let trusted_source = fs::read_to_string(&management_path).unwrap(); + fs::write( + &management_path, + format!("{trusted_source}\n# changed policy input\n"), + ) + .unwrap(); + assert!( + apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) + .await + .is_err() + ); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + fs::write(&management_path, trusted_source).unwrap(); + assert!( + apply_deployment_with_options( + dir.path(), + None, + &identity("principal:owner"), + &options, + |_, _, _| {} + ) + .await + .is_err() + ); + apply_options(dir.path(), &options).await; + assert_eq!(confirmation(dir.path(), "knowledge").await, original); + let (state, _) = execution::read_existing(&store).await.unwrap(); + crate::authorization::AppliedPolicies::load(&store, &state) + .await + .unwrap() + .check_cluster("principal:owner") + .unwrap(); + unlock(root).await; + let query = "query.knowledge.find_person"; + let digest = &state.applied_revision.resources[query].digest; + let relative = ClusterStore::payload_relative(&resource_kind(query), digest).unwrap(); + fs::write( + dir.path().join(relative), + vec![b'x'; crate::config::MAX_CONFIG_SOURCE_BYTES + 1], + ) + .unwrap(); + let options = DeploymentOptions { + repair_catalog: BTreeSet::from([query.into()]), + ..DeploymentOptions::default() + }; + let captured = capture_deployment_with_options(dir.path(), &options).unwrap(); + preflight_deployment(&captured, &owner()).await.unwrap(); + apply_options(dir.path(), &options).await; + assert_eq!(confirmation(dir.path(), "knowledge").await, original); + assert!( + read_serving_snapshot_from_storage(root) + .await + .unwrap() + .diagnostics + .is_empty() + ); +} + +#[tokio::test] +async fn configuration_management_does_not_require_unrelated_graph_data_read() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + add_second_graph(dir.path()); + fs::write( + dir.path().join("closed.policy.yaml"), + "version: 1\nrules: []\n", + ) + .unwrap(); + edit_config(dir.path(), |config| { + config["policies"]["base"]["applies_to"] = serde_yaml::from_str("[knowledge]").unwrap(); + config["policies"]["closed"] = + serde_yaml::from_str("file: ./closed.policy.yaml\napplies_to: [second]\n").unwrap(); + }); + bootstrap(dir.path()).await; + unlock(root).await; + let store = ClusterStore::for_config_dir(dir.path()); + let (state, _) = execution::read_existing(&store).await.unwrap(); + assert!( + crate::authorization::AppliedPolicies::load(&store, &state) + .await + .unwrap() + .check_graph( + "principal:owner", + "second", + omnigraph_policy::PolicyAction::Read + ) + .is_err() + ); + let actor = identity("principal:owner"); + deployment_status(root, None, &actor).await.unwrap(); + let path = dir.path().join("base.policy.yaml"); + let source = fs::read_to_string(&path) + .unwrap() + .replace("principal:reader]", "principal:reader, principal:alice]"); + fs::write(path, source).unwrap(); + let result = apply_deployment(dir.path(), None, &actor, &BTreeMap::new(), |_, _, _| {}) + .await + .unwrap(); + let DeploymentLookup::Complete { result } = result else { + panic!("missing result") + }; + assert!(result.converged); + assert_eq!( + result.graphs.keys().map(String::as_str).collect::>(), + ["knowledge"] + ); + deployment_status(root, Some(&result.id), &actor) + .await + .unwrap(); + let (state, _) = execution::read_existing(&store).await.unwrap(); + assert!( + crate::authorization::AppliedPolicies::load(&store, &state) + .await + .unwrap() + .check_graph( + "principal:owner", + "second", + omnigraph_policy::PolicyAction::Read + ) + .is_err() + ); +} diff --git a/crates/omnigraph-cluster/src/diff.rs b/crates/omnigraph-cluster/src/diff.rs index 1bc5c0aa2..89801132d 100644 --- a/crates/omnigraph-cluster/src/diff.rs +++ b/crates/omnigraph-cluster/src/diff.rs @@ -19,6 +19,7 @@ pub(crate) fn diff_resources( reason: None, binding_change: false, metadata_change: None, + lifecycle: None, migration: None, }), Some(before) if before != after => changes.push(PlanChange { @@ -30,6 +31,7 @@ pub(crate) fn diff_resources( reason: None, binding_change: false, metadata_change: None, + lifecycle: None, migration: None, }), Some(_) => {} @@ -46,6 +48,7 @@ pub(crate) fn diff_resources( reason: None, binding_change: false, metadata_change: None, + lifecycle: None, migration: None, }); } @@ -86,6 +89,7 @@ pub(crate) fn append_policy_binding_changes( reason: None, binding_change: true, metadata_change: Some(PlanMetadataChange::PolicyBindings), + lifecycle: None, migration: None, }); } @@ -126,6 +130,7 @@ pub(crate) fn append_embedding_profile_changes( reason: None, binding_change: false, metadata_change: Some(PlanMetadataChange::EmbeddingProfile), + lifecycle: None, migration: None, }); } diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index c9829b3e7..c39cbc6a1 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -35,6 +35,7 @@ mod store; mod types; pub use admission::{ ClusterAdmission, ClusterAdmissionPurpose, acquire_cluster_admission, acquire_graph_admission, + canonical_graph_uri, }; pub use authorization::{ AuthorizedEffect, AuthorizedPlanOutput, IdentityAuthorization, PlanAuthorization, @@ -56,8 +57,9 @@ pub use serve::{ AdmittedServingSnapshot, RootBoundServingSnapshot, ServingBlockedGraph, ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, acquire_serving_admission, admit_serving_snapshot, cluster_graph_ids, cluster_root_for_graph_uri, - read_root_bound_serving_snapshot, read_root_bound_serving_snapshot_from_storage, - read_serving_snapshot, read_serving_snapshot_from_storage, resolve_graph_storage_uri, + read_deployment_serving_snapshot, read_root_bound_serving_snapshot, + read_root_bound_serving_snapshot_from_storage, read_serving_snapshot, + read_serving_snapshot_from_storage, resolve_graph_storage_uri, }; use store::ClusterStore; pub use types::*; @@ -145,10 +147,29 @@ pub async fn plan_config_dir_with_options( config_dir: impl AsRef, options: PlanOptions, ) -> PlanOutput { - // Keep the shared implementation off the forwarding caller's stack. + plan_config_dir_with_deployment_options( + config_dir, + options, + &DeploymentOptions::default(), + None, + ) + .await +} + +/// Read-only execution preflight for the exact lifecycle input and actor. +/// This never acquires admission or persists a prepared intent; apply rechecks +/// the same conditions under the current writer owner. +pub async fn plan_config_dir_with_deployment_options( + config_dir: impl AsRef, + options: PlanOptions, + deployment_options: &DeploymentOptions, + actor: Option, +) -> PlanOutput { Box::pin(plan_config_dir_impl( config_dir.as_ref(), options, + deployment_options, + actor, None, &mut None, )) @@ -166,6 +187,8 @@ pub async fn plan_config_dir_authorized( let plan = Box::pin(plan_config_dir_impl( config_dir.as_ref(), options, + &DeploymentOptions::default(), + None, Some(identity), &mut authorization, )) @@ -179,6 +202,8 @@ pub async fn plan_config_dir_authorized( async fn plan_config_dir_impl( config_dir: &Path, options: PlanOptions, + deployment_options: &DeploymentOptions, + actor: Option, identity: Option<&IdentityAuthorization>, authorization: &mut Option, ) -> PlanOutput { @@ -287,6 +312,12 @@ async fn plan_config_dir_impl( if !has_errors(&diagnostics) { append_policy_binding_changes(&mut changes, prior_state.as_ref(), &desired); append_embedding_profile_changes(&mut changes, prior_state.as_ref(), &desired); + append_lifecycle_changes( + &mut changes, + &prior_resources, + &desired.resource_digests, + deployment_options, + ); } // The same v2 scope rules govern previews and execution. A refused scope // is wholly pre-effect; no approval artifact can authorize a removed path. @@ -298,7 +329,7 @@ async fn plan_config_dir_impl( "convert the stopped cluster ledger to v2 before planning deployments", )); } - match capture_deployment(config_dir, &BTreeMap::new()) { + match capture_deployment_with_options(config_dir, deployment_options) { Ok(bundle) => preview_deployment_scope(state, &bundle).err(), Err(error) => Some(error), } @@ -311,6 +342,7 @@ async fn plan_config_dir_impl( change.disposition = Some( if matches!(resource_kind(&change.resource), ResourceKind::Graph(_)) && change.operation == PlanOperation::Update + && change.lifecycle.is_none() { ApplyDisposition::Derived } else { @@ -346,10 +378,28 @@ async fn plan_config_dir_impl( } } - // Embed real migration steps for schema updates so plan is a data-aware - // preview; failures degrade to the digest diff with a warning. + if !has_errors(&diagnostics) { + let caller = match identity { + Some(identity) => DeploymentCaller::AuthenticatedIdentity(identity.clone()), + None => DeploymentCaller::storage_owner(actor), + }; + let preflight = match capture_deployment_with_options(config_dir, deployment_options) { + Ok(bundle) => preflight_deployment(&bundle, &caller).await, + Err(error) => Err(error), + }; + if let Err(error) = preflight { + for change in &mut changes { + change.disposition = Some(ApplyDisposition::Blocked); + change.reason = Some(error.code.clone()); + } + diagnostics.push(error); + } + } + + // Include migration steps only after the exact read-only preflight. An + // unavailable preview is a refusal, never an executable-looking plan. for change in &mut changes { - if change.operation != PlanOperation::Update { + if has_errors(&diagnostics) || change.operation != PlanOperation::Update { continue; } let ResourceKind::Schema(graph_id) = resource_kind(&change.resource) else { @@ -367,11 +417,15 @@ async fn plan_config_dir_impl( }; match preview { Ok(migration) => change.migration = Some(migration), - Err(err) => diagnostics.push(Diagnostic::warning( - "schema_preview_unavailable", - change.resource.clone(), - format!("could not preview the schema migration: {err}"), - )), + Err(err) => { + change.disposition = Some(ApplyDisposition::Blocked); + change.reason = Some("schema_preview_unavailable".into()); + diagnostics.push(Diagnostic::error( + "schema_preview_unavailable", + change.resource.clone(), + format!("could not preview the schema migration: {err}"), + )); + } } } let blast_radius = compute_blast_radius(&changes, &desired.dependencies); @@ -393,6 +447,68 @@ async fn plan_config_dir_impl( } } +/// Lifecycle requests can have effects without any configuration digest change. +/// Keep them in the same plan rather than returning an empty successful preview. +fn append_lifecycle_changes( + changes: &mut Vec, + prior: &BTreeMap, + desired: &BTreeMap, + options: &DeploymentOptions, +) { + let actions = options + .delete_graphs + .keys() + .map(|id| { + ( + format!("graph.{id}"), + PlanLifecycleAction::RemoveRetainingStorage, + ) + }) + .chain( + options + .adopt_graphs + .keys() + .map(|id| (format!("graph.{id}"), PlanLifecycleAction::Adopt)), + ) + .chain( + options + .recreate_graphs + .keys() + .map(|id| (format!("graph.{id}"), PlanLifecycleAction::Recreate)), + ) + .chain(options.schema_corrections.keys().map(|id| { + ( + format!("graph.{id}"), + PlanLifecycleAction::CorrectSchemaContract, + ) + })) + .chain( + options + .repair_catalog + .iter() + .map(|address| (address.clone(), PlanLifecycleAction::RepairCatalog)), + ); + for (address, action) in actions { + if let Some(change) = changes.iter_mut().find(|change| change.resource == address) { + change.lifecycle = Some(action); + } else { + changes.push(PlanChange { + resource: address.clone(), + operation: PlanOperation::Update, + before_digest: prior.get(&address).cloned(), + after_digest: desired.get(&address).cloned(), + disposition: None, + reason: None, + binding_change: false, + metadata_change: None, + lifecycle: Some(action), + migration: None, + }); + } + } + changes.sort_by(|a, b| a.resource.cmp(&b.resource)); +} + pub async fn status_config_dir(config_dir: impl AsRef) -> StatusOutput { let parsed = parse_cluster_config(config_dir.as_ref()); let mut diagnostics = parsed.diagnostics; diff --git a/crates/omnigraph-cluster/src/serve.rs b/crates/omnigraph-cluster/src/serve.rs index 76021b333..ea02c514d 100644 --- a/crates/omnigraph-cluster/src/serve.rs +++ b/crates/omnigraph-cluster/src/serve.rs @@ -221,6 +221,18 @@ pub async fn read_serving_snapshot_from_storage( read_snapshot_with_store(&backend).await } +/// Project only deployment-affected graphs and their verified runtime payloads. +/// The ledger and cluster management policy remain authoritative in full; +/// unrelated graph payload availability cannot veto an independent cutover. +pub async fn read_deployment_serving_snapshot( + storage_root: &str, + affected: &[String], +) -> Result> { + let backend = + ClusterStore::for_storage_root(storage_root).map_err(|diagnostic| vec![diagnostic])?; + read_snapshot_impl(&backend, false, None, Some(affected)).await +} + /// Test support: read a local cluster's serving snapshot through the /// production reader while its storage root reads as `display_root` (see /// `ClusterStore::with_display_root`). Graph roots derive from it too. @@ -414,7 +426,7 @@ fn cluster_root_of_graph_layout(graph_uri: &str) -> Option { pub(crate) async fn read_snapshot_with_store( backend: &ClusterStore, ) -> Result> { - read_snapshot_impl(backend, false, None).await + read_snapshot_impl(backend, false, None, None).await } /// Decode and validate legacy applied facts only for stopped-writer conversion. @@ -422,7 +434,7 @@ pub(crate) async fn read_snapshot_with_store( pub(crate) async fn read_snapshot_for_ledger_upgrade( backend: &ClusterStore, ) -> Result> { - read_snapshot_impl(backend, true, None).await + read_snapshot_impl(backend, true, None, None).await } /// Use the ordinary serving projector with already validated frozen candidate @@ -430,8 +442,9 @@ pub(crate) async fn read_snapshot_for_ledger_upgrade( pub(crate) async fn preview_snapshot_with_store( backend: &ClusterStore, candidate: &crate::CapturedDeployment, + affected: &[String], ) -> Result> { - read_snapshot_impl(backend, false, Some(candidate)).await + read_snapshot_impl(backend, false, Some(candidate), Some(affected)).await } async fn serving_payload( @@ -457,7 +470,9 @@ async fn read_snapshot_impl( backend: &ClusterStore, legacy_conversion: bool, candidate: Option<&crate::CapturedDeployment>, + affected: Option<&[String]>, ) -> Result> { + let selected = |graph: &str| affected.is_none_or(|graphs| graphs.iter().any(|id| id == graph)); let mut diagnostics: Vec = Vec::new(); let mut startup_diagnostics: Vec = Vec::new(); let mut quarantined_graphs: BTreeSet = BTreeSet::new(); @@ -536,13 +551,19 @@ async fn read_snapshot_impl( let boot_config_digest = state.applied_revision.config_digest.clone(); let boot_state_revision = state.state_revision; let boot_state_cas = observations.state_cas.clone(); - let boot_applied_graphs = applied_graph_ids(&state); + let boot_applied_graphs: Vec<_> = applied_graph_ids(&state) + .into_iter() + .filter(|id| selected(id)) + .collect(); let recovery_pending = boot_applied_graphs .iter() .any(|graph_id| quarantined_graphs.contains(graph_id)); for (graph_id, conflict) in overlapping_served_external_blob_policies(&state, backend.display_root()) { + if !selected(&graph_id) { + continue; + } quarantined_graphs.insert(graph_id.clone()); let remedy = match conflict { omnigraph::StorageRootConflict::UncomparableRoot { .. } => { @@ -566,7 +587,9 @@ async fn read_snapshot_impl( .resources .iter() .filter_map(|(address, entry)| match resource_kind(address) { - ResourceKind::Graph(graph_id) if !quarantined_graphs.contains(&graph_id) => { + ResourceKind::Graph(graph_id) + if selected(&graph_id) && !quarantined_graphs.contains(&graph_id) => + { entry.embedding_provider.clone() } _ => None, @@ -609,6 +632,9 @@ async fn read_snapshot_impl( for (address, entry) in &state.applied_revision.resources { match resource_kind(address) { ResourceKind::Graph(graph_id) => { + if !selected(&graph_id) { + continue; + } saw_applied_graph = true; if quarantined_graphs.contains(&graph_id) { continue; @@ -667,7 +693,7 @@ async fn read_snapshot_impl( let ResourceKind::Query { graph, name } = &kind else { unreachable!() }; - if quarantined_graphs.contains(graph) { + if !selected(graph) || quarantined_graphs.contains(graph) { continue; } match serving_payload(backend, candidate, &kind, &entry.digest, address).await { @@ -694,9 +720,9 @@ async fn read_snapshot_impl( let applies_to: Vec = applies_to .into_iter() .filter(|binding| { - binding - .strip_prefix("graph.") - .is_none_or(|graph| !quarantined_graphs.contains(graph)) + binding.strip_prefix("graph.").is_none_or(|graph| { + selected(graph) && !quarantined_graphs.contains(graph) + }) }) .collect(); if applies_to.is_empty() { @@ -716,7 +742,7 @@ async fn read_snapshot_impl( } } - if graphs.is_empty() { + if graphs.is_empty() && affected.is_none() { if saw_applied_graph { diagnostics.push(Diagnostic::error( "cluster_no_healthy_graphs", diff --git a/crates/omnigraph-cluster/src/store.rs b/crates/omnigraph-cluster/src/store.rs index 5303487c9..4d6d90798 100644 --- a/crates/omnigraph-cluster/src/store.rs +++ b/crates/omnigraph-cluster/src/store.rs @@ -364,6 +364,30 @@ impl ClusterStore { .await } + /// Restore the one valid value for a content-addressed slot under the + /// cluster's sole writer admission. The verified digest fixes every byte; + /// reading damaged content (possibly oversized or non-UTF8) adds no proof. + /// Atomic replacement is bounded by captured source size. A concurrent + /// valid repair of this slot necessarily writes the same bytes. + pub(crate) async fn repair_payload( + &self, + kind: &ResourceKind, + digest: &str, + content: &str, + ) -> Result<(), String> { + let relative = + Self::payload_relative(kind, digest).ok_or("resource kind has no payload")?; + if content.len() > crate::config::MAX_CONFIG_SOURCE_BYTES + || sha256_hex(content.as_bytes()) != digest + { + return Err("repair source does not match its bounded digest".into()); + } + self.adapter + .write_text(&self.uri(&relative), content) + .await + .map_err(|error| error.to_string()) + } + async fn write_content_addressed( &self, relative: &str, diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index 853214164..92462a726 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -1693,7 +1693,7 @@ async fn existing_lock_makes_plan_fail() { } #[tokio::test] -async fn state_lock_false_bypasses_lock_with_warning() { +async fn plan_refuses_execution_without_required_lock() { let dir = fixture(); fs::write( dir.path().join(CLUSTER_CONFIG_FILE), @@ -1710,7 +1710,12 @@ graphs: .unwrap(); let out = plan_config_dir(dir.path()).await; - assert!(out.ok, "{:?}", out.diagnostics); + assert!(!out.ok, "{:?}", out.diagnostics); + assert!( + out.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "deployment_requires_lock") + ); assert!(!out.state_observations.locked); assert!(!out.state_observations.lock_acquired); assert!( @@ -1856,7 +1861,7 @@ async fn durable_store_verifies_immutable_bundle_bytes_and_encoded_bound() { let store = ClusterStore::for_config_dir(dir.path()); let source_digest = sha256_hex(SCHEMA.as_bytes()); let mut bundle = deployment::DeploymentBundle { - schema_corrections: BTreeMap::new(), + options: DeploymentOptions::default(), version: 2, canonical_root: store.canonical_root().unwrap(), config_digest: sha256_hex(b"configuration"), @@ -2709,8 +2714,8 @@ async fn offline_deployment_reserves_created_query_projection_before_schema_effe let mut config = fs::read_to_string(dir.path().join(CLUSTER_CONFIG_FILE)).unwrap(); let mut declarations = String::new(); let mut projected_growth = 0; - for index in 0..256 { - let name = format!("query_{index:03}_{}", "x".repeat(480)); + for index in 0..512 { + let name = format!("query_{index:03}_{}", "x".repeat(200)); let address = config::query_address("knowledge", &name); assert!(address.len() <= 512); let source = QUERY.replace("find_person", &name); @@ -2842,6 +2847,8 @@ fn offline_deployment_reserves_partial_projection_resource_count() { GraphDeployment { intent: None, create: None, + adopt: None, + delete: None, observed_manifest_version: 1, state: GraphDeploymentState::NotStarted, settlement: None, @@ -2863,7 +2870,7 @@ fn offline_deployment_reserves_partial_projection_resource_count() { } assert!(desired.len() <= deployment::MAX_RESOURCES); let bundle = DeploymentBundle { - schema_corrections: BTreeMap::new(), + options: DeploymentOptions::default(), version: 2, canonical_root: "file:///capacity-only".into(), config_digest: "e".repeat(64), @@ -2945,6 +2952,8 @@ fn offline_deployment_graph_completion_reserve_covers_maximum_serialized_engine_ // change. Null cancels that same term in both sides of this comparison. intent: None, create: None, + adopt: None, + delete: None, observed_manifest_version: u64::MAX, state: GraphDeploymentState::NotStarted, settlement: None, @@ -3277,7 +3286,7 @@ fn write_create_sidecar( #[tokio::test] async fn plan_embeds_migration_preview_for_schema_update() { - let dir = fixture(); + let dir = identity_fixture(); apply_identity_fixture(dir.path()).await; fs::write( dir.path().join("people.pg"), @@ -3285,7 +3294,13 @@ async fn plan_embeds_migration_preview_for_schema_update() { ) .unwrap(); - let out = plan_config_dir(dir.path()).await; + let out = plan_config_dir_with_deployment_options( + dir.path(), + PlanOptions::default(), + &DeploymentOptions::default(), + Some("principal:owner".into()), + ) + .await; assert!(out.ok, "{:?}", out.diagnostics); let schema_change = out .changes @@ -3300,10 +3315,44 @@ async fn plan_embeds_migration_preview_for_schema_update() { .contains("add_property"), "{migration:?}" ); + + let caller = DeploymentCaller::storage_owner(Some("principal:owner".into())); + let root = dir.path().join("graphs/knowledge.omni"); + let db = Omnigraph::open(root.to_str().unwrap()).await.unwrap(); + db.branch_create("feature").await.unwrap(); + let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let version = identity_manifest_version(dir.path()).await; + let plan = plan_config_dir_with_deployment_options( + dir.path(), + PlanOptions::default(), + &DeploymentOptions::default(), + Some("principal:owner".into()), + ) + .await; + assert!(!plan.ok, "{plan:?}"); + let error = apply_deployment(dir.path(), None, &caller, &BTreeMap::new(), |_, _, _| {}) + .await + .unwrap_err(); + assert!( + plan.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == error.code) + ); + assert!( + plan.changes + .iter() + .all(|change| change.disposition == Some(ApplyDisposition::Blocked)) + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert_eq!(identity_manifest_version(dir.path()).await, version); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); } #[tokio::test] -async fn plan_warns_when_preview_unavailable() { +async fn plan_refuses_when_live_graph_is_unavailable() { let dir = fixture(); apply_identity_fixture(dir.path()).await; fs::remove_dir_all(dir.path().join(CLUSTER_GRAPHS_DIR)).unwrap(); // missing live root @@ -3314,17 +3363,19 @@ async fn plan_warns_when_preview_unavailable() { .unwrap(); let out = plan_config_dir(dir.path()).await; - assert!(out.ok, "{:?}", out.diagnostics); + assert!(!out.ok, "{:?}", out.diagnostics); let schema_change = out .changes .iter() .find(|change| change.resource == "schema.knowledge") .unwrap(); assert!(schema_change.migration.is_none()); + assert_eq!(schema_change.disposition, Some(ApplyDisposition::Blocked)); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); assert!( out.diagnostics .iter() - .any(|diagnostic| diagnostic.code == "schema_preview_unavailable") + .any(|diagnostic| diagnostic.code == "graph_unavailable") ); } @@ -3996,7 +4047,7 @@ async fn read_only_commands_ignore_missing_recovery_sidecar_dir() { } #[tokio::test] -async fn read_only_commands_warn_on_pending_recovery_sidecar_in_storage_root() { +async fn status_warns_and_plan_refuses_pending_recovery_in_storage_root() { let dir = fixture(); let storage = tempfile::tempdir().unwrap(); let storage_path = storage.path().to_string_lossy().to_string(); @@ -4023,7 +4074,12 @@ async fn read_only_commands_warn_on_pending_recovery_sidecar_in_storage_root() { ); let plan = plan_config_dir(dir.path()).await; - assert!(plan.ok, "{:?}", plan.diagnostics); + assert!(!plan.ok, "{:?}", plan.diagnostics); + assert!( + plan.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "policy_recovery_required") + ); assert!( plan.diagnostics .iter() @@ -4683,7 +4739,7 @@ async fn serving_snapshot_reads_applied_empty_cluster() { // ---- query discovery (Terraform-style declaration) ---- #[tokio::test] -async fn plan_refuses_unsupported_inventory_and_binding_changes_without_approval_flow() { +async fn plan_requires_exact_removal_confirmation_and_accepts_policy_rebinding() { let dir = fixture(); apply_identity_fixture(dir.path()).await; let path = dir.path().join(CLUSTER_CONFIG_FILE); @@ -4693,20 +4749,23 @@ async fn plan_refuses_unsupported_inventory_and_binding_changes_without_approval "version: 1\ngraphs: {}\n".to_owned(), original.replace("applies_to: [knowledge]", "applies_to: [cluster]"), ] { - fs::write(&path, source).unwrap(); + fs::write(&path, &source).unwrap(); let plan = plan_config_dir(dir.path()).await; - assert!(!plan.ok, "{plan:?}"); - assert!( - plan.diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "deployment_scope"), - "{plan:?}" - ); - assert!( - plan.changes - .iter() - .all(|change| change.disposition == Some(ApplyDisposition::Blocked)) - ); + let removing = !source.contains("knowledge"); + assert_eq!(plan.ok, !removing, "{plan:?}"); + if removing { + assert!( + plan.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "graph_delete_confirmation_required"), + "{plan:?}" + ); + assert!( + plan.changes + .iter() + .all(|change| change.disposition == Some(ApplyDisposition::Blocked)) + ); + } let wire = serde_json::to_value(&plan).unwrap(); assert!(wire.get("approvals_required").is_none()); assert_eq!( diff --git a/crates/omnigraph-cluster/src/types.rs b/crates/omnigraph-cluster/src/types.rs index bbc2ae13a..e8d0188fe 100644 --- a/crates/omnigraph-cluster/src/types.rs +++ b/crates/omnigraph-cluster/src/types.rs @@ -193,13 +193,26 @@ pub struct PlanChange { /// whose applied ledger metadata needs to converge. #[serde(skip_serializing_if = "Option::is_none")] pub metadata_change: Option, + /// Explicit lifecycle work remains visible even when source digests match. + #[serde(skip_serializing_if = "Option::is_none")] + pub lifecycle: Option, /// For schema updates: the engine's migration plan against the live /// graph (RFC-004 §D7's data-aware preview). Absent when the preview is - /// unavailable (warning `schema_preview_unavailable`). + /// unavailable (error `schema_preview_unavailable`). #[serde(skip_serializing_if = "Option::is_none")] pub migration: Option, } +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PlanLifecycleAction { + RemoveRetainingStorage, + Adopt, + Recreate, + RepairCatalog, + CorrectSchemaContract, +} + #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PlanMetadataChange { diff --git a/crates/omnigraph-cluster/tests/failpoints.rs b/crates/omnigraph-cluster/tests/failpoints.rs index 3b9028324..68c18c3f0 100644 --- a/crates/omnigraph-cluster/tests/failpoints.rs +++ b/crates/omnigraph-cluster/tests/failpoints.rs @@ -741,12 +741,21 @@ fn state_path(config_dir: &Path) -> PathBuf { #[serial] async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { use omnigraph_cluster::seams::catalog as seams; - use omnigraph_cluster::{GraphDeploymentResult, reconcile_deployment}; + use omnigraph_cluster::{ + DeploymentOptions, GraphDeploymentResult, apply_deployment_with_options, + reconcile_deployment, + }; let _scenario = FailScenario::setup(); - for (seam, created, replace) in [ - (&seams::DEPLOYMENT_AFTER_STARTED, false, false), - (&seams::DEPLOYMENT_AFTER_SCHEMA, true, false), - (&seams::DEPLOYMENT_AFTER_SCHEMA, true, true), + for (seam, created, replace, recreate) in [ + (&seams::DEPLOYMENT_AFTER_STARTED, false, false, false), + (&seams::DEPLOYMENT_AFTER_SCHEMA, true, false, false), + (&seams::DEPLOYMENT_AFTER_SCHEMA, true, true, false), + // An unchanged config is not convergence if explicit recreation never + // started. Same-name replacement also requires the exact new genesis. + (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, false, false, true), + (&seams::DEPLOYMENT_AFTER_STARTED, false, false, true), + (&seams::DEPLOYMENT_AFTER_SCHEMA, true, false, true), + (&seams::DEPLOYMENT_AFTER_SCHEMA, true, true, true), ] { let dir = fixture(); let root = dir.path().to_str().unwrap(); @@ -761,21 +770,35 @@ async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { .unwrap(); assert!(matches!(first, DeploymentLookup::Complete { ref result } if result.converged)); unlock_offline(dir.path()).await; - let path = dir.path().join("cluster.yaml"); - let source = fs::read_to_string(&path).unwrap(); - fs::write( - &path, - source.replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"), - ) - .unwrap(); + let target = if recreate { "knowledge" } else { "second" }; + let mut options = DeploymentOptions::default(); + if recreate { + let uri = dir.path().join("graphs/knowledge.omni"); + let db = Omnigraph::open_read_only(uri.to_str().unwrap()) + .await + .unwrap(); + options + .recreate_graphs + .insert("knowledge".into(), db.schema_contract_digest()); + drop(db); + fs::remove_dir_all(uri).unwrap(); + } else { + let path = dir.path().join("cluster.yaml"); + let source = fs::read_to_string(&path).unwrap(); + fs::write( + &path, + source.replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"), + ) + .unwrap(); + } let mut id = String::new(); { let _failure = seam.fire_always(); - let error = apply_deployment( + let error = apply_deployment_with_options( dir.path(), None, &deployment_owner(), - &BTreeMap::new(), + &options, |issued, _, _| id = issued.into(), ) .await @@ -784,7 +807,7 @@ async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { } unlock_offline(dir.path()).await; if replace { - let graph = dir.path().join("graphs/second.omni"); + let graph = dir.path().join(format!("graphs/{target}.omni")); fs::remove_dir_all(&graph).unwrap(); Omnigraph::init(graph.to_str().unwrap(), SCHEMA) .await @@ -806,15 +829,14 @@ async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { }; assert_eq!(result.converged, created); assert_eq!( - matches!( - result.graphs["second"], - GraphDeploymentResult::Created { .. } - ), + matches!(result.graphs[target], GraphDeploymentResult::Created { .. }), created ); assert!(matches!( - result.graphs["second"], - GraphDeploymentResult::Created { .. } | GraphDeploymentResult::Refused { .. } + result.graphs[target], + GraphDeploymentResult::Created { .. } + | GraphDeploymentResult::Refused { .. } + | GraphDeploymentResult::NotAttempted )); } } @@ -999,3 +1021,243 @@ async fn graph_creation_partial_result_keeps_only_achieved_policy_bindings() { .any(|policy| policy.applies_to == ["graph.knowledge", "graph.second", "graph.third"]) ); } + +/// Adoption/removal publish only the recorded catalog transition. They never +/// replay graph writes or authorize a newly recreated root during recovery. +#[tokio::test] +#[serial] +async fn exact_graph_lifecycle_reconciles_without_touching_retained_data() { + use omnigraph::db::ReadTarget; + use omnigraph_cluster::seams::catalog as seams; + use omnigraph_cluster::{ + DeploymentOptions, GraphDeploymentResult, GraphLifecycleConfirmation, + apply_deployment_with_options, reconcile_deployment, + }; + let _scenario = FailScenario::setup(); + for (seam, foreign, aliased) in [ + (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, false, false), + (&seams::DEPLOYMENT_BEFORE_RESULT, false, false), + (&seams::DEPLOYMENT_AFTER_RESULT, false, false), + (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, true, false), + #[cfg(unix)] + (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, false, true), + ] { + for remove in [false, true] { + let dir = fixture(); + let root = dir.path().to_str().unwrap(); + let config_path = dir.path().join("cluster.yaml"); + let one_graph = fs::read_to_string(&config_path).unwrap(); + let two_graphs = + one_graph.replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"); + if remove { + fs::write(&config_path, &two_graphs).unwrap(); + } + apply_deployment( + dir.path(), + None, + &deployment_owner(), + &BTreeMap::new(), + |_, _, _| {}, + ) + .await + .unwrap(); + unlock_offline(dir.path()).await; + let uri = dir.path().join("graphs/second.omni"); + let db = if remove { + Omnigraph::open(uri.to_str().unwrap()).await.unwrap() + } else { + Omnigraph::init(uri.to_str().unwrap(), SCHEMA) + .await + .unwrap() + }; + let session = omnigraph::Session::from_defaults( + std::sync::Arc::new(db), + omnigraph::settings::SessionSettings::default(), + ); + session + .load_jsonl( + r#"{"type":"Person","data":{"name":"Kept","age":37}}"#, + omnigraph::loader::LoadMode::Merge, + ) + .await + .unwrap(); + drop(session); + let db = Omnigraph::open_read_only(uri.to_str().unwrap()) + .await + .unwrap(); + let confirmation = GraphLifecycleConfirmation { + contract: db.schema_contract_digest(), + graph_manifest_version: db + .snapshot_of(ReadTarget::branch("main")) + .await + .unwrap() + .graph_manifest_version(), + }; + let contents = db.export_jsonl("main", &[]).await.unwrap(); + drop(db); + fs::write(&config_path, if remove { &one_graph } else { &two_graphs }).unwrap(); + let mut options = DeploymentOptions::default(); + if remove { + options + .delete_graphs + .insert("second".into(), confirmation.clone()); + } else { + options + .adopt_graphs + .insert("second".into(), confirmation.clone()); + } + let mut id = String::new(); + { + let _failure = seam.fire_always(); + let error = apply_deployment_with_options( + dir.path(), + None, + &deployment_owner(), + &options, + |issued, _, _| id = issued.into(), + ) + .await + .unwrap_err(); + assert_eq!(error.code, "injected_failpoint"); + } + unlock_offline(dir.path()).await; + if foreign { + fs::remove_dir_all(&uri).unwrap(); + Omnigraph::init(uri.to_str().unwrap(), SCHEMA) + .await + .unwrap(); + } + #[cfg(unix)] + if aliased { + let moved = dir.path().join("unmanaged.omni"); + fs::rename(&uri, &moved).unwrap(); + std::os::unix::fs::symlink(moved, &uri).unwrap(); + } + fs::remove_file(config_path).unwrap(); + fs::remove_file(dir.path().join("people.pg")).unwrap(); + let recovered = reconcile_deployment(root, &id, true, &deployment_owner()).await; + if foreign || aliased { + assert_eq!( + recovered.unwrap_err().code, + if aliased { + "cluster_graph_root_mismatch" + } else { + "graph_lifecycle_confirmation_mismatch" + } + ); + assert_eq!( + omnigraph_cluster::deployment_status(root, Some(&id), &deployment_owner()) + .await + .unwrap() + .outstanding_id + .as_deref(), + Some(id.as_str()) + ); + continue; + } + let DeploymentLookup::Complete { result } = recovered.unwrap() else { + panic!("missing exact result") + }; + assert!(result.converged); + assert_eq!(result.id, id); + assert_eq!( + matches!( + result.graphs["second"], + GraphDeploymentResult::Deleted { + retained_storage: true, + .. + } + ), + remove + ); + assert_eq!( + matches!( + result.graphs["second"], + GraphDeploymentResult::Adopted { .. } + ), + !remove + ); + let db = Omnigraph::open_read_only(uri.to_str().unwrap()) + .await + .unwrap(); + assert_eq!(db.schema_contract_digest(), confirmation.contract); + assert_eq!(db.export_jsonl("main", &[]).await.unwrap(), contents); + } + } +} + +#[tokio::test] +#[serial] +async fn exact_catalog_repair_survives_acceptance_loss_without_source_checkout() { + use omnigraph_cluster::seams::catalog as seams; + use omnigraph_cluster::{ + DeploymentOptions, apply_deployment_with_options, reconcile_deployment, + }; + let _scenario = FailScenario::setup(); + let dir = fixture(); + let root = dir.path().to_str().unwrap(); + apply_deployment( + dir.path(), + None, + &deployment_owner(), + &BTreeMap::new(), + |_, _, _| {}, + ) + .await + .unwrap(); + unlock_offline(dir.path()).await; + let state: serde_json::Value = + serde_json::from_slice(&fs::read(dir.path().join("__cluster/state.json")).unwrap()) + .unwrap(); + let digest = state["applied_revision"]["resources"]["policy.base"]["digest"] + .as_str() + .unwrap(); + let payload = dir + .path() + .join(format!("__cluster/resources/policy/base/{digest}.yaml")); + let original = fs::read(&payload).unwrap(); + fs::write(&payload, [0xff]).unwrap(); + let options = DeploymentOptions { + repair_catalog: ["policy.base".into()].into_iter().collect(), + ..DeploymentOptions::default() + }; + let mut id = String::new(); + { + let _failure = seams::DEPLOYMENT_AFTER_ACCEPTANCE.fire_always(); + assert_eq!( + apply_deployment_with_options( + dir.path(), + None, + &deployment_owner(), + &options, + |issued, _, _| id = issued.into() + ) + .await + .unwrap_err() + .code, + "injected_failpoint" + ); + } + // The exact accepted repair is the only authorized fallback for the + // unreadable applied policy. A root-only lookup cannot invent that source. + let status = omnigraph_cluster::deployment_status(root, Some(&id), &deployment_owner()) + .await + .unwrap(); + omnigraph_cluster::force_unlock_storage_root(root, status.lock_id.as_deref().unwrap()) + .await + .unwrap(); + fs::remove_file(dir.path().join("cluster.yaml")).unwrap(); + fs::remove_file(dir.path().join("base.policy.yaml")).unwrap(); + fs::remove_file(dir.path().join("people.pg")).unwrap(); + fs::remove_file(dir.path().join("people.gq")).unwrap(); + let DeploymentLookup::Complete { result } = + reconcile_deployment(root, &id, true, &deployment_owner()) + .await + .unwrap() + else { + panic!("missing repair receipt") + }; + assert_eq!(result.id, id); + assert!(result.converged); + assert_eq!(fs::read(payload).unwrap(), original); +} diff --git a/crates/omnigraph-server/src/deployment.rs b/crates/omnigraph-server/src/deployment.rs index d947f6d43..0457575fb 100644 --- a/crates/omnigraph-server/src/deployment.rs +++ b/crates/omnigraph-server/src/deployment.rs @@ -2,7 +2,7 @@ //! Input and graph outcomes belong to the cluster ledger; this controller owns //! only request lifetime, graph admission and activation of serving bindings. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; use axum::{ @@ -65,7 +65,12 @@ fn caller(state: &AppState, actor: &AuthenticatedActor) -> Result ApiError { } fn uncertain(message: impl Into) -> ApiError { + let message = message.into(); + tracing::error!(reason = %message, "deployment requires reconciliation"); ApiError::internal(message) } @@ -111,12 +118,18 @@ fn active_result( return false; } matches!(lookup, Some(DeploymentLookup::Complete { result }) - if result.result_revision == current_revision - && result.activation.as_ref().is_some_and(|active| - active.process_incarnation == owner.lock_id() - && active.result_revision == current_revision) - && result.graphs.keys().all(|id| GraphId::try_from(id.as_str()).is_ok_and(|id| - matches!(state.routing.registry.get(&GraphKey::cluster(id)), RegistryLookup::Ready(_))))) + if result.result_revision == current_revision + && result.activation.as_ref().is_some_and(|active| + active.process_incarnation == owner.lock_id() + && active.result_revision == current_revision) + && result.graphs.iter().all(|(id, outcome)| GraphId::try_from(id.as_str()).is_ok_and(|id| { + let current = state.routing.registry.get(&GraphKey::cluster(id)); + if matches!(outcome, omnigraph_cluster::GraphDeploymentResult::Deleted { .. }) { + matches!(current, RegistryLookup::Gone) + } else { + matches!(current, RegistryLookup::Ready(_)) + } + }))) } #[utoipa::path( @@ -249,7 +262,21 @@ async fn execute( omnigraph_cluster::deployment_affected_graphs(&request.deployment, &owner, &caller) .await .map_err(refusal)?; - validate_serving_candidate(&request.deployment, &owner, &caller, &affected).await?; + for id in request.deployment.options().recreate_graphs.keys() { + let key = GraphKey::cluster( + GraphId::try_from(id.as_str()) + .map_err(|error| ApiError::bad_request(error.to_string()))?, + ); + if matches!( + state.routing.registry.get(&key), + RegistryLookup::Ready(_) | RegistryLookup::Transitioning(_) + ) { + return Err(ApiError::conflict(format!( + "graph {id} retains a runtime owner; restart after correcting the missing root before recreation" + ))); + } + } + let retained = validate_serving_candidate(&state, &request.deployment, &owner, &caller).await?; let mut keys = Vec::new(); for id in &affected { let key = GraphKey::cluster( @@ -257,7 +284,9 @@ async fn execute( .map_err(|error| ApiError::bad_request(error.to_string()))?, ); match state.routing.registry.get(&key) { - RegistryLookup::Ready(_) => keys.push(key), + RegistryLookup::Ready(_) + | RegistryLookup::Transitioning(_) + | RegistryLookup::Blocked(_) => keys.push(key), RegistryLookup::Gone => {} // a new graph becomes visible only after achieved publication _ => { return Err(ApiError::conflict(format!( @@ -269,7 +298,7 @@ async fn execute( let transition = state .routing .registry - .prepare_transition( + .prepare_deployment_transition( &state.operations, &keys, Instant::now() + TRANSITION_TIMEOUT, @@ -306,6 +335,7 @@ async fn execute( .into_iter() .map(|(key, engine)| (key.graph_id.to_string(), engine)), ); + live.extend(retained); let mut effects_started = false; let applied = omnigraph_cluster::apply_captured_deployment( &request.deployment, @@ -344,61 +374,123 @@ async fn execute( .await .map_err(|error| { uncertain(format!( - "deployment applied; activation contract unavailable: {}", + "deployment applied; activation contracts unavailable: {}", error.message )) })?; - let snapshot = omnigraph_cluster::read_serving_snapshot_from_storage(owner.canonical_root()) - .await - .map_err(|_| { - uncertain("deployment applied; achieved serving snapshot could not be loaded") - })?; - let settings = crate::settings::settings_from_snapshot( + let snapshot = + omnigraph_cluster::read_deployment_serving_snapshot(owner.canonical_root(), &affected) + .await + .map_err(|_| uncertain("deployment applied; achieved serving snapshot unavailable"))?; + let settings = crate::settings::deployment_settings_from_snapshot( std::path::Path::new(owner.canonical_root()), - None, - false, - false, snapshot, ) .map_err(|error| { uncertain(format!( - "deployment applied; serving configuration is invalid: {error}" + "deployment applied; achieved bindings invalid: {error}" )) })?; - let crate::ServerConfigMode::Multi { graphs, .. } = settings.mode; - let mut bindings = HashMap::new(); - let mut additions = Vec::new(); + let crate::ServerConfigMode::Multi { + graphs, + server_policy, + .. + } = settings.mode; + let server_policy = + prepare_management_policy(server_policy).map_err(|error| uncertain(error.message))?; + let mut handles = Vec::new(); + let mut unavailable = Vec::new(); + let mut removals: HashSet<_> = keys + .iter() + .filter(|key| !contracts.contains_key(key.graph_id.as_str())) + .cloned() + .collect(); for graph in graphs { - if !affected.contains(&graph.graph_id) { - continue; - } + let id = graph.graph_id.clone(); let contract = contracts - .get(&graph.graph_id) + .get(&id) .ok_or_else(|| uncertain("achieved schema contract is missing"))? .clone(); let key = GraphKey::cluster( - GraphId::try_from(graph.graph_id.as_str()) - .map_err(|error| uncertain(error.to_string()))?, + GraphId::try_from(id.as_str()).map_err(|error| uncertain(error.to_string()))?, ); - if keys.contains(&key) { - if graph.startup_failure.is_some() { - return Err(uncertain("applied graph has invalid serving bindings")); + removals.remove(&key); + let uri = omnigraph::storage::normalize_root_uri(&graph.uri) + .map_err(|error| uncertain(format!("achieved graph URI is invalid: {error}")))?; + let prepared = match crate::prepare_single_graph(graph, Some(contract.clone())) { + Ok(prepared) => prepared, + Err(error) if !live.contains_key(&id) => { + unavailable.push(Arc::new(crate::BlockedGraph { + key, + uri, + policy: error.policy, + failure: error.failure, + })); + continue; } - bindings.insert(key, (contract, graph.queries)); - } else { - let prepared = - crate::prepare_single_graph(graph, Some(contract.clone())).map_err(|error| { - uncertain(format!("new graph serving preparation failed: {error}")) + Err(error) => { + return Err(uncertain(format!( + "applied runtime bindings invalid: {error}" + ))); + } + }; + if let Some(engine) = live.get(&id) { + let policy = prepared + .pending + .policy + .as_ref() + .map(|policy| Arc::clone(policy) as Arc); + let engine = Arc::new( + engine + .with_runtime_bindings( + policy, + prepared.cfg.embedding.clone().map(Arc::new), + prepared.cfg.external_blob_policy.clone(), + ) + .map_err(|error| { + uncertain(format!("applied runtime bindings invalid: {error}")) + })?, + ); + crate::verify_server_schema_contract(&engine, Some(&contract)) + .map_err(|error| uncertain(format!("applied graph contract changed: {error}")))?; + let queries = crate::validate_and_attach(prepared.cfg.queries, &engine.catalog(), &id) + .map_err(|error| { + uncertain(format!("applied stored queries are invalid: {error}")) })?; - let opened = crate::open_prepared_graph(prepared) - .await - .map_err(|error| uncertain(format!("new graph serving open failed: {error}")))?; - additions.push((opened.handle, contract)); + handles.push(( + Arc::new(crate::GraphHandle { + key, + uri: prepared.pending.uri.clone(), + engine, + policy: prepared.pending.policy.clone(), + queries, + }), + contract, + )); + } else { + match crate::open_prepared_graph(prepared).await { + Ok(opened) => handles.push((opened.handle, contract)), + Err(error) => unavailable.push(Arc::new(crate::BlockedGraph { + key, + uri, + policy: error.policy, + failure: error.failure, + })), + } } } + let fully_ready = unavailable.is_empty(); transition - .activate(bindings, additions) + .activate_deployment(handles, removals, unavailable, server_policy) .map_err(|error| uncertain(format!("deployment applied; activation refused: {error}")))?; + if result.config_digest.is_none() || !fully_ready { + // Serving follows the exact achieved schema/query/runtime projection. + // Partial convergence never acknowledges the desired revision active. + return Ok(DeploymentResponse { + deployment: applied, + active: false, + }); + } let activated = omnigraph_cluster::record_deployment_activation( owner.canonical_root(), &request.deployment_id, @@ -431,27 +523,15 @@ async fn execute( /// preparation as startup, before accepting any deployment or graph effect. /// Unaffected bindings remain installed and are not reconfigured by this apply. async fn validate_serving_candidate( + state: &AppState, deployment: &CapturedDeployment, owner: &omnigraph_cluster::ClusterAdmission, caller: &DeploymentCaller, - affected: &[String], -) -> Result<(), ApiError> { - let mut snapshot = +) -> Result>, ApiError> { + let snapshot = omnigraph_cluster::preview_deployment_serving_snapshot(deployment, owner, caller) .await .map_err(refusal)?; - snapshot - .graphs - .retain(|graph| affected.contains(&graph.graph_id)); - snapshot - .quarantined_graphs - .retain(|graph| affected.contains(&graph.graph_id)); - snapshot - .queries - .retain(|query| affected.contains(&query.graph_id)); - snapshot - .applied_graphs - .retain(|graph| affected.contains(graph)); let settings = crate::settings::settings_from_snapshot( std::path::Path::new(owner.canonical_root()), None, @@ -464,13 +544,90 @@ async fn validate_serving_candidate( "deployment serving configuration is invalid: {error}" )) })?; - let crate::ServerConfigMode::Multi { graphs, .. } = settings.mode; + let crate::ServerConfigMode::Multi { + graphs, + server_policy, + .. + } = settings.mode; + prepare_management_policy(server_policy)?; + state + .routing + .registry + .validate_owner_capacity( + &graphs + .iter() + .map(|graph| graph.uri.clone()) + .collect::>(), + ) + .map_err(|error| ApiError::conflict(error.to_string()))?; + let mut retained = BTreeMap::new(); for graph in graphs { - crate::prepare_single_graph(graph, None).map_err(|error| { + let id = graph.graph_id.clone(); + let prepared = crate::prepare_single_graph(graph, None).map_err(|error| { ApiError::conflict(format!("deployment serving preparation failed: {error}")) })?; + let retired = state + .routing + .registry + .retained_engine(&prepared.pending.uri) + .map_err(|error| ApiError::conflict(error.to_string()))?; + if let Some(engine) = &retired { + let confirmation = deployment.options().adopt_graphs.get(&id).ok_or_else(|| { + ApiError::conflict(format!("graph {id} retains a deleted runtime owner; exact adoption or restart is required")) + })?; + let snapshot = engine + .snapshot_of(omnigraph::db::ReadTarget::branch("main")) + .await + .map_err(|error| ApiError::from_omni(error.before_effect()))?; + if engine.schema_contract_digest() != confirmation.contract + || snapshot.graph_manifest_version() != confirmation.graph_manifest_version + { + return Err(ApiError::conflict(format!( + "graph {id} retained owner does not match the adoption confirmation" + ))); + } + retained.insert(id, Arc::clone(engine)); + } + let engine = match state.routing.registry.get(&prepared.pending.key) { + RegistryLookup::Ready(view) | RegistryLookup::Transitioning(view) => { + Some(Arc::clone(&view.engine)) + } + _ => retired, + }; + if let Some(engine) = engine { + let policy = prepared + .pending + .policy + .as_ref() + .map(|policy| Arc::clone(policy) as Arc); + let engine = engine + .with_runtime_bindings( + policy, + prepared.cfg.embedding.clone().map(Arc::new), + prepared.cfg.external_blob_policy.clone(), + ) + .map_err(|error| { + ApiError::conflict(format!("deployment runtime bindings are invalid: {error}")) + })?; + drop(engine); + } + } + Ok(retained) +} + +fn prepare_management_policy( + source: Option, +) -> Result>, ApiError> { + match source { + Some(crate::PolicySource::Inline(source)) => { + Some(crate::PolicyEngine::load_cluster_from_source(&source)) + } + Some(crate::PolicySource::File(path)) => Some(crate::PolicyEngine::load_cluster(&path)), + None => None, } - Ok(()) + .transpose() + .map_err(|error| ApiError::conflict(format!("invalid management policy: {error}"))) + .map(|policy| policy.map(Arc::new)) } fn abort_before_effects( diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 2b69e194b..4489ab2e2 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -161,10 +161,10 @@ pub(crate) async fn server_graphs_list( State(state): State, actor: Option>, ) -> std::result::Result, ApiError> { - let registry = &state.routing().registry; + let snapshot = state.routing().registry.snapshot_ref(); - // Server-level Cedar gate. `state.server_policy` is loaded from the - // cluster-scoped policy bundle at startup. When no server policy is + // Capture management authorization and inventory from one activated + // registry snapshot. 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 @@ -172,7 +172,7 @@ pub(crate) async fn server_graphs_list( // the registry until the operator explicitly authorizes it). authorize_request( actor.as_ref().map(|Extension(actor)| actor), - state.server_policy.as_deref(), + snapshot.server_policy.as_deref(), PolicyRequest { action: PolicyAction::GraphList, branch: None, @@ -187,9 +187,9 @@ pub(crate) async fn server_graphs_list( }; let stopping = state.draining.load(std::sync::atomic::Ordering::SeqCst) || state.operations.snapshot().closed; - let mut graphs: Vec = registry - .entries() - .into_iter() + let mut graphs: Vec = snapshot + .graphs + .values() .filter(|entry| may_list(entry.key().graph_id.as_str())) .map(|entry| { let failure = match &entry { @@ -222,7 +222,7 @@ pub(crate) async fn server_graphs_list( GraphEntry::Loading(_) => GraphAvailabilityAction::WaitForStartup, GraphEntry::Ready(_) => GraphAvailabilityAction::None, GraphEntry::Transitioning(_) => GraphAvailabilityAction::WaitForTransition, - GraphEntry::Blocked(_) => GraphAvailabilityAction::RestartAfterCorrection, + GraphEntry::Blocked(_) => GraphAvailabilityAction::ApplyCorrectionOrRestart, } }, } @@ -512,29 +512,32 @@ pub(crate) fn resolve_registered_graph( key: &GraphKey, actor: Option<&AuthenticatedActor>, ) -> std::result::Result { - let (policy, invalid_policy, message) = - match state.routing().registry.capture(&state.operations, key)? { - RegistryCapture::Loading(graph) => ( - graph.policy.clone(), - false, - "graph is loading; wait for its startup attempt to complete", - ), - RegistryCapture::Ready(handle) => return Ok(handle), - RegistryCapture::Gone => return Err(ApiError::not_found("graph not found")), - RegistryCapture::Transitioning(view) => ( - view.policy.clone(), - false, - "graph admission is closed for a serving transition", - ), - RegistryCapture::Blocked(graph) => ( - graph.policy.clone(), - matches!( - graph.failure, - StartupFailure::InvalidPolicy | StartupFailure::InvalidConfiguration - ), - "graph is unavailable; an operator must correct its startup failure and restart", + let (captured, server_policy) = state + .routing() + .registry + .capture_with_policy(&state.operations, key)?; + let (policy, invalid_policy, message) = match captured { + RegistryCapture::Loading(graph) => ( + graph.policy.clone(), + false, + "graph is loading; wait for its startup attempt to complete", + ), + RegistryCapture::Ready(handle) => return Ok(handle), + RegistryCapture::Gone => return Err(ApiError::not_found("graph not found")), + RegistryCapture::Transitioning(view) => ( + view.policy.clone(), + false, + "graph admission is closed for a serving transition", + ), + RegistryCapture::Blocked(graph) => ( + graph.policy.clone(), + matches!( + graph.failure, + StartupFailure::InvalidPolicy | StartupFailure::InvalidConfiguration ), - }; + "graph is unavailable; an operator must apply an explicit correction or restart after fixing startup configuration", + ), + }; // Invalid policy is not equivalent to the operator choosing no policy. // Both startup failures and transitions preserve authorization before // disclosing a known graph's unavailability. @@ -555,7 +558,7 @@ pub(crate) fn resolve_registered_graph( || matches!( authorize( actor, - state.server_policy.as_deref(), + server_policy.as_deref(), PolicyRequest { action: PolicyAction::GraphList, branch: None, @@ -609,10 +612,9 @@ pub(crate) enum Authz { /// policy-evaluation error). Two sources of the policy engine: /// * Per-graph handler — passes `handle.policy.as_deref()` so the /// graph's Cedar rules govern read/change/branch_*/schema_apply. -/// * Management handler — passes `state.server_policy.as_deref()` so -/// server-level Cedar rules govern `graph_list` (the only shipped -/// server-scoped action; runtime `graph_create` / `graph_delete` -/// are deferred until a managed cluster catalog lands). +/// * Management handler — captures the current registry snapshot policy so +/// server-level Cedar rules govern management access coherently with its +/// graph inventory. /// /// The MR-731 invariant lives inside this function: actor identity is /// supplied as a separate argument from the resolved bearer match. The diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 130dfa41d..e6b2537d7 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -397,11 +397,6 @@ pub struct AppState { bearer_tokens: Arc<[(BearerTokenHash, Arc)]>, data_token_trust: Option>, oidc_identity_trust: Option>, - /// 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>, /// Bounded process-wide ownership for queued served-export bytes. The /// response body and detached producer jointly retain each reservation. export_transport: export_transport::ExportTransport, @@ -768,7 +763,6 @@ impl AppState { }, workload, bearer_tokens, - server_policy: None, data_token_trust: None, oidc_identity_trust: None, operations: operations::OperationRuntime::new(), @@ -812,6 +806,7 @@ impl AppState { ) -> std::result::Result { let bearer_tokens = hash_bearer_tokens(bearer_tokens); let registry = Arc::new(GraphRegistry::from_entries(entries)?); + registry.initialize_server_policy(server_policy.map(Arc::new)); Ok(Self { cluster_admission: None, deployments: Arc::new(deployment::DeploymentRuntime::default()), @@ -821,7 +816,6 @@ impl AppState { }, workload: Arc::new(workload), bearer_tokens, - server_policy: server_policy.map(Arc::new), data_token_trust: None, oidc_identity_trust: None, operations: operations::OperationRuntime::new(), @@ -924,14 +918,15 @@ impl AppState { { return true; } - if self.server_policy.is_some() { + let snapshot = self.routing.registry.snapshot_ref(); + if snapshot.server_policy.is_some() { return true; } // Any per-graph policy also requires auth — otherwise the // policy gate would receive unauthenticated requests. Reading // the cached `any_per_graph_policy` flag off the registry // snapshot is O(1). - self.routing.registry.snapshot_ref().any_per_graph_policy + snapshot.any_per_graph_policy } fn authenticate_bearer_token(&self, provided_token: &str) -> Option { @@ -2848,7 +2843,7 @@ async fn prepare_multi_graph_state( } } - // Server-level policy (loaded once, applies to management endpoints). + // Initial server-level policy, replaced atomically by deployment activation. // The placeholder graph_id `"server"` is the sentinel the Cedar // resource-model refactor maps to the singleton // `Omnigraph::Server::"root"` entity at evaluation time. diff --git a/crates/omnigraph-server/src/registry.rs b/crates/omnigraph-server/src/registry.rs index d66772bb5..9f1bc8b53 100644 --- a/crates/omnigraph-server/src/registry.rs +++ b/crates/omnigraph-server/src/registry.rs @@ -3,7 +3,7 @@ //! Snapshot readers retain one immutable view. Request capture and transitions //! use the same synchronous registry boundary under process admission. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use omnigraph::db::{Omnigraph, SchemaContractDigest}; @@ -111,6 +111,8 @@ impl GraphEntry { pub struct RegistrySnapshot { pub graphs: HashMap, pub any_per_graph_policy: bool, + /// Management policy published in the same snapshot as graph bindings. + pub server_policy: Option>, } impl RegistrySnapshot { @@ -119,10 +121,19 @@ impl RegistrySnapshot { Self { graphs, any_per_graph_policy, + server_policy: None, } } } +impl RegistrySnapshot { + fn with_graphs(&self, graphs: HashMap) -> Self { + let mut snapshot = Self::new(graphs); + snapshot.server_policy = self.server_policy.clone(); + snapshot + } +} + impl Default for RegistrySnapshot { fn default() -> Self { Self::new(HashMap::new()) @@ -163,6 +174,9 @@ pub enum InsertError { struct RegistryState { snapshot: Arc, + // Logical deletion removes serving admission, not native owner lifetime. + // Retain drained owners so a later adoption cannot open a second writer. + retired: HashMap>, // One active scheduling record per process registry. This record adds no // engine instance or request capacity: the graph entry retains the predecessor // after closure, including when the record expires or is abandoned. @@ -182,11 +196,17 @@ fn locked(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } +fn root_identity(uri: &str) -> Result { + omnigraph_cluster::canonical_graph_uri(uri) + .map_err(|error| ServingTransitionError::InvalidGraph(error.message)) +} + impl GraphRegistry { pub fn new() -> Self { Self { state: Mutex::new(RegistryState { snapshot: Arc::new(RegistrySnapshot::default()), + retired: HashMap::new(), candidate: None, }), } @@ -213,15 +233,56 @@ impl GraphRegistry { Ok(Self { state: Mutex::new(RegistryState { snapshot: Arc::new(RegistrySnapshot::new(graphs)), + retired: HashMap::new(), candidate: None, }), }) } + pub(crate) fn initialize_server_policy(&self, policy: Option>) { + let mut state = locked(&self.state); + let mut snapshot = RegistrySnapshot::new(state.snapshot.graphs.clone()); + snapshot.server_policy = policy; + state.snapshot = Arc::new(snapshot); + } + pub fn snapshot_ref(&self) -> Arc { Arc::clone(&locked(&self.state).snapshot) } + pub(crate) fn retained_engine( + &self, + uri: &str, + ) -> Result>, ServingTransitionError> { + Ok(locked(&self.state) + .retired + .get(&root_identity(uri)?) + .cloned()) + } + + /// Include retired owners in the bounded process inventory. No owner is + /// evicted without native settlement evidence; restart releases this cache. + pub(crate) fn validate_owner_capacity( + &self, + candidate_uris: &[String], + ) -> Result<(), ServingTransitionError> { + const MAX_RUNTIME_OWNERS: usize = 2048; + let state = locked(&self.state); + let mut roots: HashSet<_> = state.retired.keys().cloned().collect(); + for entry in state.snapshot.graphs.values() { + roots.insert(root_identity(entry.uri())?); + } + for uri in candidate_uris { + roots.insert(root_identity(uri)?); + } + if roots.len() > MAX_RUNTIME_OWNERS { + return Err(ServingTransitionError::InvalidGraph(format!( + "runtime owner limit of {MAX_RUNTIME_OWNERS} reached; restart to release retained deleted owners" + ))); + } + Ok(()) + } + pub fn get(&self, key: &GraphKey) -> RegistryLookup { match self.snapshot_ref().graphs.get(key) { Some(GraphEntry::Loading(graph)) => RegistryLookup::Loading(Arc::clone(graph)), @@ -261,14 +322,24 @@ impl GraphRegistry { /// Process admission is always acquired before the registry lock. Only /// AppState's HTTP/MCP boundary passes its actual process runtime here. + #[cfg(test)] pub(crate) fn capture( &self, operations: &OperationRuntime, key: &GraphKey, ) -> Result { + self.capture_with_policy(operations, key) + .map(|(capture, _)| capture) + } + + pub(crate) fn capture_with_policy( + &self, + operations: &OperationRuntime, + key: &GraphKey, + ) -> Result<(RegistryCapture, Option>), ApiError> { operations.while_open(|| { let state = locked(&self.state); - Ok(match state.snapshot.graphs.get(key) { + let capture = match state.snapshot.graphs.get(key) { Some(GraphEntry::Loading(graph)) => RegistryCapture::Loading(Arc::clone(graph)), Some(GraphEntry::Ready(view)) => RegistryCapture::Ready(view.capture()?), Some(GraphEntry::Transitioning(view)) => { @@ -276,7 +347,8 @@ impl GraphRegistry { } Some(GraphEntry::Blocked(graph)) => RegistryCapture::Blocked(Arc::clone(graph)), None => RegistryCapture::Gone, - }) + }; + Ok((capture, state.snapshot.server_policy.clone())) }) } @@ -303,7 +375,7 @@ impl GraphRegistry { for (pending, result) in results { graphs.insert(pending.key.clone(), result); } - state.snapshot = Arc::new(RegistrySnapshot::new(graphs)); + state.snapshot = Arc::new(state.snapshot.with_graphs(graphs)); Ok(()) }) } @@ -327,6 +399,25 @@ impl GraphRegistry { operations: &OperationRuntime, keys: &[GraphKey], deadline: Instant, + ) -> Result { + self.prepare_transition_inner(operations, keys, deadline, false) + } + + pub(crate) fn prepare_deployment_transition( + self: &Arc, + operations: &OperationRuntime, + keys: &[GraphKey], + deadline: Instant, + ) -> Result { + self.prepare_transition_inner(operations, keys, deadline, true) + } + + fn prepare_transition_inner( + self: &Arc, + operations: &OperationRuntime, + keys: &[GraphKey], + deadline: Instant, + recovery: bool, ) -> Result { operations.while_open(|| { let mut state = locked(&self.state); @@ -347,12 +438,27 @@ impl GraphRegistry { } let mut seen = std::collections::HashSet::with_capacity(keys.len()); let mut predecessors = Vec::with_capacity(keys.len()); + let mut unavailable = Vec::new(); + let mut recovering = HashSet::new(); for key in keys { if !seen.insert(key) { return Err(ServingTransitionError::InvalidBindings); } let predecessor = match state.snapshot.graphs.get(key) { - Some(GraphEntry::Ready(view)) => Arc::clone(view), + Some(GraphEntry::Ready(view)) => { + if recovery && !view.contract_is_current() { + recovering.insert(key.clone()); + } + Arc::clone(view) + } + Some(GraphEntry::Blocked(graph)) if recovery => { + unavailable.push(Arc::clone(graph)); + continue; + } + Some(GraphEntry::Transitioning(view)) if recovery => { + recovering.insert(key.clone()); + Arc::clone(view) + } Some( GraphEntry::Loading(_) | GraphEntry::Transitioning(_) @@ -362,7 +468,7 @@ impl GraphRegistry { } None => return Err(ServingTransitionError::Gone), }; - if !predecessor.contract_is_current() { + if !recovering.contains(key) && !predecessor.contract_is_current() { return Err(ServingTransitionError::SchemaChanged); } predecessor.epoch().successor()?; @@ -370,6 +476,8 @@ impl GraphRegistry { } let record = Arc::new(TransitionRecord { predecessors, + unavailable, + recovering, deadline, }); if Instant::now() >= deadline { @@ -398,11 +506,11 @@ impl GraphRegistry { } for predecessor in &record.predecessors { if !matches!(state.snapshot.graphs.get(&predecessor.key), - Some(GraphEntry::Ready(view)) if Arc::ptr_eq(view, predecessor)) + Some(GraphEntry::Ready(view) | GraphEntry::Transitioning(view)) if Arc::ptr_eq(view, predecessor)) { return Err(ServingTransitionError::StaleAttempt); } - if !predecessor.contract_is_current() { + if !record.recovering.contains(&predecessor.key) && !predecessor.contract_is_current() { return Err(ServingTransitionError::SchemaChanged); } } @@ -413,7 +521,7 @@ impl GraphRegistry { GraphEntry::Transitioning(Arc::clone(predecessor)), ); } - let snapshot = Arc::new(RegistrySnapshot::new(graphs)); + let snapshot = Arc::new(state.snapshot.with_graphs(graphs)); if Instant::now() >= record.deadline { return Err(ServingTransitionError::DeadlineElapsed); } @@ -471,11 +579,14 @@ impl GraphRegistry { let mut graphs = state.snapshot.graphs.clone(); let mut epochs = HashMap::with_capacity(record.predecessors.len()); for predecessor in &record.predecessors { + if record.recovering.contains(&predecessor.key) { + continue; + } if !matches!(graphs.get(&predecessor.key), Some(GraphEntry::Transitioning(view)) if Arc::ptr_eq(view, predecessor)) { return Err(ServingTransitionError::StaleAttempt); } - if !predecessor.contract_is_current() { + if !record.recovering.contains(&predecessor.key) && !predecessor.contract_is_current() { return Err(ServingTransitionError::SchemaChanged); } let epoch = predecessor.epoch().successor()?; @@ -485,11 +596,11 @@ impl GraphRegistry { ); epochs.insert(predecessor.key.clone(), epoch); } - let snapshot = Arc::new(RegistrySnapshot::new(graphs)); + let snapshot = Arc::new(state.snapshot.with_graphs(graphs)); if record .predecessors .iter() - .any(|view| !view.contract_is_current()) + .any(|view| !record.recovering.contains(&view.key) && !view.contract_is_current()) { return Err(ServingTransitionError::SchemaChanged); } @@ -523,6 +634,7 @@ impl GraphRegistry { /// Compile the complete replacement set before entering the short final /// publication boundary. The exact engine contract is checked both before /// compilation and again by activate, so no query registry crosses schemas. + #[cfg(test)] pub(crate) fn validate_activation( &self, record: &Arc, @@ -583,49 +695,148 @@ impl GraphRegistry { Ok(views) } + pub(crate) fn validate_deployment_activation( + &self, + record: &Arc, + handles: Vec<(Arc, SchemaContractDigest)>, + ) -> Result>, ServingTransitionError> { + self.check_drained(record)?; + handles + .into_iter() + .map(|(handle, contract)| { + let (_, handle) = canonicalize_handle_uri(handle) + .map_err(|error| ServingTransitionError::InvalidGraph(error.to_string()))?; + if contract != handle.engine.schema_contract_digest() { + return Err(ServingTransitionError::SchemaChanged); + } + if let Some(queries) = &handle.queries { + crate::validate_registry_against_catalog( + queries, + &handle.engine.catalog(), + handle.key.graph_id.as_str(), + ) + .map_err(|error| ServingTransitionError::InvalidQueries(error.to_string()))?; + } + let epoch = match record.predecessors.iter().find(|old| old.key == handle.key) { + Some(old) => old.epoch().successor()?, + None => ServingEpoch::INITIAL, + }; + Ok(Arc::new(ServingView::new(handle, epoch))) + }) + .collect() + } + pub(crate) fn activate( &self, record: &Arc, views: Vec>, + ) -> Result, ServingTransitionError> { + self.activate_deployment( + record, + views, + HashSet::new(), + Vec::new(), + self.snapshot_ref().server_policy.clone(), + ) + } + + pub(crate) fn activate_deployment( + &self, + record: &Arc, + views: Vec>, + removals: HashSet, + unavailable: Vec>, + server_policy: Option>, ) -> Result, ServingTransitionError> { let mut state = locked(&self.state); validate_closed(&state, record)?; let mut graphs = state.snapshot.graphs.clone(); + let mut retired = state.retired.clone(); let mut epochs = HashMap::with_capacity(views.len()); + for key in &removals { + if !record.predecessors.iter().any(|old| &old.key == key) + && !record.unavailable.iter().any(|old| &old.key == key) + { + return Err(ServingTransitionError::InvalidBindings); + } + if let Some(GraphEntry::Transitioning(view)) = graphs.remove(key) { + retired.insert(root_identity(&view.uri)?, Arc::clone(&view.engine)); + } + } for view in &views { - if epochs.insert(view.key.clone(), view.epoch()).is_some() { + let root = root_identity(&view.uri)?; + if removals.contains(&view.key) + || epochs.insert(view.key.clone(), view.epoch()).is_some() + { return Err(ServingTransitionError::InvalidBindings); } if let Some(predecessor) = record.predecessors.iter().find(|old| old.key == view.key) { - if !Arc::ptr_eq(&predecessor.engine, &view.engine) - || predecessor.uri != view.uri + if !predecessor.engine.shares_runtime_owner(&view.engine) + || root_identity(&predecessor.uri)? != root || view.epoch() != predecessor.epoch().successor()? { return Err(ServingTransitionError::InvalidBindings); } + } else if let Some(blocked) = record.unavailable.iter().find(|old| old.key == view.key) + { + if root_identity(&blocked.uri)? != root || view.epoch() != ServingEpoch::INITIAL { + return Err(ServingTransitionError::InvalidBindings); + } } else if graphs.contains_key(&view.key) { return Err(ServingTransitionError::InvalidGraph( InsertError::DuplicateKey(view.key.clone()).to_string(), )); } - if graphs - .values() - .any(|entry| entry.key() != &view.key && entry.uri() == view.uri) - { - return Err(ServingTransitionError::InvalidGraph( - InsertError::DuplicateUri(view.uri.clone()).to_string(), - )); + if let Some(retained) = retired.remove(&root) { + if !retained.shares_runtime_owner(&view.engine) { + return Err(ServingTransitionError::InvalidBindings); + } + } + for entry in graphs.values() { + if entry.key() != &view.key && root_identity(entry.uri())? == root { + return Err(ServingTransitionError::InvalidGraph( + InsertError::DuplicateUri(view.uri.clone()).to_string(), + )); + } } graphs.insert(view.key.clone(), GraphEntry::Ready(Arc::clone(view))); } + let mut unavailable_keys = HashSet::new(); + for graph in unavailable { + let root = root_identity(&graph.uri)?; + if removals.contains(&graph.key) + || !unavailable_keys.insert(graph.key.clone()) + || epochs.contains_key(&graph.key) + || record.predecessors.iter().any(|old| old.key == graph.key) + { + return Err(ServingTransitionError::InvalidBindings); + } + for entry in graphs.values() { + if entry.key() != &graph.key && root_identity(entry.uri())? == root { + return Err(ServingTransitionError::InvalidBindings); + } + } + if let Some(existing) = graphs.get(&graph.key) { + if !record.unavailable.iter().any(|old| { + old.key == graph.key + && root_identity(&old.uri).is_ok_and(|old_root| old_root == root) + }) || !matches!(existing, GraphEntry::Blocked(_)) + { + return Err(ServingTransitionError::InvalidBindings); + } + } + graphs.insert(graph.key.clone(), GraphEntry::Blocked(graph)); + } if record .predecessors .iter() - .any(|view| !epochs.contains_key(&view.key)) + .any(|view| !epochs.contains_key(&view.key) && !removals.contains(&view.key)) { return Err(ServingTransitionError::InvalidBindings); } - let snapshot = Arc::new(RegistrySnapshot::new(graphs)); + let mut snapshot = state.snapshot.with_graphs(graphs); + snapshot.server_policy = server_policy; + let snapshot = Arc::new(snapshot); if views.iter().any(|view| !view.contract_is_current()) { return Err(ServingTransitionError::SchemaChanged); } @@ -633,6 +844,7 @@ impl GraphRegistry { return Err(ServingTransitionError::DeadlineElapsed); } state.snapshot = snapshot; + state.retired = retired; state.candidate = None; Ok(epochs) } @@ -655,7 +867,7 @@ impl GraphRegistry { } let mut graphs = state.snapshot.graphs.clone(); graphs.insert(handle.key.clone(), GraphEntry::ready(handle)); - state.snapshot = Arc::new(RegistrySnapshot::new(graphs)); + state.snapshot = Arc::new(state.snapshot.with_graphs(graphs)); Ok(()) } } @@ -671,6 +883,12 @@ fn validate_candidate( { return Err(ServingTransitionError::StaleAttempt); } + if record.unavailable.iter().any(|old| { + !matches!(state.snapshot.graphs.get(&old.key), + Some(GraphEntry::Blocked(current)) if Arc::ptr_eq(old, current)) + }) { + return Err(ServingTransitionError::StaleAttempt); + } if Instant::now() >= record.deadline { return Err(ServingTransitionError::DeadlineElapsed); } @@ -1407,6 +1625,145 @@ mod tests { captured(®istry, &operations, &peer.key).epoch(), peer_epoch ); + + // A completed/expired deployment may leave an exact closed view. Its + // successor retains request owners and can repair the achieved contract + // without reopening a second engine or reviving stale admission. + let retained = captured(®istry, &operations, &beta.key); + let stopped = registry + .prepare_transition( + &operations, + std::slice::from_ref(&beta.key), + transition_deadline(), + ) + .unwrap() + .close() + .unwrap(); + drop(stopped); + let attempted = registry + .prepare_deployment_transition( + &operations, + std::slice::from_ref(&beta.key), + transition_deadline(), + ) + .unwrap() + .close() + .unwrap(); + assert!(attempted.abort_before_effects().unwrap().is_empty()); + assert!(matches!( + registry.get(&beta.key), + RegistryLookup::Transitioning(_) + )); + + let blocked_handle = build_handle("recoverable", dir.path()).await; + { + let mut state = locked(®istry.state); + let mut graphs = state.snapshot.graphs.clone(); + graphs.insert( + blocked_handle.key.clone(), + GraphEntry::Blocked(Arc::new(BlockedGraph { + key: blocked_handle.key.clone(), + uri: blocked_handle.uri.clone(), + policy: None, + failure: StartupFailure::OpenFailed, + })), + ); + state.snapshot = Arc::new(state.snapshot.with_graphs(graphs)); + } + let transition = registry + .prepare_deployment_transition( + &operations, + &[ + alpha.key.clone(), + beta.key.clone(), + blocked_handle.key.clone(), + ], + transition_deadline(), + ) + .unwrap() + .close() + .unwrap(); + { + let waiting = transition.wait_requests(); + tokio::pin!(waiting); + assert!(futures::poll!(waiting.as_mut()).is_pending()); + drop(retained); + waiting.await.unwrap(); + } + beta.engine.apply_schema("// accepted during deployment\nnode Person { name: String @key nickname: String? }\n").await.unwrap(); + let rebound = Arc::new(GraphHandle { + key: beta.key.clone(), + uri: beta.uri.clone(), + policy: None, + queries: None, + engine: Arc::new( + beta.engine + .with_runtime_bindings(None, None, Default::default()) + .unwrap(), + ), + }); + let management = Arc::new(PolicyEngine::load_cluster_from_source( + "version: 1\ngroups:\n admins: [successor]\nrules:\n - id: admin\n allow:\n actors: {group: admins}\n actions: [config_manage]\n").unwrap()); + let previous = registry.snapshot_ref(); + transition + .activate_deployment( + vec![ + (rebound, beta.engine.schema_contract_digest()), + ( + Arc::clone(&blocked_handle), + blocked_handle.engine.schema_contract_digest(), + ), + ], + HashSet::from([alpha.key.clone()]), + Vec::new(), + Some(Arc::clone(&management)), + ) + .unwrap(); + let activated = registry.snapshot_ref(); + assert!(Arc::ptr_eq( + activated.server_policy.as_ref().unwrap(), + &management + )); + assert!(previous.server_policy.is_none()); + assert!(previous.graphs.contains_key(&alpha.key)); + assert!(!activated.graphs.contains_key(&alpha.key)); + assert!(matches!( + registry.get(&blocked_handle.key), + RegistryLookup::Ready(_) + )); + let beta_current = captured(®istry, &operations, &beta.key); + assert!(beta_current.engine.shares_runtime_owner(&beta.engine)); + assert_eq!( + beta_current.schema_contract(), + &beta.engine.schema_contract_digest() + ); + assert!( + registry + .retained_engine(&alpha.uri) + .unwrap() + .unwrap() + .shares_runtime_owner(&alpha.engine) + ); + // Four active roots plus the deleted alpha owner still consume five slots. + let mut candidates: Vec<_> = (0..2043) + .map(|index| { + dir.path() + .join(format!("future-{index}")) + .to_string_lossy() + .into_owned() + }) + .collect(); + registry.validate_owner_capacity(&candidates).unwrap(); + candidates.push( + dir.path() + .join("one-too-many") + .to_string_lossy() + .into_owned(), + ); + assert!(matches!( + registry.validate_owner_capacity(&candidates), + Err(ServingTransitionError::InvalidGraph(_)) + )); } #[tokio::test] @@ -1963,7 +2320,7 @@ mod tests { ServingEpoch::MAX, ))), ); - state.snapshot = Arc::new(RegistrySnapshot::new(graphs)); + state.snapshot = Arc::new(state.snapshot.with_graphs(graphs)); } assert!(matches!( registry.prepare_same_view(&operations, &alpha.key, transition_deadline()), diff --git a/crates/omnigraph-server/src/serving.rs b/crates/omnigraph-server/src/serving.rs index 1b9e1407b..77d9c760f 100644 --- a/crates/omnigraph-server/src/serving.rs +++ b/crates/omnigraph-server/src/serving.rs @@ -17,8 +17,9 @@ use tokio::time::Instant; use crate::ApiError; use crate::identity::GraphKey; use crate::operations::OperationRuntime; +#[cfg(test)] use crate::queries::QueryRegistry; -use crate::registry::{GraphHandle, GraphRegistry}; +use crate::registry::{BlockedGraph, GraphHandle, GraphRegistry}; /// A non-reusable epoch within one registered graph's lifetime. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -37,7 +38,7 @@ impl ServingEpoch { } } -/// Immutable bindings for one epoch. Changed bindings retain the same engine; +/// Immutable bindings for one epoch. Changed bindings retain the engine owner; /// a view is not an engine snapshot or a native settlement proof. pub struct ServingView { handle: Arc, @@ -256,6 +257,8 @@ impl From for ServingTransitionError { /// Arc identity fences stale tickets without a second persistent identity. pub(crate) struct TransitionRecord { pub(crate) predecessors: Vec>, + pub(crate) unavailable: Vec>, + pub(crate) recovering: std::collections::HashSet, pub(crate) deadline: Instant, } @@ -324,12 +327,9 @@ impl GraphTransition { ) -> Result>, ServingTransitionError> { self.operations.while_open(|| { self.registry.check_drained(&self.record)?; - if self - .record - .predecessors - .iter() - .any(|view| !view.contract_is_current()) - { + if self.record.predecessors.iter().any(|view| { + !self.record.recovering.contains(&view.key) && !view.contract_is_current() + }) { return Err(ServingTransitionError::SchemaChanged); } Ok(self @@ -344,6 +344,7 @@ impl GraphTransition { /// Publish complete query/schema bindings only after the deployment /// controller has recorded their durable achieved state. All replacement /// engines and policies stay identical; additions are already durable graphs. + #[cfg(test)] pub(crate) fn activate( self, bindings: HashMap, @@ -356,6 +357,28 @@ impl GraphTransition { .while_open(|| self.registry.activate(&self.record, replacements)) } + /// Publish graph lifecycle and authorization changes in one registry snapshot. + pub(crate) fn activate_deployment( + self, + handles: Vec<(Arc, SchemaContractDigest)>, + removals: std::collections::HashSet, + unavailable: Vec>, + server_policy: Option>, + ) -> Result, ServingTransitionError> { + let views = self + .registry + .validate_deployment_activation(&self.record, handles)?; + self.operations.while_open(|| { + self.registry.activate_deployment( + &self.record, + views, + removals, + unavailable, + server_policy, + ) + }) + } + /// The controller attests no deployment effect began. Reopen only these /// exact unchanged predecessors even if drain timed out. Old descendants /// remain counted by subsequent transitions. Shutdown and stale tickets diff --git a/crates/omnigraph-server/src/settings.rs b/crates/omnigraph-server/src/settings.rs index 5ab3f2344..7ca8332b0 100644 --- a/crates/omnigraph-server/src/settings.rs +++ b/crates/omnigraph-server/src/settings.rs @@ -84,6 +84,34 @@ pub(crate) fn settings_from_snapshot( cli_allow_unauthenticated: bool, cli_require_all_graphs: bool, snapshot: omnigraph_cluster::ServingSnapshot, +) -> Result { + settings_from_snapshot_inner( + cluster_dir, + cli_bind, + cli_allow_unauthenticated, + cli_require_all_graphs, + snapshot, + false, + ) +} + +/// Project a settled deployment's achieved inventory, including unavailable +/// graphs. Startup's all-graphs-ready environment setting does not veto an +/// already-settled management-policy handoff. +pub(crate) fn deployment_settings_from_snapshot( + cluster_dir: &Path, + snapshot: omnigraph_cluster::ServingSnapshot, +) -> Result { + settings_from_snapshot_inner(cluster_dir, None, false, false, snapshot, true) +} + +fn settings_from_snapshot_inner( + cluster_dir: &Path, + cli_bind: Option, + cli_allow_unauthenticated: bool, + cli_require_all_graphs: bool, + snapshot: omnigraph_cluster::ServingSnapshot, + deployment: bool, ) -> Result { for diagnostic in &snapshot.diagnostics { warn!( @@ -94,7 +122,7 @@ pub(crate) fn settings_from_snapshot( ); } let env_require_all_graphs = env_flag("OMNIGRAPH_REQUIRE_ALL_GRAPHS"); - let require_all_graphs = cli_require_all_graphs || env_require_all_graphs; + let require_all_graphs = !deployment && (cli_require_all_graphs || env_require_all_graphs); // Boot provenance is independent of the complete runtime graph inventory. let witness = BootWitness { booted_serving_digest: snapshot.config_digest.clone(), @@ -236,7 +264,8 @@ pub(crate) fn settings_from_snapshot( }); } graphs.sort_by(|a, b| a.graph_id.cmp(&b.graph_id)); - if graphs.iter().all(|graph| graph.startup_failure.is_some()) + if !deployment + && graphs.iter().all(|graph| graph.startup_failure.is_some()) && !snapshot.applied_graphs.is_empty() { let skipped = skipped_graphs.join(", "); diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index 9a552b11d..850b8c427 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -1282,7 +1282,7 @@ rules: assert_eq!(body["graphs"][1]["graph_id"], "beta"); assert_eq!(body["graphs"][1]["state"], "blocked"); assert_eq!(body["graphs"][1]["failure"], "open_failed"); - assert_eq!(body["graphs"][1]["action"], "restart_after_correction"); + assert_eq!(body["graphs"][1]["action"], "apply_correction_or_restart"); assert_eq!(body["graphs"][1]["read_available"], false); assert_eq!(body["graphs"][1]["write_available"], false); assert!(body.get("quarantined").is_none()); @@ -2320,7 +2320,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i let RegistryLookup::Ready(current) = state.routing().registry.get(&key) else { panic!("ready") }; - assert!(Arc::ptr_eq(&original_engine, ¤t.engine)); + assert!(original_engine.shares_runtime_owner(¤t.engine)); assert_ne!(current.engine.schema_contract_digest(), original_contract); let committed_head = original_engine.list_commits(None).await.unwrap()[0] .graph_commit_id @@ -2355,4 +2355,383 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i assert_eq!(old["active"], false); assert_eq!(original_engine.schema_contract_digest(), latest_contract); assert!(!state.operation_runtime().snapshot().closed); + + // Unrelated runtime payload loss cannot veto another graph's cutover. + // The already-admitted knowledge view retains its verified query source. + let ledger: serde_json::Value = + serde_json::from_slice(&fs::read(temp.path().join("__cluster/state.json")).unwrap()) + .unwrap(); + let query_digest = + ledger["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"] + .as_str() + .unwrap(); + let catalog_query = temp.path().join(format!( + "__cluster/resources/query/knowledge/find_person/{query_digest}.gq" + )); + let source = fs::read(&catalog_query).unwrap(); + fs::remove_file(&catalog_query).unwrap(); + fs::write( + temp.path().join("peer.pg"), + "node Peer { name: String @key note: String? }\n", + ) + .unwrap(); + let independent = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (next, _) = status(&app, None).await; + let (code, changed) = json_response( + &app, + submit(&next.next_deployment_id(), &independent, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::OK, "{changed}"); + assert_eq!(changed["active"], true); + assert!( + !catalog_query.exists(), + "unrelated corruption must not be silently repaired" + ); + assert!(!state.operation_runtime().snapshot().closed); + fs::write(catalog_query, source).unwrap(); + + // Recreation cannot dispose a retained native owner, even after graph + // admission drains. Refuse before recording an operation or touching data. + let recreate = omnigraph_cluster::capture_deployment_with_options( + temp.path(), + &omnigraph_cluster::DeploymentOptions { + recreate_graphs: BTreeMap::from([("knowledge".to_owned(), latest_contract.clone())]), + ..Default::default() + }, + ) + .unwrap(); + let (next, _) = status(&app, None).await; + let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let (code, refused) = json_response( + &app, + submit(&next.next_deployment_id(), &recreate, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::CONFLICT, "{refused}"); + assert!( + refused["error"] + .as_str() + .unwrap() + .contains("retains a runtime owner") + ); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + ledger_before + ); + assert!(matches!( + state.routing().registry.get(&key), + RegistryLookup::Ready(_) + )); + + // Existing policies can grant and revoke writes on the same runtime. + // Candidate authorization cannot grant its submitter permission to apply. + let old_policy = fs::read_to_string(temp.path().join("policy.yaml")).unwrap(); + let old_management = fs::read_to_string(temp.path().join("cluster-policy.yaml")).unwrap(); + let granted = old_policy.replace("actions: [read]", "actions: [read, change]"); + fs::write(temp.path().join("policy.yaml"), &granted).unwrap(); + fs::write( + temp.path().join("cluster-policy.yaml"), + old_management.replace("[operator]", "[reader]"), + ) + .unwrap(); + let denied_candidate = + omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (next, _) = status(&app, None).await; + let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let (code, _) = json_response( + &app, + submit( + &next.next_deployment_id(), + &denied_candidate, + "reader-token", + ), + ) + .await; + assert_eq!(code, StatusCode::FORBIDDEN); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + ledger_before + ); + fs::write(temp.path().join("cluster-policy.yaml"), &old_management).unwrap(); + let grant = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (code, body) = json_response( + &app, + submit(&next.next_deployment_id(), &grant, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(body["active"], true); + + fn change(body: Body) -> Request { + Request::post("/graphs/knowledge/change") + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .header("authorization", "Bearer reader-token") + .header("content-type", "application/json") + .body(body) + .unwrap() + } + let mutation = serde_json::json!({"query":"query add() { insert Person { name: \"Granted\" } }", "name":"add"}); + let (code, body) = json_response(&app, change(Body::from(mutation.to_string()))).await; + assert_eq!(code, StatusCode::OK, "{body}"); + + // A revocation waits for a request admitted under the granted policy. New + // captures close immediately; after cutover neither HTTP nor engine bypass + // can use the old grant. + let (polled, body_polled) = tokio::sync::oneshot::channel(); + let (release, body_released) = tokio::sync::oneshot::channel(); + let parked = change(Body::from_stream(futures::stream::once(async move { + polled.send(()).unwrap(); + body_released.await.unwrap(); + Ok::<_, std::io::Error>(axum::body::Bytes::from_static( + br#"{"query":"query add() { insert Person { name: \"BeforeRevocation\" } }","name":"add"}"#, + )) + }))); + let request_app = app.clone(); + let writer = tokio::spawn(async move { json_response(&request_app, parked).await }); + tokio::time::timeout(Duration::from_secs(10), body_polled) + .await + .unwrap() + .unwrap(); + fs::write(temp.path().join("policy.yaml"), &old_policy).unwrap(); + let revoke = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (next, _) = status(&app, None).await; + let revoke_request = submit(&next.next_deployment_id(), &revoke, "operator-token"); + let request_app = app.clone(); + let revoker = tokio::spawn(async move { json_response(&request_app, revoke_request).await }); + tokio::time::timeout(Duration::from_secs(10), async { + while !matches!( + state.routing().registry.get(&key), + RegistryLookup::Transitioning(_) + ) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!( + !revoker.is_finished(), + "revocation must await admitted requests" + ); + let (code, _) = json_response(&app, change(Body::from(mutation.to_string()))).await; + assert_eq!(code, StatusCode::SERVICE_UNAVAILABLE); + release.send(()).unwrap(); + let (code, body) = writer.await.unwrap(); + assert_eq!(code, StatusCode::OK, "{body}"); + let (code, body) = revoker.await.unwrap(); + assert_eq!(code, StatusCode::OK, "{body}"); + let (code, _) = json_response(&app, change(Body::from(mutation.to_string()))).await; + assert_eq!(code, StatusCode::FORBIDDEN); + let RegistryLookup::Ready(revoked) = state.routing().registry.get(&key) else { + panic!("ready") + }; + assert!(revoked.engine.shares_runtime_owner(&original_engine)); + let engine = omnigraph::Session::from_defaults(Arc::clone(&revoked.engine), Default::default()); + assert!(matches!( + engine + .mutate_as( + "main", + "query add() { insert Person { name: \"EngineBypass\" } }", + "add", + &Default::default(), + Some("reader") + ) + .await, + Err(omnigraph::error::OmniError::Policy(_)) + )); + + // Provider definitions, graph selection and Blob allowlists are runtime + // settings. Updating and removing them keeps data/history and writer owner. + let base_config = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap(); + for model in ["first-model", "second-model"] { + let bound = base_config.replace(" schema: ./people.pg\n", " schema: ./people.pg\n embedding_provider: selected\n external_blobs:\n allow:\n - base: s3://lifecycle-assets/allowed\n scope: server_safe\n"); + fs::write(temp.path().join("cluster.yaml"), format!("{bound}providers:\n embedding:\n selected:\n kind: mock\n model: {model}\n")).unwrap(); + let candidate = + omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (next, _) = status(&app, None).await; + let (code, body) = json_response( + &app, + submit(&next.next_deployment_id(), &candidate, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(body["active"], true); + let RegistryLookup::Ready(view) = state.routing().registry.get(&key) else { + panic!("ready") + }; + assert!(view.engine.shares_runtime_owner(&original_engine)); + } + // An invalid replacement cannot remove the currently working bindings. + let provider_config = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap(); + let broken = provider_config.replace( + "kind: mock", + &format!("kind: openai-compatible\n api_key: ${{{missing_key}}}"), + ); + fs::write(temp.path().join("cluster.yaml"), broken).unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (next, _) = status(&app, None).await; + let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let RegistryLookup::Ready(before) = state.routing().registry.get(&key) else { + panic!("ready") + }; + let (code, _) = json_response( + &app, + submit(&next.next_deployment_id(), &candidate, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::CONFLICT); + let RegistryLookup::Ready(after) = state.routing().registry.get(&key) else { + panic!("ready") + }; + assert!(Arc::ptr_eq(&before, &after)); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + ledger_before + ); + fs::write(temp.path().join("cluster.yaml"), &base_config).unwrap(); + let removed_bindings = + omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (code, body) = json_response( + &app, + submit( + &next.next_deployment_id(), + &removed_bindings, + "operator-token", + ), + ) + .await; + assert_eq!(code, StatusCode::OK, "{body}"); + + // Explicit removal hides the graph; exact adoption restores its data and + // history rather than recreating it. Both activate without a restart. + let peer_key = GraphKey::cluster(GraphId::try_from("peer").unwrap()); + let RegistryLookup::Ready(peer) = state.routing().registry.get(&peer_key) else { + panic!("ready") + }; + let confirmation = omnigraph_cluster::GraphLifecycleConfirmation { + contract: peer.engine.schema_contract_digest(), + graph_manifest_version: peer + .engine + .snapshot_of(omnigraph::db::ReadTarget::branch("main")) + .await + .unwrap() + .graph_manifest_version(), + }; + let peer_owner = Arc::clone(&peer.engine); + let removed = base_config + .replace(" peer:\n schema: ./peer.pg\n", "") + .replace("[knowledge, peer]", "[knowledge]"); + fs::write(temp.path().join("cluster.yaml"), &removed).unwrap(); + let options = omnigraph_cluster::DeploymentOptions { + delete_graphs: BTreeMap::from([("peer".into(), confirmation.clone())]), + ..Default::default() + }; + let candidate = + omnigraph_cluster::capture_deployment_with_options(temp.path(), &options).unwrap(); + let (next, _) = status(&app, None).await; + let (code, body) = json_response( + &app, + submit(&next.next_deployment_id(), &candidate, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(body["active"], true); + assert!(matches!( + state.routing().registry.get(&peer_key), + RegistryLookup::Gone + )); + assert!(temp.path().join("graphs/peer.omni").exists()); + fs::write(temp.path().join("cluster.yaml"), &base_config).unwrap(); + let options = omnigraph_cluster::DeploymentOptions { + adopt_graphs: BTreeMap::from([("peer".into(), confirmation.clone())]), + ..Default::default() + }; + let candidate = + omnigraph_cluster::capture_deployment_with_options(temp.path(), &options).unwrap(); + let (next, _) = status(&app, None).await; + // A read-only retained-owner check can fail before deployment acceptance. + // Temporarily hide its manifest to exercise the storage failure boundary. + let manifest = temp.path().join("graphs/peer.omni/__manifest"); + let held_manifest = temp.path().join("held-peer-manifest"); + fs::rename(&manifest, &held_manifest).unwrap(); + let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let (code, refused) = json_response( + &app, + submit(&next.next_deployment_id(), &candidate, "operator-token"), + ) + .await; + fs::rename(held_manifest, manifest).unwrap(); + assert!(!code.is_success(), "{refused}"); + assert!( + !state.operation_runtime().snapshot().closed, + "preflight read failure must not contain the process: {refused}" + ); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + ledger_before + ); + let (code, body) = json_response( + &app, + submit(&next.next_deployment_id(), &candidate, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(body["active"], true); + let RegistryLookup::Ready(peer) = state.routing().registry.get(&peer_key) else { + panic!("ready") + }; + assert!(peer_owner.shares_runtime_owner(&peer.engine)); + assert_eq!(peer.engine.schema_contract_digest(), confirmation.contract); + assert_eq!( + peer.engine + .snapshot_of(omnigraph::db::ReadTarget::branch("main")) + .await + .unwrap() + .graph_manifest_version(), + confirmation.graph_manifest_version + ); + + // Management-policy handoff takes effect on all cloned routers. The old + // administrator cannot deploy or list; the newly granted one can observe. + fs::write( + temp.path().join("cluster-policy.yaml"), + format!( + "{} - id: inventory\n allow:\n actors: {{ group: admins }}\n actions: [graph_list]\n", + old_management.replace("[operator]", "[reader]") + ), + ) + .unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let (next, _) = status(&app, None).await; + let (code, body) = json_response( + &app, + submit(&next.next_deployment_id(), &candidate, "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(body["active"], true); + assert_eq!( + json_response(&app, get_request("/cluster/deployments", "operator-token")) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + json_response(&app, get_request("/cluster/deployments", "reader-token")) + .await + .0, + StatusCode::OK + ); + assert_eq!( + json_response(&app, get_request("/graphs", "operator-token")) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + json_response(&app, get_request("/graphs", "reader-token")) + .await + .0, + StatusCode::OK + ); } diff --git a/crates/omnigraph/src/db/omnigraph.rs b/crates/omnigraph/src/db/omnigraph.rs index ebf551a8a..c399aa910 100644 --- a/crates/omnigraph/src/db/omnigraph.rs +++ b/crates/omnigraph/src/db/omnigraph.rs @@ -896,6 +896,49 @@ impl Omnigraph { self } + /// Prepare an immutable runtime view over this handle's existing owner. + /// + /// No storage is opened or written. The coordinator, schema authority, + /// writer gates and Lance sessions remain shared; policy, embedding client + /// and external Blob admission belong to the returned view. A serving + /// caller must drain the old view before admitting requests on this one. + /// Existing views deliberately retain their original authorization. + pub fn with_runtime_bindings( + &self, + policy: Option>, + embedding_config: Option>, + external_blob_policy: crate::blob::ExternalBlobPolicy, + ) -> Result { + let table_store = self + .table_store + .clone() + .with_external_blob_policy(external_blob_policy)?; + Ok(Self { + root_uri: self.root_uri.clone(), + storage: Arc::clone(&self.storage), + lance_access: self.lance_access.clone(), + coordinator: Arc::clone(&self.coordinator), + table_store, + runtime_cache: RuntimeCache::default(), + feed_cut_cache: tokio::sync::RwLock::new(None), + read_caches: Arc::clone(&self.read_caches), + schema_view: Arc::clone(&self.schema_view), + write_queue: Arc::clone(&self.write_queue), + merge_authority_cache: tokio::sync::Mutex::new(None), + history: self.history.clone(), + policy, + embedding: Arc::new(tokio::sync::OnceCell::new()), + embedding_config, + }) + } + + /// Whether two immutable runtime views share the same engine owner. + /// Equal root strings alone do not establish this relationship. + pub fn shares_runtime_owner(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.coordinator, &other.coordinator) + && Arc::ptr_eq(&self.write_queue, &other.write_queue) + } + /// The injected embedding config, if any (see the `embedding_config` field). pub(crate) fn embedding_config_ref(&self) -> Option<&crate::embedding::EmbeddingConfig> { self.embedding_config.as_deref() diff --git a/crates/omnigraph/tests/end_to_end.rs b/crates/omnigraph/tests/end_to_end.rs index 41c49bd61..f53841753 100644 --- a/crates/omnigraph/tests/end_to_end.rs +++ b/crates/omnigraph/tests/end_to_end.rs @@ -2295,8 +2295,26 @@ async fn assert_graph_root_refuses_overlapping_bases( matches!(error, OmniError::ExternalBlobPolicy { .. }), "unexpected error: {error}" ); - drop(db); + assert_eq!(graph_state(graph_uri.clone()).await, before); + let payload = external.join("content.bin"); + std::fs::write(&payload, [1_u8, 2, 3]).unwrap(); + let row = serde_json::json!({"type":"Document", "data": { + "title":"external", "content":url::Url::from_file_path(&payload).unwrap().to_string() + }}) + .to_string(); + let denied = helpers::session( + db.with_runtime_bindings(None, None, ExternalBlobPolicy::Deny) + .unwrap(), + ); + assert!(db.shares_runtime_owner(&denied)); + assert!(matches!( + denied.load_jsonl(&row, LoadMode::Merge).await.unwrap_err(), + OmniError::ExternalBlobPolicy { .. } + )); assert_eq!(graph_state(graph_uri).await, before); + // The old admitted view keeps its allow-list; the replacement denies new + // external ingestion without changing the old policy or graph ownership. + db.load_jsonl(&row, LoadMode::Merge).await.unwrap(); } // ─── Regression: blob load with external file URI ──────────────────────────── diff --git a/crates/omnigraph/tests/policy_engine_chassis.rs b/crates/omnigraph/tests/policy_engine_chassis.rs index 86051314e..81f529c5f 100644 --- a/crates/omnigraph/tests/policy_engine_chassis.rs +++ b/crates/omnigraph/tests/policy_engine_chassis.rs @@ -221,6 +221,68 @@ async fn apply_schema_as_allows_when_policy_permits_actor() { ); } +#[tokio::test] +async fn runtime_binding_views_share_engine_authority_and_keep_policy_immutable() { + let dir = tempfile::tempdir().unwrap(); + let (original, _) = init_with_policy(&dir).await; + let replacement_source = POLICY_YAML.replace("act-allowed", "act-replacement"); + let policy = Arc::new( + PolicyEngine::load_graph_from_source(&replacement_source, dir.path().to_str().unwrap()) + .unwrap(), + ); + let replacement = original + .with_runtime_bindings( + Some(policy as Arc), + None, + omnigraph::ExternalBlobPolicy::Deny, + ) + .unwrap(); + assert!(original.shares_runtime_owner(&replacement)); + let before = original.list_commits(None).await.unwrap(); + let desired = additive_schema(); + assert_denied( + replacement + .prepare_schema_apply_as(&desired, Some("act-allowed")) + .await, + "replacement revokes original actor", + ); + original + .prepare_schema_apply_as(&desired, Some("act-allowed")) + .await + .unwrap(); + assert_eq!(original.list_commits(None).await.unwrap(), before); + replacement + .apply_schema_as(&desired, Some("act-replacement")) + .await + .unwrap(); + assert_eq!( + original.schema_contract_digest(), + replacement.schema_contract_digest() + ); + assert_eq!( + original.list_commits(None).await.unwrap(), + replacement.list_commits(None).await.unwrap() + ); + let reopened = Omnigraph::open_read_only(dir.path().to_str().unwrap()) + .await + .unwrap(); + assert!(!original.shares_runtime_owner(&reopened)); + let unprotected = replacement + .with_runtime_bindings(None, None, omnigraph::ExternalBlobPolicy::Deny) + .unwrap(); + assert!(replacement.shares_runtime_owner(&unprotected)); + unprotected + .prepare_schema_apply_as(&desired, None) + .await + .unwrap(); + assert_denied( + replacement + .prepare_schema_apply_as(&desired, Some("act-allowed")) + .await, + "removing policy from a new view leaves predecessor immutable", + ); +} + #[tokio::test] async fn apply_schema_without_actor_when_policy_is_installed_denies() { // MR-722 footgun guard: if a PolicyChecker is installed AND the diff --git a/crates/omnigraph/tests/search.rs b/crates/omnigraph/tests/search.rs index 267a29852..6e9c59ca7 100644 --- a/crates/omnigraph/tests/search.rs +++ b/crates/omnigraph/tests/search.rs @@ -2002,6 +2002,45 @@ async fn injected_embedding_config_is_used_instead_of_env() { .unwrap(); assert_eq!(result_slugs(&result)[0], "alpha-doc"); + + // Warmed clients stay immutable: replacing a provider must not reuse the + // old view's initialized embedding client or alter its same-space check. + let replacement = session( + db.with_runtime_bindings( + None, + Some(std::sync::Arc::new(omnigraph::embedding::EmbeddingConfig { + provider: omnigraph::embedding::Provider::Mock, + model: "test-model-b".to_string(), + base_url: String::new(), + api_key: String::new(), + })), + omnigraph::ExternalBlobPolicy::Deny, + ) + .unwrap(), + ); + assert!(db.shares_runtime_owner(&replacement)); + let error = query_main( + &replacement, + MOCK_SEARCH_QUERIES, + "vector_search_string", + ¶ms(&[("$q", "alpha")]), + ) + .await + .unwrap_err() + .to_string(); + assert!( + error.contains("test-model-a") && error.contains("test-model-b"), + "{error}" + ); + let original = query_main( + &db, + MOCK_SEARCH_QUERIES, + "vector_search_string", + ¶ms(&[("$q", "alpha")]), + ) + .await + .unwrap(); + assert_eq!(result_slugs(&original)[0], "alpha-doc"); } // ─── BM25 search ──────────────────────────────────────────────────────────── diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index f66891e4a..9fb5daac2 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -61,16 +61,18 @@ resets graphs nor migrates their storage format. Unknown versions and `state.lock: false` refuse. There is no offline/online mode field and no legacy apply, import, refresh or approval executor. -Apply creates graphs and changes schemas and stored queries. Existing roots, -policies, provider/Blob bindings, trust and format stay fixed. New graph bindings -are validated with creation; deletion and replacement of existing runtime -bindings refuse. Normal engine open requires v14; server HTTP requires v0.12. +Apply manages graphs, schemas, queries, policies and provider/Blob bindings. +Runtime changes are validated before effects and activated with the achieved +revision. Roots, trust and storage format stay fixed. Exact lifecycle input +permits graph removal with retained storage, adoption, missing-root recreation +and targeted catalog repair. Normal engine open requires v14; server HTTP +requires v0.12. The applied revision and every achieved base retain each graph's exact source/IR digests and identity domain/version, captured coherently during conversion and -advanced by successful schema outcomes. Serving and every new deployment compare +advanced by successful schema outcomes. Serving and preparation of every affected graph compare this identity even after receipt eviction, rejecting a recreated graph with -identical schema text. Explicit correction supplies the exact observed contract +identical schema text. Unaffected graph opens are skipped. Explicit correction supplies the exact observed contract through `--schema-correction FILE`; it becomes immutable deployment input and requires schema-apply authority even when the source is unchanged. @@ -114,7 +116,9 @@ nonce is unknown. Applied result revision is distinct from server activation. `DeploymentCaller` preserves the storage-owner trust boundary and an optional actor label. Authenticated identity callers recheck current cluster -`ConfigManage`, graph `Read`, and `SchemaApply` for schema effects/recovery. +`ConfigManage`; schema effects/recovery additionally require affected graph +`Read` and `SchemaApply`. Deployment status and results expose management metadata +under `ConfigManage`, without requiring data access on unrelated graphs. Original authority stays immutable; recovery records the current executor while preserving the authored engine receipt. The accepted [server runtime RFC](../rfcs/2026-09-29-server-runtime-and-online-deployment.md) @@ -178,8 +182,10 @@ resolves required provider secrets and validates new graph bindings before any deployment effect. It reserves one batch transition, atomically closes affected graph admission and drains request descendants. Existing engine handles remain owned. It executes under the server's lifetime admission, loads the achieved bindings, -and atomically installs exact contracts, query registries and new graph handles. -Unchanged siblings continue serving. Pre-effect refusals restore predecessor +and atomically installs exact contracts, query registries, immutable engine +runtime bindings, management policy and graph inventory. Rebound engine views +share the coordinator, write queues, schema and Lance sessions without reopening +storage. Unchanged siblings continue serving. Pre-effect refusals restore predecessor views with new epochs. Uncertain effects use existing process containment. After installation, the ledger records the result revision/config digest and @@ -313,8 +319,14 @@ The embedding entry point is `AppState::prepare_same_view`. It binds the actual process runtime; callers cannot substitute a new runtime to bypass stopping. It grants no schema/query replacement or deployment authority. The internal batch capability used by `deployment.rs` validates exact achieved schema/query -bindings and new graph additions under the same shutdown/attempt fence. There -is no public arbitrary-view replacement endpoint. +bindings and explicit graph lifecycle under the same shutdown/attempt fence. +Logical deletion retains the engine owner by canonical storage identity; +readoption reuses that owner and validates its exact confirmation. At most 2,048 +active plus retained roots are admitted per process. Retained owners are never +evicted without settlement; restart clears them. Recreating a graph with a +retained owner is a pre-effect refusal. Blocked startup entries with no engine +can receive a newly created graph. There is no public arbitrary-view replacement +endpoint. These registrations account for server lifetimes, not universal storage-I/O settlement. A joined future or zero operation counter cannot authorize runtime diff --git a/docs/dev/invariants.md b/docs/dev/invariants.md index 58fa1223c..69f77058a 100644 --- a/docs/dev/invariants.md +++ b/docs/dev/invariants.md @@ -142,11 +142,13 @@ Cases an RFC has argued: the flat locator objects and legacy data files under ## Current support boundaries - The server is cluster-only and ledger v2 is the sole operational protocol. - Server-owned apply activates schemas, stored queries and new graphs under the - existing writer admission; existing engines and runtime bindings stay owned. + Server-owned apply activates schemas, queries, policies, provider/Blob bindings + and explicitly confirmed graph lifecycle changes under existing writer admission. + Runtime views share engine authority and retain immutable request bindings. Direct apply bootstraps or deploys while serving is stopped. Legacy ledger conversion is explicit and preserves data; no v1 executor remains. Graph - deletion and replacement of existing runtime bindings are outside this class. + removal retains storage; adoption and missing-root recreation require exact + confirmations and never infer identity from matching schema text. One outstanding durable authority owns completion/recovery. Generic drain does not prove native-I/O settlement or authorize lock release; excluded raw and older writers remain an operator obligation. diff --git a/docs/rfcs/0049-control-plane-seams.md b/docs/rfcs/0049-control-plane-seams.md index d1e00dbe6..aa9e08615 100644 --- a/docs/rfcs/0049-control-plane-seams.md +++ b/docs/rfcs/0049-control-plane-seams.md @@ -124,7 +124,7 @@ counts distinguish actual startup outcomes. `GET /graphs` returns one `graphs` list including those outcomes under the same gate, with `state` (`loading`, `ready`, `blocked`, `transitioning`, `stopping`), `read_available`, `write_available`, optional sanitized `failure`, and `action` (`none`, `wait_for_startup`, `wait_for_transition`, -`restart_after_correction`, `wait_for_restart`). Closed transitions count as +`apply_correction_or_restart`, `wait_for_restart`). Closed transitions count as blocked; pending initial admission counts as loading. These booleans describe runtime availability, not permission. Raw failures stay in server logs. The separate `quarantined` response field is removed. diff --git a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md index 8e75af683..edb02590f 100644 --- a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md +++ b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md @@ -7,7 +7,7 @@ implementation: in-progress authors: - OmniGraph maintainers created: 2026-09-29 -updated: 2026-10-03 +updated: 2026-10-05 discussion: https://github.com/ModernRelay/omnigraph/pull/799 supersedes: - "0034" @@ -19,7 +19,7 @@ blocked_on: - "B: native accepted-I/O settlement, completion-memory/local-I/O reserves and engine-work bounds" - "E0: backend-specific offline deployment/recovery qualification beyond the recorded evidence" - "E1: broader native resource and backend fault qualification beyond the recorded same-engine transition evidence" - - "Existing-cluster rollout: qualified data-preserving offline conversion to storage v13" + - "Existing-cluster rollout: qualified data-preserving offline conversion to storage v14" - "Azure: adversarial live-provider qualification under the retained admission wrapper" --- @@ -30,7 +30,7 @@ blocked_on: The server owns admitted writes through completion and reports exact outcomes and graph availability. Ledger v2 is the sole deployment protocol: direct apply bootstraps a stopped cluster; server-owned apply changes schemas and stored -queries and adds graphs without restarting. +queries, policy/provider/Blob bindings and graph inventory without restarting. The engine publishes graph contents and their accepted schema together; the existing cluster ledger owns deployment input and applied results. No new transaction manager, content-recovery log or job queue is added. @@ -76,11 +76,11 @@ captures coherent request bindings and supports atomic activation of a deployment's affected views. Startup loading remains observable; transient startup retry is separate work. -The deployment class covers graph creation and changes to schemas and stored -queries. Existing graph roots, graph format, policy, credentials, providers, -trust and external-Blob bindings stay fixed; new graphs receive validated -bindings in their creation input. Graph deletion and replacement of existing -runtime bindings are not deployment effects. Schema apply remains main-only and +The deployment class covers graph creation, schemas, stored queries, graph and +cluster policies, embedding-provider definitions and bindings, and external-Blob +rules. Exact lifecycle confirmations authorize graph removal, adoption and +recreation; explicit catalog repair restores digest-verified payloads. Existing +graph roots, storage format and credential/trust configuration stay fixed. Schema apply remains main-only and requires a single live branch. Unsupported migrations refuse before effects; dropping declarations does not reclaim retained data or require a destructive- schema override. @@ -88,14 +88,14 @@ The following are outside this RFC: - independently available historical reads during a schema transition; - durable data-request idempotency keys and lookup after a lost response; -- live replacement of other runtime bindings, additional serving roles and +- live credential/trust replacement, additional serving roles and general-purpose recovery scheduling; - online binary/storage upgrades, automatic embedding and new test-harness protocols. Those capabilities need separate decisions. The former E2, F and G increments are no longer implementation commitments here. Narrow retry of transient graph startup/control failures remains in scope to prevent sticky quarantine. Existing -clusters need a qualified offline v13 conversion before this rollout; its protocol +clusters need a qualified offline v14 conversion before this rollout; its protocol belongs to [Explicit storage upgrades](0064-explicit-storage-upgrades.md). Current behavior and test ownership live in [writes](../dev/writes.md), [recovery](../dev/recovery.md), [serving](../dev/control-plane.md) and @@ -205,7 +205,7 @@ terminates at cutoff and leaves unresolved outcomes for startup reconciliation. ## Serving views A serving view binds graph/root incarnation, schema/catalog identity, stored -queries, fixed authorization/provider bindings and activation witness. Capture it +queries, immutable authorization/provider bindings and activation witness. Capture it before dependent routing or authorization and retain it through the request. An engine snapshot remains per-attempt authority; a Session alone is not a serving view. Existing trust-file refresh retains its qualified fixed binding. @@ -223,8 +223,10 @@ closing a graph, owns HTTP/MCP roots through their final logical users and allow exact-view resumption or exact achieved schema/query activation under fresh epochs. Preparation and resumption bind the real process runtime and share shutdown's synchronous boundary. The batch validates schema and query -replacements together, retaining each existing engine, root and policy; only -declared graph additions supply new handles. A completed refusal before effects +replacements together. A runtime-binding replacement shares the existing engine +coordinator, writer gates, schema authority and Lance sessions without reopening +storage; its policy, provider client and Blob admission remain immutable. New, +adopted and explicitly recreated graphs supply validated handles. A completed refusal before effects can explicitly abort, even after a drain deadline, restoring unchanged views under fresh epochs. Their gates retain old descendants, so a subsequent transition still waits for those owners. Expired or abandoned attempts without @@ -236,7 +238,7 @@ bounded inventory limits these views, and stale ticket identity cannot resume th This retirement does not dispose a native candidate or release its charges. Logical request ownership does not by itself prove native settlement. -Candidate validation uses read-only captured state. Current v13 engine `refresh` +Candidate validation uses read-only captured state. Current v14 engine `refresh` reads storage and adopts an in-memory view; it no longer installs a contract. Changing the serving engine's view still belongs to the owned transition, not candidate validation. Read-write local open still performs a capability-probe @@ -249,7 +251,9 @@ matching stored-query and engine-contract identities throughout; an old request cannot resume with old queries against a new contract. Writes, native controls, exports and maintenance that can interfere with apply or reclamation must finish with known outcomes. Apply then publishes atomically and activation validates -the exact achieved contract and query bindings. Other effective bindings stay fixed. +the exact achieved contract, queries and runtime bindings. Management policy and +graph views activate together. Old admitted views keep their own bindings until +they finish; candidate policy cannot authorize its own deployment. Any remaining native read tail must be proven unable to publish, reclaim or change serving bindings. Keep its immutable snapshot, input owners and existing @@ -317,8 +321,9 @@ Conversion captures these from one coherent accepted engine view; successful schema outcomes advance them with the resource projection. Refusal and not-attempted outcomes preserve them. Bind the same map in the achieved base, reserve its encoded capacity, and compare the full identity before serving or -accepting any deployment, including schema changes and after terminal receipt -eviction. Matching or changed source text alone cannot adopt a recreated graph +accepting changes to that graph, including runtime-only changes and after +terminal receipt eviction. Unaffected graphs are not opened during preparation; +their unavailability does not prevent an independent deployment. Matching or changed source text alone cannot adopt a recreated graph or different accepted IR. Explicit schema correction supplies a map from graph ID to the full observed contract through `--schema-correction FILE`. Bind this map into the immutable deployment input, compare every acknowledgement against @@ -329,9 +334,11 @@ establish the acknowledged contract. Status and boot never manufacture one. The immutable input contains normalized configuration semantics and exact schema and stored-query bytes, including query deletions. Persist and verify their -content-addressed bytes before acceptance. Existing roots, format and runtime bindings must match the applied projection. -New graph declarations receive exact prepared creation identities; removing or -replacing an existing graph refuses. Validate the whole +content-addressed bytes before acceptance. Roots and format remain fixed; +runtime-binding changes are explicit differences from the applied projection. +New graph declarations receive exact prepared creation identities. Lifecycle +confirmations are immutable input, never a bypass around current authority. +Validate the whole effect set and every query against its intended accepted schema before the first schema effect. Execution and recovery never reread mutable configuration files. There is no digest-based sweep/import/refresh execution path. @@ -389,7 +396,9 @@ principal in the accepted deployment. Candidate policy never authorizes a change to an existing graph; new graph bindings are part of the authorized creation input. No persisted approval is a capability. Recovery/lookup check the **current** caller's required authority: identity-authorized recovery requires existing `ConfigManage` plus -graph `SchemaApply` for settlement and `Read` for disclosed evidence, as needed. +graph `SchemaApply` and `Read` for schema settlement. Deployment status and result +metadata require current `ConfigManage`, without graph-data `Read` on unrelated +graphs; they disclose no rows or stored query/policy source. No new policy action or read-only right permits fence publication. Recovery can settle an earlier actor's outcome without impersonating that actor; preserve the original receipt attribution and record the actual recovery executor @@ -536,10 +545,11 @@ independently of configuration discovery or current input files. | Command | Behavior | |---|---| -| `omnigraph --cluster ROOT cluster upgrade-ledger --writers-stopped` | Explicit conversion to ledger v2; validate existing applied resources and v13 graphs, then conditionally replace the ledger. | +| `omnigraph --cluster ROOT cluster upgrade-ledger --writers-stopped` | Explicit conversion to ledger v2; validate existing applied resources and v14 graphs, then conditionally replace the ledger. | | `omnigraph --cluster ROOT cluster status [--deployment-id ID]` | Bounded authorized lookup; return incarnation, next sequence, outstanding full ID and lock identity without graph opens or effects. | -| `omnigraph cluster apply --config DIR [--deployment-id ID] [--schema-correction FILE]` | Bootstrap or execute under direct exclusive admission; allocate and emit the original ID unless supplied. | -| `omnigraph cluster apply --server URL --config DIR [--deployment-id ID] [--schema-correction FILE]` | Submit captured input to the running owner; keep the PID/listener and activate coherent schema/query bindings and graph additions. | +| `omnigraph cluster plan --config DIR [--lifecycle FILE]` | Run the same read-only preflight with the intended actor; show lifecycle work even when source digests are unchanged. | +| `omnigraph cluster apply --config DIR [--deployment-id ID] [--lifecycle FILE]` | Bootstrap or execute under direct exclusive admission; allocate and emit the original ID unless supplied. | +| `omnigraph cluster apply --server URL --config DIR [--deployment-id ID] [--lifecycle FILE]` | Submit captured input to the running owner; keep the PID/listener and activate coherent schema/query/policy/provider/Blob bindings and explicit graph lifecycle changes. | | `omnigraph cluster status --server URL [--deployment-id ID]` | Authorized ledger lookup plus an observation of whether that result is active in this process. | | `omnigraph --cluster ROOT cluster apply --deployment-id ID --writers-stopped` | Reconcile that original deployment only; never submit, use new files or replay schema effects. A terminal result is lookup-only. | | `omnigraph --cluster ROOT cluster force-unlock LOCK_ID` | Exact-ID release under the operator-exclusion/quiescence procedure; outstanding evidence remains intact. | @@ -566,6 +576,50 @@ single conversion CAS; an interrupted/lost-ack conversion rereads the root and recognizes the same admitted v2 ledger. No automatic migration/downgrade occurs. Graph-format conversion remains separately gated by the storage-upgrade decision. +### Explicit lifecycle and repair + +`cluster plan` and `cluster apply` accept `--lifecycle FILE`, a bounded JSON +`DeploymentOptions` value captured with the sources. `schema_corrections` is the +existing exact-contract correction map; `--schema-correction` is its mutually +exclusive shorthand. No v1 executor, approval artifact or implicit refresh is +introduced. + +- `delete_graphs` and `adopt_graphs` map IDs to the exact schema contract and + observed graph manifest version. Removal must agree with the desired inventory; + adoption requires a readable graph at the canonical derived root and matching + desired schema. Stale or unnecessary confirmations refuse before effects. +- `recreate_graphs` acknowledges the previous achieved schema contract of a + missing managed graph. Its entire derived root must be absent. Creation uses + the existing prepared-birth protocol and records a new identity; it cannot + overwrite partial, foreign or retained data. +- `repair_catalog` names exact policy/query resource addresses to restore from + content-addressed desired source bytes. Only those repairs affect admission. A storage owner may supply a missing applied policy only when + those bytes match its already-recorded digest. An authenticated caller cannot + authorize itself using a replacement for unreadable current policy. + +Graph removal unregisters its serving inventory and retains storage, rows, +branches and history; the result explicitly reports retained storage. Reuse of +that root requires exact adoption. Physical reclamation is not part of this +ledger-only effect and cannot be inferred from a removal receipt. Preserve a +removed runtime's engine owner by canonical URI until restart; exact readoption +reuses it. Bound active plus retained owners at 2,048 and refuse additional roots +before effects. Recreation refuses while a live or retained engine still owns +that root, because draining request descendants does not prove native disposal. +Startup-blocked entries without an engine can be recreated online. + +Policy grants, revocations and rebinding use the currently applied authority. +Removing a provider still referenced by desired graphs fails configuration +validation. A provider change does not re-embed stored vectors. Blob rules retain +root-disjointness and server-safe projection checks. A terminal partial result +publishes only achieved graph state; serving must install achieved management +policy too, so a policy handoff cannot strand the corrective deployment. + +Plan runs the shared effect-free preparation for the captured lifecycle options +and actor. Missing graphs, schema drift, unsupported migrations and branch +restrictions are errors, not successful plans with a warning. Apply repeats +those checks under writer admission; a plan is not a reservation or permission +to replay a deployment. + ## Online deployment `POST /cluster/deployments` accepts a client-known `deployment_id` and one @@ -584,19 +638,19 @@ including activation; competing POSTs refuse without queueing. The accepted future belongs to the server through completion even if its HTTP caller leaves. Before any effect, validate captured input through the shared serving projection, -resolve required provider secrets and validate new graph bindings. Close only +resolve required provider secrets and validate all changed runtime bindings. Close only affected graph admissions in one batch. Drain their request descendants, then execute prepared intents using their existing engine handles and the server's retained root admission. Unaffected graphs continue serving. Install exact achieved schema -contracts, validated query registries and new graph handles in one registry -snapshot under the shutdown/attempt/deadline fence. Deterministic pre-effect +contracts, validated query registries, engine runtime bindings, management policy +and graph inventory in one registry snapshot under the shutdown/attempt/deadline fence. Deterministic pre-effect refusals resume the predecessor views under new epochs. Uncertain effects retain closed admission and the existing process-containment rule. After activation, persist a witness binding the achieved revision and config digest to this admission incarnation. Status reports `active` only when the witness belongs to this process, still matches the current achieved revision, -and the affected graphs remain ready. Boot revision/digest stays a boot fact. +and affected retained graphs remain ready while removed graphs remain absent. Boot revision/digest stays a boot fact. Original-ID resubmission is lookup-only before graph closure, including a historical inactive result. A late attempt cannot install an older view. A terminal inactive or partially converged predecessor permits a corrective @@ -756,7 +810,7 @@ callbacks. Persistent or unknown failures remain explicit refusals with required action. Unsafe external-Blob policy, an uncomparable storage root, digest mismatch or unsupported format requires correction, not a transient retry. Candidate activation repeats the existing root-disjointness, server-safe policy projection -and digest checks even though E1 keeps that policy fixed. Report phase, attempts, +and digest checks for the achieved policy. Report phase, attempts, classified failure and limiting resource. Supervisor `Retry-After` requires a finite scheduled retry; admission 429 may give caller-backoff guidance without scheduling work. Neither header authorizes write @@ -791,7 +845,7 @@ not a proven no-effect refusal. No general surface-hash or mixed-version negotiation framework is required. v0.12 is a software release, not internal manifest stamp 12. The current binary -serves storage v13 only; normal open never migrates. Supported standalone roots +serves storage v14 only; normal open never migrates. Supported standalone roots have explicit offline routes under the [current storage contract](../dev/versioning.md#current-storage-contract). Preserve graph data, identities, branches and retained history. Unsupported roots @@ -1072,7 +1126,7 @@ schema interface with strict intent version 2, neutral settlement fencing and the [v2 ledger protocol](#ledger-v2). Protected evidence and prior-owner/control-I/O quiescence remain independent obligations. E0 does not wait for E1's retained-engine activation proof and does not claim it. -Existing-cluster rollout also requires qualified offline v13 conversion. +Existing-cluster rollout also requires qualified offline v14 conversion. E1 does not wait for deferred historical serving or data idempotency. Implement slices against the v0.12 contract and update their actual status, OpenAPI and user/developer documentation together. @@ -1100,10 +1154,16 @@ Before enabling an affected increment, its owners must implement and qualify: 4. Azure: qualify live-provider lease-loss and accepted-I/O faults while the existing wrapped server owns HTTP deployments. 5. Storage/cluster: qualify data-preserving offline conversion of existing - cluster roots to v13 before rollout, under the storage-upgrade owner. + cluster roots to v14 before rollout, under the storage-upgrade owner. ## Decision log +- 2026-10-05: Restore declarative policy/provider/Blob lifecycle and explicit + graph removal, adoption, recreation and catalog repair under the sole v2 + protocol. Share exact read-only preparation with plan, limit graph opens to + affected resources, and activate achieved authorization coherently. Storage + upgrade and native-I/O settlement remain separate work. + - 2026-10-03: Restore the original no-restart goal with one v2 execution protocol. Summary, scope, authority, serving views, durable deployment, CLI, online submission, availability, compatibility and alternatives now remove the diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index 512ee4b7d..8596c00b9 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -187,12 +187,15 @@ Without a managed context, cluster commands read a directory containing ```bash omnigraph cluster validate --config ./company-brain -omnigraph cluster plan --config ./company-brain +omnigraph cluster plan --config ./company-brain --as act-alice omnigraph cluster apply --config ./company-brain --as act-alice ``` -They manage graph definitions, schemas, stored queries, and policies—not graph -data. See [Operating a cluster](../clusters/index.md). +They manage graph definitions, schemas, stored queries, policies, embedding +providers and external Blob rules. Use `--lifecycle FILE` on plan and apply for +explicit removal, adoption, recreation, catalog repair or schema-contract +correction. Use the same actor and lifecycle file for both commands. See +[Operating a cluster](../clusters/index.md). For a managed cluster, log in to its Intent API and select the cluster for your config directory: diff --git a/docs/user/cli/managed-data.md b/docs/user/cli/managed-data.md index 2f54d570b..a4bf43ec3 100644 --- a/docs/user/cli/managed-data.md +++ b/docs/user/cli/managed-data.md @@ -29,8 +29,8 @@ or `change`. Ad-hoc query and mutation source uses `read` or `change` respectively. Control-plane admin or apply permission does not confer graph permission. -Existing graph policy bindings stay fixed in the current deployment class; -editing a source file alone has no effect. Schema changes use the +Apply graph policy grants, revocations and binding changes through the cluster +configuration workflow; editing a source file alone has no effect. Changes use the [cluster configuration workflow](../clusters/index.md#deploy-without-restarting); identity credentials do not bypass its ownership or permission checks. diff --git a/docs/user/clusters/config.md b/docs/user/clusters/config.md index 543b5fcda..6e2ffd15f 100644 --- a/docs/user/clusters/config.md +++ b/docs/user/clusters/config.md @@ -205,7 +205,7 @@ Prefer relative paths; they are what keep a bundle portable and hermetic. |---|---:|---| | `validate` | no | Parse and type-check the declaration | | `plan` | no | Preview desired differences; unsupported deployment effects still refuse | -| `apply` | yes | Bootstrap, add graphs, or update schemas and queries | +| `apply` | yes | Converge graph inventory, schemas, queries, policies and provider/Blob settings | | `status` | no | Read recorded deployment and lock status; server status also observes activation | | `observe` | no | Report current observations without changing authority | | `force-unlock` | yes | Remove one proven-stale lock by exact ID | @@ -214,8 +214,9 @@ Prefer relative paths; they are what keep a bundle portable and hermetic. All execution uses ledger v2 and requires `state.lock: true`. With `--server`, apply submits to the existing writer and activates without restart. Direct apply owns exclusive admission and requires an explicit handoff before serving. -Existing roots and runtime bindings stay fixed; graph additions receive their -new bindings with creation. Graph deletion is not a supported deployment effect. +Existing roots and storage formats stay fixed. Policy/provider/Blob bindings can +change; explicit graph removal retains storage for confirmed adoption. See +[lifecycle and repair](index.md#explicit-lifecycle-and-repair). Apply does not load rows or start servers. Root-addressed status, reconciliation and conversion do not use `cluster.yaml`; see [the deployment workflow](index.md). diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index 4edf73199..2ae5a0cfb 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -1,8 +1,9 @@ # Operating a cluster An OmniGraph cluster is a declarative bundle of graphs, schemas, stored queries -and authorization policies. Apply can create graphs and update schemas and -queries. Submit to the running server to activate those changes without a restart. +and authorization policies. Apply converges graph inventory, schemas, queries, +policies and provider/Blob settings. Submit to the running server to activate +changes without a restart. Use a cluster for a multi-graph server or shared operational configuration. For one local graph, the [quickstart](../quickstart.md) is simpler. @@ -41,8 +42,10 @@ of the deployment ledger version. The [configuration reference](config.md) covers storage roots, embedding providers, external Blob policy and limits. For server-owned deployment, the applied policy must grant the operator -`config_manage` at cluster scope, `read` on disclosed graphs, and `schema_apply` -on graphs whose schema changes. New graphs need suitable declared policies too. +`config_manage` at cluster scope. Schema changes additionally require `read` and +`schema_apply` on the affected graphs. Deployment status reveals management +metadata under `config_manage`; it does not require data access on unrelated +graphs. New graphs need suitable declared policies too. The server derives the actor from its bearer token; `--as` is for direct access. ## Bootstrap a cluster @@ -91,7 +94,8 @@ mount or storage credentials for the server’s root. The CLI prints the deployment ID before submission. The server keeps its PID, listener and writer ownership. It briefly closes admission on affected graphs, finishes their admitted requests, publishes schema changes, and activates -matching schemas and stored queries together. Unaffected graphs keep serving. +matching schemas, queries and runtime permissions together. Unaffected graphs +keep serving. Graph additions become available through the same deployment. The response separates the durable deployment result from `active`, which means @@ -101,13 +105,12 @@ across multiple graphs is not one transaction. Query-only changes create no grap commit and also work with multiple branches. Schema changes remain main-only and require a single live branch. -Existing graph roots, format, policy, provider, trust and external-Blob bindings -stay fixed. New graph bindings are validated with their creation input. A refusal before any -deployment effect restores the unchanged serving views, including after a drain -timeout; any old requests remain tracked for a subsequent transition. Removing -graphs or changing existing runtime bindings is outside this deployment class. -There is one operational ledger protocol; no legacy import, refresh, approval -or sweep command runs alongside it. +Policies, provider definitions and graph bindings, and external-Blob rules can +change on existing graphs. Current permissions authorize the deployment; proposed +permissions cannot authorize themselves. Provider changes do not re-embed stored +vectors. Roots, format and credential/trust configuration stay fixed. +A refusal before effects restores unchanged serving views, including after a +drain timeout. There is one deployment protocol and no legacy execution fallback. ## Direct deployments and conversion @@ -179,8 +182,8 @@ and outcome as unknown; it never authorizes replay. See [limits](config.md#limit ## Correct deliberate schema drift -Every deployment compares the achieved schema identity, even when source text -changes. A recreated graph or out-of-band schema change refuses with +Every affected graph is checked against its achieved schema identity, even when +source text changes. A recreated graph or out-of-band schema change refuses with `applied_schema_drift`. To accept a reviewed observed contract, save the exact graph-to-contract JSON map from that refusal and submit: @@ -195,6 +198,46 @@ permission. Unknown, stale or unnecessary entries refuse. Correction accepts the identified current graph; it does not restore missing data or history. An original-ID resubmission must retain the same correction input. +## Explicit lifecycle and repair + +Use the same bounded JSON file with `cluster plan --lifecycle FILE` and +`cluster apply --lifecycle FILE` (also accepted with `apply --server`). Plan +refusals name the exact observed confirmations to inspect and copy. Apply checks +those observations again; a stale confirmation fails before effects. + +| JSON field | Meaning | +|---|---| +| `delete_graphs` | Map graph IDs to `{ "contract": , "graph_manifest_version": }`; remove those graphs from the desired configuration | +| `adopt_graphs` | Same confirmation shape; declare an existing graph at its derived root with its exact current schema | +| `recreate_graphs` | Map missing graph IDs to their previous achieved schema contracts; the entire root must be absent | +| `repair_catalog` | Array of exact policy or stored-query resource addresses to repair from verified source bytes | +| `schema_corrections` | The exact correction map described above; cannot combine this file with `--schema-correction` | + +Removal makes the graph unavailable through the cluster but **retains its +storage, rows, branches and history**. Reuse requires explicit adoption. The +running server retains removed graph owners for safe readoption; active and +retained roots together are limited to 2,048 per process. Restart releases +retained owners. Recreation refuses while that process still owns the old +engine; a graph blocked at startup because its root is missing can be recreated +online. +Recreation creates an empty graph with a new identity; it never restores lost +data or overwrites partial storage. Adoption preserves existing identity/history; +apply schema changes in a subsequent deployment. + +For example, to restore one corrupt stored-query payload: + +```json +{"repair_catalog": ["query.knowledge.people"]} +``` + +Use the actual resource address from plan/observe. Repairing an unreadable +applied policy requires direct storage ownership and source bytes matching its +recorded digest; a remote caller cannot grant itself authority from new files. +Plan runs effect-free engine and ledger checks and reports unavailable affected graphs, +schema drift and migration restrictions as errors. An unavailable unrelated +graph does not block an independent change. Live apply additionally validates +provider secrets and serving settings on the server. + ## Operational boundaries - One mutation-capable process owns a cluster. Online deployment runs inside it; diff --git a/docs/user/deployment.md b/docs/user/deployment.md index ed30ea0a0..bb33d8f66 100644 --- a/docs/user/deployment.md +++ b/docs/user/deployment.md @@ -57,7 +57,7 @@ returns one `graphs` list, including blocked entries, with each graph's `state` Blocked entries include a sanitized `failure`: `invalid_configuration`, `invalid_policy`, `invalid_external_blob_policy`, `open_failed` or `invalid_stored_queries`. Details remain in server logs. -`restart_after_correction` means correct that graph's configuration or +`apply_correction_or_restart` means apply an explicit graph correction or correct the storage problem and restart; `wait_for_restart` describes shutdown. `wait_for_startup` means the graph's one startup attempt has not completed admission. It promises no retry time and does not trigger another open. diff --git a/docs/user/operations/policy.md b/docs/user/operations/policy.md index d428e7efa..b52eab8c8 100644 --- a/docs/user/operations/policy.md +++ b/docs/user/operations/policy.md @@ -29,8 +29,9 @@ schema or contents. See [signed data credentials](server.md#signed-data-credenti `config_manage` is cluster-scoped. It authorizes configuration changes through the identity-authorized cluster API, including stored queries, graph creation, -and a new graph's initial schema and policy bindings. Existing policy membership -and bindings stay fixed in the current deployment class. Changing an existing +and policy membership/binding changes on existing graphs. Grants and revocations +are authorized by the currently applied policy, never the proposed policy. +Changing an existing graph's schema requires that graph's `schema_apply` permission; reading its remote schema or migration preview requires `read` on `main`. The reserved graph `admin` action does not grant cluster management. @@ -120,9 +121,11 @@ omnigraph policy explain \ --actor act-alice --action read --branch main ``` -Bind policies when bootstrapping or creating a graph. Deployment preserves -existing policy bindings; changing an existing cluster or graph policy is outside -its current schema/query deployment class. +Edit the policy file or its `applies_to` bindings, then run +`cluster apply --server URL --config DIR`. The server finishes admitted requests +before activating matching HTTP and engine permissions. Later requests use the +new rules. Cluster management policy changes take effect in the same deployment; +a newly granted administrator cannot authorize that grant. ## Actor identity diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index 622dfabd5..dfa805334 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -30,9 +30,10 @@ An applied empty cluster creates no default graph and serves an empty inventory. Missing or unapplied state, or a nonempty cluster whose graphs all fail, refuses startup. Authentication, policy and data-token root checks still apply. -Use `cluster apply --server URL --config DIR` for schema/query changes and graph -additions without restart. Direct apply requires stopped serving and a subsequent start. Graph deletion and changes to existing runtime -bindings are outside this deployment class; see [cluster deployments](../clusters/index.md). +Use `cluster apply --server URL --config DIR` for schema, query, policy, +provider and Blob-rule changes without restart. Explicit graph lifecycle changes +use the same deployment. Direct apply requires stopped serving and a subsequent +start; see [cluster deployments](../clusters/index.md). An unapplied resource edit does not activate it, although changing or breaking the directory's config can change where boot looks for applied state. @@ -129,10 +130,9 @@ clock up to 30 seconds ahead, so at most 86,430 seconds can remain on admission. Expiry has no grace period. Logout or a permission change at the issuer does not revoke an issued token; already accepted operations can finish after expiry. Stored-query calls need `invoke_query` plus `read` or `change` for the -body. Existing policy bindings remain fixed across deployments; editing a -policy source file does not change permissions. Schema changes use -`cluster apply --server` and its [current-policy authorization](policy.md#actions); -the identity credential supplies no permission or ownership bypass. +body. Apply policy changes through `cluster apply --server`; editing a source +file alone does not change permissions. Deployment uses +[current-policy authorization](policy.md#actions); the identity credential supplies no bypass. Static credentials can coexist for operator recovery. An exact configured static credential keeps its existing authority, including credentials with diff --git a/docs/user/operations/troubleshooting.md b/docs/user/operations/troubleshooting.md index d1d10fc1a..609829582 100644 --- a/docs/user/operations/troubleshooting.md +++ b/docs/user/operations/troubleshooting.md @@ -118,29 +118,29 @@ between graph roots. ## Cluster failures - Run `cluster validate` before `plan` or `apply`. -- Graph deletion is outside the supported deployment class; no approval command - authorizes it. +- Graph removal, adoption and missing-root recreation require exact confirmations + through `--lifecycle FILE`; see [lifecycle and repair](../clusters/index.md#explicit-lifecycle-and-repair). - A retained lock requires prior-owner and accepted-I/O quiescence, exclusion of other admissions/unlocks, and its exact ID; follow [ownership transfer](../deployment.md#writer-topology). - Directory boot reads `cluster.yaml` to resolve storage, but served graph, - query, and policy resources come from applied state. Submit schema/query - changes and graph additions with `cluster apply --server` for live activation. + query, and policy resources come from applied state. Submit configuration + changes with `cluster apply --server` for live activation. - By default one graph that cannot open is quarantined while healthy graphs serve. Use `--require-all-graphs` when partial startup is unacceptable. - `external_blob_base_overlaps_storage_root`: an `external_blobs` base lies inside, or contains, the cluster storage root that holds every graph and the applied state. For a new declaration, move the base to a sibling prefix before - applying. Existing external-Blob bindings cannot be replaced through the - current deployment class. An invalid applied binding keeps its graph blocked; + applying. Correct existing external-Blob bindings in the desired configuration + and apply them. An invalid applied binding keeps its graph blocked; if every graph is blocked, startup fails with `cluster_no_healthy_graphs`. See [External Blob references](../clusters/config.md#external-blob-references). - `external_blob_storage_root_uncomparable`: the cluster storage root is spelled with a path component an external Blob base cannot express (an empty component, or a percent sign in a local path), so a base of the same storage kind cannot be proven to lie outside it. Moving the base does not help: - correct a new declaration before applying. Existing graph roots and Blob - bindings remain fixed. Validation refuses the base, and an invalid applied + correct the declaration or remove the allow-list before applying. Graph roots + remain fixed. Validation refuses the base, and an invalid applied binding keeps the graph blocked, as for an overlap. See [Operating a cluster](../clusters/index.md). diff --git a/docs/user/operations/upgrade.md b/docs/user/operations/upgrade.md index a41bd6cbf..36856dc5b 100644 --- a/docs/user/operations/upgrade.md +++ b/docs/user/operations/upgrade.md @@ -333,8 +333,8 @@ these cutovers: `knowledge_next` with the desired schema, query, provider, and policy bindings. Validate, plan, and submit `cluster apply --server` so the running owner creates and activates its derived root. Load through that server, verify - the new ID, and move clients to it. Preserve the old declaration: graph deletion - is outside the current deployment class. + the new ID, and move clients to it. Remove the old declaration only with an + exact lifecycle confirmation; removal retains its storage for recovery. - **Same graph ID in a parallel cluster root.** Copy the source bundle, set a new `storage` root, and keep the original cluster untouched. Validate, plan, and apply the new bundle; load the export into its derived graph root; then diff --git a/openapi.json b/openapi.json index bf00397ce..e1ecd9072 100644 --- a/openapi.json +++ b/openapi.json @@ -9234,7 +9234,7 @@ "none", "wait_for_startup", "wait_for_transition", - "restart_after_correction", + "apply_correction_or_restart", "wait_for_restart" ] }, From 101d1dffb79fbd37f443763d2ae748322e3a727b Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Mon, 5 Oct 2026 12:23:56 +0300 Subject: [PATCH 02/12] docs(cluster): clarify deployment build upgrade boundary --- docs/user/clusters/index.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index 2ae5a0cfb..29e402e48 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -112,6 +112,13 @@ vectors. Roots, format and credential/trust configuration stay fixed. A refusal before effects restores unchanged serving views, including after a drain timeout. There is one deployment protocol and no legacy execution fallback. +Upgrade the CLI, server and cluster tools together. Before installing the build +that adds `--lifecycle`, finish outstanding deployments with the build that +accepted them: captured input now requires `options`, so the new build cannot +resume older bundles. Completed results remain readable through deployment +status; recapturing an old deployment with the new CLI changes its input digest +and can refuse resubmission of that ID. Graph storage format is unchanged. + ## Direct deployments and conversion Without `--server`, apply executes under its own exclusive admission and requires From fa54a72954ec7b542b2bfb45fd606efd6e6d380a Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Mon, 5 Oct 2026 12:50:42 +0300 Subject: [PATCH 03/12] test(cluster): cover live lifecycle changes through CLI and HTTP --- .github/workflows/ci.yml | 1 + crates/omnigraph-cli/tests/cli_cluster_e2e.rs | 930 +++++++++++++++++- docs/dev/testing.md | 8 +- 3 files changed, 923 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2469630e..620af07b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -707,6 +707,7 @@ jobs: # These real CLI/server/proxy journeys must execute in the ordinary # workspace gate. A removed, filtered or re-ignored owner is not green. for test_name in \ + cluster_e2e_live_apply_changes_schema_queries_and_adds_graph_without_restart \ remote_server_and_cli_end_to_end_flow \ remote_schema_apply_refuses_additive_change_for_cluster_backed_graph \ remote_schema_apply_refuses_incompatible_change_for_cluster_backed_graph \ diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs index e960d0f16..f076086dc 100644 --- a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -117,13 +117,15 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root ); unlock(); - let server = spawn_server_with_cluster_env( - temp.path(), - &[( - "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", - r#"{"act-live":"live-deployment-token"}"#, - )], - ); + let bindings = storage_root.is_none().then(LiveBindingsFixture::new); + let mut server_env = vec![( + "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", + r#"{"act-live":"live-deployment-token","act-reader":"reader-token","act-next":"next-token"}"#, + )]; + if let Some(bindings) = &bindings { + server_env.extend(bindings.envs()); + } + let server = spawn_server_with_cluster_env(temp.path(), &server_env); let original_pid = server.id(); let original_url = server.base_url.clone(); let stop = Arc::new(AtomicBool::new(false)); @@ -300,6 +302,34 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root "{mismatched:?}" ); assert_eq!(server.id(), original_pid); + live_policy_grants_revocations_and_management_handoff(temp.path(), &server); + if let Some(bindings) = &bindings { + bindings.exercise(temp.path(), &server); + } + let retained = live_graph_removal_and_readoption( + temp.path(), + &server, + &applied["deployment"]["result"]["graphs"]["tools"]["contract"], + ); + if storage_root.is_none() { + live_catalog_repair(temp.path(), &server); + } + assert_eq!(server.id(), original_pid); + assert_eq!(server.base_url, original_url); + + // Reboot from the durable projection: no source apply is allowed to hide + // an activation-only change that was never actually persisted. + live_policy_handoff_before_restart(temp.path(), &server); + server.stop(); + unlock(); + let server = spawn_server_with_cluster_env(temp.path(), &server_env); + live_policy_state_survives_restart(temp.path(), &server); + assert_eq!(live_snapshot(&server, "tools"), retained); + assert_live_tool_data(&server); + assert_live_person_query(&server); + if let Some(bindings) = &bindings { + bindings.assert_after_restart(temp.path(), &server); + } if storage_root.is_none() { // Simulate missing storage while its owner is stopped, then prove an // unrelated deployment and explicit recovery both work in one new PID. @@ -310,13 +340,20 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root .unwrap(); let prior_contract = state["applied_revision"]["schema_contracts"]["peer"].clone(); fs::remove_dir_all(temp.path().join("graphs/peer.omni")).unwrap(); - let recovering = spawn_server_with_cluster_env( - temp.path(), - &[( - "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", - r#"{"act-live":"live-deployment-token"}"#, - )], - ); + // Deliberately bypass the ledger while every serving writer is stopped. + // Text equality must not hide the changed accepted schema contract. + let drifted = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = Omnigraph::open(temp.path().join("graphs/knowledge.omni").to_str().unwrap()) + .await + .unwrap(); + db.apply_schema( + "node Person { name: String @key age: I32? bio: String? bypassed: Bool? }\n", + ) + .await + .unwrap(); + serde_json::to_value(db.schema_contract_digest()).unwrap() + }); + let recovering = spawn_server_with_cluster_env(temp.path(), &server_env); let recovery_pid = recovering.id(); let read_peer = || { client.post(format!("{}/graphs/peer/query", recovering.base_url)) @@ -325,7 +362,8 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root .send().unwrap() }; assert_eq!(read_peer().status(), 503); - // The tools query differs from the last accepted bytes; peer is unchanged. + // Change only the tools query; the unavailable peer is unchanged. + fs::write(&tools, format!(" {}", fs::read_to_string(&tools).unwrap())).unwrap(); let unrelated = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_BEARER_TOKEN", "live-deployment-token") @@ -341,6 +379,12 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root )); assert_eq!(unrelated["active"], true, "{unrelated}"); assert_eq!(read_peer().status(), 503); + live_schema_correction( + temp.path(), + &recovering, + &state["applied_revision"]["schema_contracts"]["knowledge"], + &drifted, + ); let lifecycle = temp.path().join("lifecycle.json"); fs::write( &lifecycle, @@ -374,6 +418,20 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root ); assert_eq!(read_peer().status(), 200); assert_eq!(recovering.id(), recovery_pid); + let peer_snapshot = live_snapshot(&recovering, "peer"); + let corrected_snapshot = live_snapshot(&recovering, "knowledge"); + recovering.stop(); + unlock(); + let restarted = spawn_server_with_cluster_env(temp.path(), &server_env); + assert_eq!(live_snapshot(&restarted, "peer"), peer_snapshot); + assert_eq!(live_snapshot(&restarted, "knowledge"), corrected_snapshot); + assert_live_schema_matches_source(temp.path(), &restarted); + assert_live_person_query(&restarted); + assert_live_tool_data(&restarted); + bindings + .as_ref() + .unwrap() + .assert_after_restart(temp.path(), &restarted); } } @@ -946,3 +1004,845 @@ fn apply_lifecycle_fixture( unlock_cluster_fixture(root); result } + +fn live_apply( + config_dir: &std::path::Path, + server: &TestServer, + token: &str, + lifecycle: Option<&serde_json::Value>, +) -> serde_json::Value { + let mut command = cli(); + command + .env("OMNIGRAPH_BEARER_TOKEN", token) + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json") + .timeout(std::time::Duration::from_secs(30)); + if let Some(options) = lifecycle { + let path = config_dir.join("live-lifecycle.json"); + fs::write(&path, serde_json::to_vec(options).unwrap()).unwrap(); + command.arg("--lifecycle").arg(path); + } + let result = parse_stdout_json(&output_success(&mut command)); + assert_eq!(result["active"], true, "{result}"); + assert_eq!( + result["deployment"]["result"]["converged"], true, + "{result}" + ); + result +} + +fn live_get(server: &TestServer, path: &str) -> serde_json::Value { + let response = graph_http_client() + .get(format!("{}{path}", server.base_url)) + .bearer_auth("live-deployment-token") + .timeout(std::time::Duration::from_secs(10)) + .send() + .unwrap(); + let status = response.status(); + let body = response.text().unwrap(); + assert!(status.is_success(), "{path}: {status}: {body}"); + serde_json::from_str(&body).unwrap() +} + +fn live_snapshot(server: &TestServer, graph: &str) -> serde_json::Value { + live_get(server, &format!("/graphs/{graph}/snapshot")) +} + +fn assert_live_tool_data(server: &TestServer) { + let result = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token").args([ + "query", + "tools", + "--server", + &server.base_url, + "--graph", + "tools", + "--json", + ]), + )); + assert_eq!(result["row_count"], 1, "{result}"); + assert_eq!(result["rows"][0]["t.name"], "Retained", "{result}"); +} + +fn assert_live_person_query(server: &TestServer) { + let result = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token").args([ + "query", + "find_person", + "--server", + &server.base_url, + "--graph", + "knowledge", + "--params", + r#"{"name":"Alice"}"#, + "--json", + ]), + )); + assert_eq!(result["row_count"], 1, "{result}"); + assert_eq!(result["rows"][0]["p.name"], "Alice", "{result}"); +} + +fn live_graph_removal_and_readoption( + config_dir: &std::path::Path, + server: &TestServer, + contract: &serde_json::Value, +) -> serde_json::Value { + output_success(live_policy_cli("live-deployment-token").args([ + "mutate", + "seed", + "--server", + &server.base_url, + "--graph", + "tools", + "-e", + "query seed() { insert Tool { name: \"Retained\" } }", + "--json", + ])); + let snapshot = live_snapshot(server, "tools"); + let history = live_get(server, "/graphs/tools/commits"); + assert_live_tool_data(server); + let config_path = config_dir.join("cluster.yaml"); + let source = fs::read_to_string(&config_path).unwrap(); + let mut removed: serde_yaml::Value = serde_yaml::from_str(&source).unwrap(); + removed["graphs"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("tools")); + for policy in removed["policies"].as_mapping_mut().unwrap().values_mut() { + policy["applies_to"] + .as_sequence_mut() + .unwrap() + .retain(|graph| graph.as_str() != Some("tools")); + } + fs::write(&config_path, serde_yaml::to_string(&removed).unwrap()).unwrap(); + let status_before = live_policy_status(server, "live-deployment-token"); + let confirmation = serde_json::json!({ + "contract": contract, + "graph_manifest_version": snapshot["graph_manifest_version"], + }); + let mut stale = confirmation.clone(); + stale["graph_manifest_version"] = + (snapshot["graph_manifest_version"].as_u64().unwrap() + 1).into(); + for (options, expected) in [ + (serde_json::json!({}), "graph_delete_confirmation_required"), + ( + serde_json::json!({"delete_graphs":{"tools":stale}}), + "graph_lifecycle_confirmation_mismatch", + ), + ] { + let path = config_dir.join("live-lifecycle.json"); + fs::write(&path, serde_json::to_vec(&options).unwrap()).unwrap(); + let output = output_failure( + live_policy_cli("live-deployment-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--lifecycle") + .arg(path) + .arg("--json"), + ); + let refused = parse_stdout_json(&output); + assert!(refused.to_string().contains(expected), "{refused}"); + assert_eq!( + live_policy_status(server, "live-deployment-token"), + status_before + ); + assert_eq!(live_snapshot(server, "tools"), snapshot); + } + let deleted = live_apply( + config_dir, + server, + "live-deployment-token", + Some(&serde_json::json!({"delete_graphs":{"tools":confirmation}})), + ); + assert_eq!( + deleted["deployment"]["result"]["graphs"]["tools"]["outcome"], + "deleted" + ); + assert_eq!( + deleted["deployment"]["result"]["graphs"]["tools"]["retained_storage"], + true + ); + let response = graph_http_client() + .get(format!("{}/graphs/tools/snapshot", server.base_url)) + .bearer_auth("live-deployment-token") + .timeout(std::time::Duration::from_secs(10)) + .send() + .unwrap(); + assert_eq!(response.status(), 404); + assert_live_person_query(server); + + fs::write(config_path, source).unwrap(); + let adopted = live_apply( + config_dir, + server, + "live-deployment-token", + Some(&serde_json::json!({"adopt_graphs":{"tools":confirmation}})), + ); + assert_eq!( + adopted["deployment"]["result"]["graphs"]["tools"]["outcome"], + "adopted" + ); + assert_eq!( + adopted["deployment"]["result"]["graphs"]["tools"]["contract"], + *contract + ); + assert_eq!(live_snapshot(server, "tools"), snapshot); + assert_eq!(live_get(server, "/graphs/tools/commits"), history); + assert_live_tool_data(server); + snapshot +} + +fn live_catalog_repair(config_dir: &std::path::Path, server: &TestServer) { + let ledger: serde_json::Value = + serde_json::from_slice(&fs::read(config_dir.join("__cluster/state.json")).unwrap()) + .unwrap(); + let digest = ledger["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"] + .as_str() + .unwrap(); + let payload = config_dir.join(format!( + "__cluster/resources/query/knowledge/find_person/{digest}.gq" + )); + let expected = fs::read(&payload).unwrap(); + let snapshot = live_snapshot(server, "knowledge"); + // The warm view can still answer; only explicit repair may restore the + // durable catalog that a fresh server must load. + fs::write(&payload, [0xff, 0xfe]).unwrap(); + assert_live_person_query(server); + live_apply(config_dir, server, "live-deployment-token", None); + assert_eq!(fs::read(&payload).unwrap(), [0xff, 0xfe]); + live_apply( + config_dir, + server, + "live-deployment-token", + Some(&serde_json::json!({"repair_catalog":["query.knowledge.find_person"]})), + ); + assert_eq!(fs::read(payload).unwrap(), expected); + assert_eq!(live_snapshot(server, "knowledge"), snapshot); + assert_live_person_query(server); +} + +fn live_schema_correction( + config_dir: &std::path::Path, + server: &TestServer, + stale: &serde_json::Value, + observed: &serde_json::Value, +) { + let before = fs::read(config_dir.join("__cluster/state.json")).unwrap(); + let path = config_dir.join("live-lifecycle.json"); + fs::write( + &path, + serde_json::to_vec(&serde_json::json!({ + "schema_corrections":{"knowledge":stale}, + })) + .unwrap(), + ) + .unwrap(); + let refused = parse_stdout_json(&output_failure( + live_policy_cli("live-deployment-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--lifecycle") + .arg(path) + .arg("--json"), + )); + assert!( + refused.to_string().contains("applied_schema_drift"), + "{refused}" + ); + assert_eq!( + fs::read(config_dir.join("__cluster/state.json")).unwrap(), + before + ); + live_apply( + config_dir, + server, + "live-deployment-token", + Some(&serde_json::json!({"schema_corrections":{"knowledge":observed}})), + ); + assert_live_schema_matches_source(config_dir, server); + assert_live_person_query(server); +} + +fn assert_live_schema_matches_source(config_dir: &std::path::Path, server: &TestServer) { + let schema = live_get(server, "/graphs/knowledge/schema"); + assert_eq!( + schema["schema_source"], + fs::read_to_string(config_dir.join("people.pg")).unwrap() + ); +} + +/// Exercise policy cutover through the public CLI and a real TCP listener. +/// The existing journey owns process startup and the later restart. +fn live_policy_grants_revocations_and_management_handoff( + config_dir: &std::path::Path, + server: &TestServer, +) { + let original_pid = server.id(); + let original_url = server.base_url.clone(); + let graph_policy_path = config_dir.join("graph.policy.yaml"); + let management_path = config_dir.join("live.policy.yaml"); + let original_graph_policy = fs::read_to_string(&graph_policy_path).unwrap(); + let original_management = fs::read_to_string(&management_path).unwrap(); + + let before = live_snapshot(server, "knowledge"); + live_policy_expect_forbidden(&mut live_policy_mutation(server, "PolicyBeforeGrant")); + assert_eq!(live_snapshot(server, "knowledge"), before); + let before_status = live_policy_status(server, "live-deployment-token"); + + let granted = format!( + "{} - id: lifecycle-reader\n allow:\n actors: {{ group: lifecycle-readers }}\n actions: [read, change]\n", + original_graph_policy.replace("groups:\n", "groups:\n lifecycle-readers: [act-reader]\n") + ); + fs::write(&graph_policy_path, &granted).unwrap(); + fs::write( + &management_path, + original_management.replace("[act-live]", "[act-reader]"), + ) + .unwrap(); + live_policy_expect_forbidden( + live_policy_cli("reader-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json"), + ); + assert_eq!( + live_policy_status(server, "live-deployment-token"), + before_status + ); + assert_eq!(live_snapshot(server, "knowledge"), before); + + // The current administrator can grant data access; the candidate could + // not authorize its own submission or publish the attempted mutation. + fs::write(&management_path, &original_management).unwrap(); + live_apply(config_dir, server, "live-deployment-token", None); + let mutation = parse_stdout_json(&output_success(&mut live_policy_mutation( + server, + "PolicyGranted", + ))); + assert!( + mutation["commit"]["graph_commit_id"].is_string(), + "{mutation}" + ); + live_policy_assert_granted_row(server); + + fs::write( + &graph_policy_path, + granted.replace("actions: [read, change]", "actions: [read]"), + ) + .unwrap(); + live_apply(config_dir, server, "live-deployment-token", None); + let before = live_snapshot(server, "knowledge"); + live_policy_expect_forbidden(&mut live_policy_mutation(server, "PolicyAfterRevocation")); + assert_eq!(live_snapshot(server, "knowledge"), before); + live_policy_assert_granted_row(server); + + // Management authority moves independently of graph permissions. act-next + // has no graph grant and must still be able to observe and restore policy. + fs::write( + &management_path, + original_management.replace("[act-live]", "[act-next]"), + ) + .unwrap(); + let handoff = live_apply(config_dir, server, "live-deployment-token", None); + let current = live_policy_status(server, "next-token"); + assert_eq!( + current["status"]["result_revision"], + handoff["deployment"]["result"]["result_revision"] + ); + live_policy_expect_forbidden(live_policy_cli("live-deployment-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--json", + ])); + fs::write(&management_path, &original_management).unwrap(); + live_policy_expect_forbidden( + live_policy_cli("live-deployment-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json"), + ); + assert_eq!(live_policy_status(server, "next-token"), current); + live_apply(config_dir, server, "next-token", None); + live_policy_status(server, "live-deployment-token"); + live_policy_expect_forbidden(live_policy_cli("next-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--json", + ])); + assert_eq!(server.id(), original_pid); + assert_eq!(server.base_url, original_url); +} + +fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &TestServer) { + let management_path = config_dir.join("live.policy.yaml"); + let current = fs::read_to_string(&management_path).unwrap(); + let handoff = current.replace("[act-live]", "[act-next]"); + assert_ne!( + handoff, current, + "fixture must still grant act-live management" + ); + fs::write(management_path, handoff).unwrap(); + live_apply(config_dir, server, "live-deployment-token", None); + live_policy_expect_forbidden(live_policy_cli("live-deployment-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--json", + ])); + live_policy_status(server, "next-token"); +} + +fn live_policy_state_survives_restart(config_dir: &std::path::Path, server: &TestServer) { + let before = live_snapshot(server, "knowledge"); + live_policy_expect_forbidden(&mut live_policy_mutation(server, "PolicyAfterRestart")); + assert_eq!(live_snapshot(server, "knowledge"), before); + live_policy_assert_granted_row(server); + live_policy_expect_forbidden(live_policy_cli("live-deployment-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--json", + ])); + live_policy_status(server, "next-token"); + let management_path = config_dir.join("live.policy.yaml"); + let handoff = fs::read_to_string(&management_path).unwrap(); + let restored = handoff.replace("[act-next]", "[act-live]"); + assert_ne!(restored, handoff, "restart fixture must retain the handoff"); + fs::write(management_path, restored).unwrap(); + live_apply(config_dir, server, "next-token", None); + live_policy_status(server, "live-deployment-token"); + live_policy_expect_forbidden(live_policy_cli("next-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--json", + ])); +} + +fn live_policy_cli(token: &str) -> assert_cmd::Command { + let mut command = cli(); + command + .env("OMNIGRAPH_BEARER_TOKEN", token) + .timeout(std::time::Duration::from_secs(30)); + command +} + +fn live_policy_mutation(server: &TestServer, name: &str) -> assert_cmd::Command { + let mut command = live_policy_cli("reader-token"); + command.args([ + "mutate", + "policy_probe", + "--server", + &server.base_url, + "--graph", + "knowledge", + "-e", + "query policy_probe($name: String) { insert Person { name: $name } }", + "--params", + &serde_json::json!({"name":name}).to_string(), + "--json", + ]); + command +} + +fn live_policy_expect_forbidden(command: &mut assert_cmd::Command) { + let output = output_failure(command); + let error = parse_stdout_json(&output); + let message = error["error"] + .as_str() + .expect("CLI error message") + .to_ascii_lowercase(); + assert!( + message.contains("forbidden") || message.contains("policy denied"), + "expected an authorization refusal, got {error}" + ); +} + +fn live_policy_status(server: &TestServer, token: &str) -> serde_json::Value { + parse_stdout_json(&output_success(live_policy_cli(token).args([ + "cluster", + "status", + "--server", + &server.base_url, + "--json", + ]))) +} + +fn live_policy_assert_granted_row(server: &TestServer) { + let rows = parse_stdout_json(&output_success(live_policy_cli("reader-token").args([ + "query", + "policy_read", + "--server", + &server.base_url, + "--graph", + "knowledge", + "-e", + "query policy_read($name: String) { match { $p: Person { name: $name } } return { $p.name } }", + "--params", + r#"{"name":"PolicyGranted"}"#, + "--json", + ]))); + assert_eq!(rows["row_count"], 1, "{rows}"); + assert_eq!(rows["rows"][0]["p.name"], "PolicyGranted", "{rows}"); +} + +/// Bounded loopback dependencies for the existing local live-deployment journey. +/// The S3 fixture serves HEAD only: overwrite retains the descriptor, while the +/// engine still probes its size before publishing. No cloud credentials or +/// external object service participate in this test. +struct LiveBindingsFixture { + first: support::managed_http::IntentApiFixture, + replacement: support::managed_http::IntentApiFixture, + blobs: support::managed_http::IntentApiFixture, + final_snapshot: std::cell::RefCell>, +} + +impl LiveBindingsFixture { + fn new() -> Self { + use support::managed_http::{IntentApiFixture, IntentReply}; + let embedding = |vector| { + IntentReply::json( + 200, + serde_json::json!({"data":[{"index":0,"embedding":vector}]}), + ) + }; + let head = IntentReply { + status: 200, + headers: vec![ + ("content-length".into(), "8".into()), + ( + "last-modified".into(), + "Mon, 05 Oct 2026 00:00:00 GMT".into(), + ), + ("etag".into(), "\"lifecycle-external\"".into()), + ], + body: Vec::new(), + }; + Self { + first: IntentApiFixture::new(vec![ + embedding([1.0, 0.0, 0.0]), + embedding([1.0, 0.0, 0.0]), + embedding([0.0, 1.0, 0.0]), + ]), + // Rebinding, refused replacement, and post-restart checks each + // execute a real query. Every call is counted below. + replacement: IntentApiFixture::new(vec![embedding([1.0, 0.0, 0.0]); 4]), + blobs: IntentApiFixture::new(vec![head.clone(), head]), + final_snapshot: std::cell::RefCell::new(None), + } + } + + fn envs(&self) -> Vec<(&str, &str)> { + vec![ + ("OMNIGRAPH_LIFECYCLE_EMBED_KEY", "test-lifecycle-key"), + ("OMNIGRAPH_EMBEDDINGS_MOCK", "0"), + ("OMNIGRAPH_EMBED_RETRY_ATTEMPTS", "1"), + ("OMNIGRAPH_EMBED_TIMEOUT_MS", "2000"), + ("OMNIGRAPH_EMBED_DEADLINE_MS", "3000"), + ("AWS_ACCESS_KEY_ID", "lifecycle-test-key"), + ("AWS_SECRET_ACCESS_KEY", "lifecycle-test-secret"), + ("AWS_SESSION_TOKEN", ""), + ( + "AWS_CONFIG_FILE", + "/nonexistent/omnigraph-lifecycle-aws-config", + ), + ( + "AWS_SHARED_CREDENTIALS_FILE", + "/nonexistent/omnigraph-lifecycle-aws-credentials", + ), + ("AWS_REGION", "us-east-1"), + ("AWS_DEFAULT_REGION", "us-east-1"), + ("AWS_ENDPOINT", self.blobs.origin.as_str()), + ("AWS_ENDPOINT_URL", self.blobs.origin.as_str()), + ("AWS_ENDPOINT_URL_S3", self.blobs.origin.as_str()), + ("AWS_ALLOW_HTTP", "true"), + ("AWS_VIRTUAL_HOSTED_STYLE_REQUEST", "false"), + ("AWS_S3_FORCE_PATH_STYLE", "true"), + ("AWS_EC2_METADATA_DISABLED", "true"), + ("OBJECT_STORE_CLIENT_MAX_RETRIES", "0"), + ("OBJECT_STORE_CLIENT_RETRY_TIMEOUT", "2"), + ] + } + + fn exercise(&self, config_dir: &std::path::Path, server: &TestServer) { + let token = "live-deployment-token"; + let first_profile = serde_yaml::from_str::(&format!( + "kind: openai-compatible\nbase_url: {}/v1\nmodel: space-a\napi_key: ${{OMNIGRAPH_LIFECYCLE_EMBED_KEY}}\n", + self.first.origin + )).unwrap(); + fs::write(config_dir.join("bindings.pg"), "node SearchDoc { name: String @key text: String embedding: Vector(3) @embed(\"text\") }\nnode Asset { name: String @key content: Blob? }\n").unwrap(); + bindings_edit_config(config_dir, |config| { + config["graphs"]["bindings"] = serde_yaml::from_str("schema: ./bindings.pg\nembedding_provider: first\nexternal_blobs:\n allow:\n - base: s3://lifecycle-assets/first\n scope: server_safe\n").unwrap(); + config["providers"] = serde_yaml::from_str("embedding: {}\n").unwrap(); + config["providers"]["embedding"]["first"] = first_profile; + config["policies"]["graph_operators"]["applies_to"] + .as_sequence_mut() + .unwrap() + .push("bindings".into()); + }); + live_apply(config_dir, server, token, None); + let data = config_dir.join("binding-vectors.jsonl"); + fs::write(&data, "{\"type\":\"SearchDoc\",\"data\":{\"name\":\"alpha\",\"text\":\"first\",\"embedding\":[1,0,0]}}\n{\"type\":\"SearchDoc\",\"data\":{\"name\":\"beta\",\"text\":\"second\",\"embedding\":[0,1,0]}}\n").unwrap(); + output_success( + cli() + .env("OMNIGRAPH_BEARER_TOKEN", token) + .args([ + "load", + "--server", + &server.base_url, + "--graph", + "bindings", + "--mode", + "overwrite", + "--yes", + "--data", + ]) + .arg(data) + .timeout(std::time::Duration::from_secs(30)), + ); + let before = live_snapshot(server, "bindings"); + bindings_nearest(server, "alpha"); + bindings_nearest(server, "alpha"); + self.assert_embedding_requests(&self.first, &["space-a", "space-a"], "/v1/embeddings"); + + // Replacing an already-used profile must replace its lazy engine client. + bindings_edit_config(config_dir, |config| { + config["providers"]["embedding"]["first"]["model"] = "space-b".into(); + }); + live_apply(config_dir, server, token, None); + bindings_nearest(server, "beta"); + self.assert_embedding_requests( + &self.first, + &["space-a", "space-a", "space-b"], + "/v1/embeddings", + ); + assert_eq!(live_snapshot(server, "bindings"), before); + + // A new endpoint and graph binding also remove the former definition. + let replacement = serde_yaml::from_str::(&format!( + "kind: openai-compatible\nbase_url: {}/v2\nmodel: space-c\napi_key: ${{OMNIGRAPH_LIFECYCLE_EMBED_KEY}}\n", + self.replacement.origin + )).unwrap(); + bindings_edit_config(config_dir, |config| { + config["providers"]["embedding"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("first")); + config["providers"]["embedding"]["replacement"] = replacement; + config["graphs"]["bindings"]["embedding_provider"] = "replacement".into(); + }); + live_apply(config_dir, server, token, None); + bindings_nearest(server, "alpha"); + self.assert_embedding_requests(&self.replacement, &["space-c"], "/v2/embeddings"); + self.first.assert_complete(); + assert_eq!(live_snapshot(server, "bindings"), before); + + // Missing server credentials refuse before acceptance and keep the + // already-used replacement provider working under its existing binding. + let config_path = config_dir.join("cluster.yaml"); + let valid_config = fs::read(&config_path).unwrap(); + let ledger = fs::read(config_dir.join("__cluster/state.json")).unwrap(); + let missing = format!( + "OMNIGRAPH_MISSING_LIFECYCLE_{}", + uuid::Uuid::new_v4().simple() + ); + assert!(std::env::var_os(&missing).is_none()); + bindings_edit_config(config_dir, |config| { + config["providers"]["embedding"]["replacement"]["api_key"] = + format!("${{{missing}}}").into(); + }); + let refused = output_failure( + cli() + .env("OMNIGRAPH_BEARER_TOKEN", token) + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json") + .timeout(std::time::Duration::from_secs(30)), + ); + assert!(stdout_string(&refused).contains(&missing), "{refused:?}"); + assert_eq!( + fs::read(config_dir.join("__cluster/state.json")).unwrap(), + ledger + ); + assert_eq!(live_snapshot(server, "bindings"), before); + assert_eq!(self.replacement.requests().len(), 1); + fs::write(config_path, valid_config).unwrap(); + bindings_nearest(server, "alpha"); + self.assert_embedding_requests( + &self.replacement, + &["space-c", "space-c"], + "/v2/embeddings", + ); + + bindings_external_load(server, "s3://lifecycle-assets/first/asset.bin", true); + self.assert_blob_probes(&["/lifecycle-assets/first/asset.bin"]); + bindings_external_redirect(server, "s3://lifecycle-assets/first/asset.bin"); + let first_blob = live_snapshot(server, "bindings"); + bindings_edit_config(config_dir, |config| { + config["graphs"]["bindings"]["external_blobs"]["allow"][0]["base"] = + "s3://lifecycle-assets/second".into(); + }); + live_apply(config_dir, server, token, None); + assert_eq!(live_snapshot(server, "bindings"), first_blob); + bindings_external_load(server, "s3://lifecycle-assets/first/asset.bin", false); + assert_eq!(live_snapshot(server, "bindings"), first_blob); + self.assert_blob_probes(&["/lifecycle-assets/first/asset.bin"]); + bindings_external_redirect(server, "s3://lifecycle-assets/first/asset.bin"); + + bindings_external_load(server, "s3://lifecycle-assets/second/asset.bin", true); + self.assert_blob_probes(&[ + "/lifecycle-assets/first/asset.bin", + "/lifecycle-assets/second/asset.bin", + ]); + let final_snapshot = live_snapshot(server, "bindings"); + // A server must not promote an embedded-only source to server-safe. + bindings_edit_config(config_dir, |config| { + config["graphs"]["bindings"]["external_blobs"]["allow"][0]["scope"] = + "embedded_only".into(); + }); + live_apply(config_dir, server, token, None); + bindings_external_load(server, "s3://lifecycle-assets/second/asset.bin", false); + assert_eq!(live_snapshot(server, "bindings"), final_snapshot); + bindings_edit_config(config_dir, |config| { + config["graphs"]["bindings"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("external_blobs")); + }); + live_apply(config_dir, server, token, None); + bindings_external_load(server, "s3://lifecycle-assets/second/asset.bin", false); + bindings_external_redirect(server, "s3://lifecycle-assets/second/asset.bin"); + assert_eq!(live_snapshot(server, "bindings"), final_snapshot); + self.blobs.assert_complete(); + *self.final_snapshot.borrow_mut() = Some(final_snapshot); + } + + fn assert_after_restart(&self, _config_dir: &std::path::Path, server: &TestServer) { + let previous = self.replacement.requests().len(); + bindings_nearest(server, "alpha"); + assert_eq!(self.replacement.requests().len(), previous + 1); + self.assert_embedding_requests( + &self.replacement, + &vec!["space-c"; previous + 1], + "/v2/embeddings", + ); + self.first.assert_complete(); + bindings_external_load(server, "s3://lifecycle-assets/second/asset.bin", false); + bindings_external_redirect(server, "s3://lifecycle-assets/second/asset.bin"); + self.blobs.assert_complete(); + assert_eq!( + live_snapshot(server, "bindings"), + *self.final_snapshot.borrow().as_ref().unwrap() + ); + } + + fn assert_embedding_requests( + &self, + fixture: &support::managed_http::IntentApiFixture, + models: &[&str], + path: &str, + ) { + let requests = fixture.requests(); + assert_eq!(requests.len(), models.len()); + for (request, model) in requests.iter().zip(models) { + assert_eq!(request.method, "POST"); + assert_eq!(request.path, path); + assert_eq!( + request.headers["authorization"], + "Bearer test-lifecycle-key" + ); + assert_eq!(request.body["model"], *model); + assert_eq!( + request.body["input"], + serde_json::json!(["same-query-text"]) + ); + assert_eq!(request.body["dimensions"], 3); + } + } + + fn assert_blob_probes(&self, paths: &[&str]) { + let requests = self.blobs.requests(); + assert_eq!( + requests.len(), + paths.len(), + "external I/O must match admitted sources" + ); + for (request, path) in requests.iter().zip(paths) { + assert_eq!( + request.method, "HEAD", + "overwrite must retain the descriptor without fetching its body" + ); + assert_eq!(request.path, *path); + } + } +} + +fn bindings_edit_config(config_dir: &std::path::Path, edit: impl FnOnce(&mut serde_yaml::Value)) { + let path = config_dir.join("cluster.yaml"); + let mut config: serde_yaml::Value = serde_yaml::from_slice(&fs::read(&path).unwrap()).unwrap(); + edit(&mut config); + fs::write(path, serde_yaml::to_string(&config).unwrap()).unwrap(); +} + +fn bindings_nearest(server: &TestServer, expected: &str) { + let response = graph_http_client().post(format!("{}/graphs/bindings/query", server.base_url)) + .bearer_auth("live-deployment-token") + .timeout(std::time::Duration::from_secs(10)) + .json(&serde_json::json!({"query":"query nearest_docs($q: String) { match { $d: SearchDoc } return { $d.name } order { nearest($d.embedding, $q) } limit 1 }", "name":"nearest_docs", "params":{"q":"same-query-text"}})) + .send().unwrap(); + let status = response.status(); + let body: serde_json::Value = response.json().unwrap(); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["row_count"], 1, "{body}"); + assert_eq!(body["rows"][0]["d.name"], expected, "{body}"); +} + +fn bindings_external_load(server: &TestServer, uri: &str, allowed: bool) { + let response = graph_http_client().post(format!("{}/graphs/bindings/load", server.base_url)) + .bearer_auth("live-deployment-token") + .timeout(std::time::Duration::from_secs(10)) + .json(&serde_json::json!({"mode":"overwrite", "data":serde_json::json!({"type":"Asset", "data":{"name":"kept","content":uri}}).to_string()})) + .send().unwrap(); + let status = response.status(); + let body: serde_json::Value = response.json().unwrap(); + assert_eq!(status.as_u16(), if allowed { 200 } else { 400 }, "{body}"); + if !allowed { + assert!( + body["error"].as_str().unwrap().contains("external Blob"), + "{body}" + ); + } +} + +fn bindings_external_redirect(server: &TestServer, uri: &str) { + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + let response = client + .get(format!("{}/graphs/bindings/blob", server.base_url)) + .header( + omnigraph_api_types::HTTP_API_CONTRACT_HEADER, + omnigraph_api_types::HTTP_API_CONTRACT, + ) + .bearer_auth("live-deployment-token") + .query(&[ + ("entity", "node"), + ("type", "Asset"), + ("id", "kept"), + ("property", "content"), + ]) + .send() + .unwrap(); + assert_eq!(response.status(), 302); + assert_eq!(response.headers()[reqwest::header::LOCATION], uri); +} diff --git a/docs/dev/testing.md b/docs/dev/testing.md index ba9ff234c..e13331011 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -138,7 +138,13 @@ availability. The same owners cover coherent schema/query batch activation; thes are not generic native settlement. Server `boot_settings` exercises authenticated submission, parked requests, caller disconnect, pre-effect refusal and historical ID observation. CLI `cli_cluster_e2e` proves one PID/listener survives schema/query -replacement and graph addition while an unaffected peer keeps serving. +replacement, graph addition, policy grant/revocation, management handoff and +confirmed graph removal/readoption while preserving data and history. It checks +the achieved configuration again after restart. The local journey also exercises +counted embedding-provider replacement, external-Blob admission changes, catalog +repair, exact schema correction and missing-root recreation. S3/Azure wrappers +share the transport-independent phases; local success does not qualify their +storage-fault paths. CI requires the local journey to execute successfully. CLI suites own their named planes: cluster lifecycle, data commands, stored queries, schema/config, cross-version rebuild, embedded/remote parity, and local/remote system journeys. Keep `OMNIGRAPH_HOME` hermetic by using `tests/support::cli()` or `cli_process()`. From 10baf601a366384ec329d559f9e4e25f6690bf45 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Mon, 5 Oct 2026 12:53:12 +0300 Subject: [PATCH 04/12] test(cluster): align lifecycle refusals with affected-graph deployment --- crates/omnigraph-cli/tests/system_local.rs | 15 ++++++++++++--- crates/omnigraph-cluster/tests/s3_cluster.rs | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index 28ce8763b..4d7cc9781 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -2058,7 +2058,7 @@ fn skip_system_e2e(test_name: &str) -> bool { /// The whole control-plane story in one test: declare two graphs → converge /// (apply creates them) → serve → direct schema/query evolution → serve the new -/// shape → exact acknowledged drift correction → unsupported deletion refuses. +/// shape → exact acknowledged drift correction → unconfirmed deletion refuses. /// The live same-PID journey is owned by cli_cluster_e2e. #[test] fn local_cluster_full_lifecycle_declare_serve_evolve_refuse_delete() { @@ -2164,6 +2164,11 @@ fn local_cluster_full_lifecycle_declare_serve_evolve_refuse_delete() { db.apply_schema(rogue_pg).await.unwrap(); db.schema_contract_digest() }); + // Apply validates only affected graphs. Change this graph's query payload + // so its exact achieved contract must be checked before catalog publication. + let query_path = dir.join("people.gq"); + let query_source = fs::read_to_string(&query_path).unwrap(); + fs::write(&query_path, format!("{query_source}\n")).unwrap(); let before = fs::read(dir.join("__cluster/state.json")).unwrap(); let refused = cluster_cli(dir, &["apply"]); assert_eq!(refused["ok"], false, "{refused}"); @@ -2201,12 +2206,16 @@ fn local_cluster_full_lifecycle_declare_serve_evolve_refuse_delete() { "drift must be dropped back to the declared schema: {shown}" ); - // Removal is outside the deployment class. Refusal preserves the whole - // achieved projection and both graph roots; no approval command exists. + // Removal requires an exact lifecycle confirmation. Refusal preserves the + // whole achieved projection and both graph roots. fs::write(dir.join("cluster.yaml"), "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries:\n find_person:\n file: ./people.gq\n").unwrap(); let before = fs::read(dir.join("__cluster/state.json")).unwrap(); let blocked = cluster_cli(dir, &["apply"]); assert_eq!(blocked["ok"], false, "{blocked}"); + assert_eq!( + blocked["diagnostics"][0]["code"], "graph_delete_confirmation_required", + "{blocked}" + ); assert_eq!(fs::read(dir.join("__cluster/state.json")).unwrap(), before); assert!(dir.join("graphs/engineering.omni").exists()); let server = spawn_server_with_cluster(dir); diff --git a/crates/omnigraph-cluster/tests/s3_cluster.rs b/crates/omnigraph-cluster/tests/s3_cluster.rs index a18d45b21..8779dedaf 100644 --- a/crates/omnigraph-cluster/tests/s3_cluster.rs +++ b/crates/omnigraph-cluster/tests/s3_cluster.rs @@ -284,7 +284,8 @@ async fn object_storage_cluster_full_lifecycle(root: &str, expected_scheme: &str serde_json::from_str(&adapter.read_text(&ledger_path).await.unwrap()).unwrap(); let evolved_revision = evolved["state_revision"].as_u64().unwrap(); - // Inventory deletion is an explicit refusal, never a recursive erase. + // Inventory deletion requires exact lifecycle confirmation and never + // recursively erases graph storage. let before_delete = adapter.read_text(&ledger_path).await.unwrap(); fs::write( dir.path().join("cluster.yaml"), @@ -300,7 +301,7 @@ async fn object_storage_cluster_full_lifecycle(root: &str, expected_scheme: &str ) .await .unwrap_err(); - assert_eq!(refused.code, "deployment_scope"); + assert_eq!(refused.code, "graph_delete_confirmation_required"); assert_eq!( adapter.read_text(&ledger_path).await.unwrap(), before_delete From d3404463f1c0568f0a68996a5cc8b3ffe3a8b582 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 6 Oct 2026 13:54:20 +0300 Subject: [PATCH 05/12] feat(cluster): simplify deployment and delete managed graphs --- .../canonical-data-interfaces.breaking.md | 5 + .../cluster-lifecycle-completion.added.md | 2 +- ...rver-deployment-simplification.breaking.md | 19 + crates/omnigraph-api-types/src/lib.rs | 87 +- crates/omnigraph-cli/src/cli.rs | 80 +- crates/omnigraph-cli/src/client.rs | 176 +- crates/omnigraph-cli/src/cluster_remote.rs | 19 +- crates/omnigraph-cli/src/command_outcome.rs | 1 - crates/omnigraph-cli/src/helpers.rs | 109 - crates/omnigraph-cli/src/main.rs | 167 +- crates/omnigraph-cli/src/main_tests.rs | 44 +- crates/omnigraph-cli/src/managed.rs | 28 +- crates/omnigraph-cli/src/managed/data.rs | 329 +- .../omnigraph-cli/src/managed/data/tests.rs | 435 ++- crates/omnigraph-cli/src/output.rs | 49 - crates/omnigraph-cli/src/planes.rs | 30 +- crates/omnigraph-cli/tests/cli_cluster.rs | 40 +- crates/omnigraph-cli/tests/cli_cluster_e2e.rs | 677 ++-- crates/omnigraph-cli/tests/cli_data.rs | 348 +-- crates/omnigraph-cli/tests/cli_queries.rs | 49 +- .../omnigraph-cli/tests/cli_schema_config.rs | 78 +- crates/omnigraph-cli/tests/parity_matrix.rs | 11 +- crates/omnigraph-cli/tests/system_local.rs | 191 +- crates/omnigraph-cli/tests/system_remote.rs | 72 +- crates/omnigraph-cluster/src/admission.rs | 46 +- crates/omnigraph-cluster/src/authorization.rs | 112 +- crates/omnigraph-cluster/src/config.rs | 17 - crates/omnigraph-cluster/src/deployment.rs | 137 +- .../src/deployment/execution.rs | 1149 +++---- .../omnigraph-cluster/src/deployment/tests.rs | 886 +++--- crates/omnigraph-cluster/src/diff.rs | 5 - crates/omnigraph-cluster/src/lib.rs | 231 +- crates/omnigraph-cluster/src/serve.rs | 49 +- crates/omnigraph-cluster/src/store.rs | 252 +- crates/omnigraph-cluster/src/tests.rs | 435 ++- crates/omnigraph-cluster/src/types.rs | 22 +- crates/omnigraph-cluster/tests/failpoints.rs | 581 ++-- .../tests/identity_recovery.rs | 33 +- crates/omnigraph-cluster/tests/s3_cluster.rs | 83 +- .../examples/bench_actor_isolation.rs | 26 +- .../examples/bench_concurrent_http.rs | 6 +- crates/omnigraph-server/src/data_tokens.rs | 96 +- .../omnigraph-server/src/data_tokens/tests.rs | 124 +- crates/omnigraph-server/src/deployment.rs | 217 +- crates/omnigraph-server/src/handlers.rs | 371 +-- .../omnigraph-server/src/handlers/dispatch.rs | 30 +- crates/omnigraph-server/src/identity.rs | 79 +- crates/omnigraph-server/src/ingress.rs | 4 +- crates/omnigraph-server/src/lib.rs | 47 +- crates/omnigraph-server/src/mcp.rs | 5 +- .../src/oidc_identity_tests.rs | 1 - crates/omnigraph-server/src/registry.rs | 407 ++- crates/omnigraph-server/src/serving.rs | 24 +- crates/omnigraph-server/src/settings.rs | 12 +- crates/omnigraph-server/tests/auth_policy.rs | 227 +- .../omnigraph-server/tests/boot_settings.rs | 346 ++- crates/omnigraph-server/tests/data_routes.rs | 464 +-- crates/omnigraph-server/tests/multi_graph.rs | 12 +- crates/omnigraph-server/tests/openapi.rs | 185 +- crates/omnigraph-server/tests/s3.rs | 10 +- .../omnigraph-server/tests/schema_routes.rs | 814 +---- .../omnigraph-server/tests/stored_queries.rs | 30 +- .../tests/support/data_tokens.rs | 13 +- crates/omnigraph-server/tests/support/mod.rs | 71 +- crates/omnigraph-storage/src/lib.rs | 47 +- crates/omnigraph/src/db/omnigraph.rs | 12 + .../src/db/omnigraph/schema_apply/prepared.rs | 5 +- crates/omnigraph/src/loader/mod.rs | 6 + crates/omnigraph/tests/forbidden_apis.rs | 1 + crates/omnigraph/tests/schema_apply.rs | 11 +- crates/omnigraph/tests/writes.rs | 2 +- docs/dev/control-plane.md | 123 +- docs/dev/execution.md | 2 +- docs/dev/ingestion.md | 2 +- docs/dev/invariants.md | 12 +- docs/dev/testing.md | 11 +- docs/rfcs/0009-unified-access-paths.md | 23 +- docs/rfcs/0011-cli-addressing-and-config.md | 50 +- docs/rfcs/0047-search-plan-truth.md | 10 +- docs/rfcs/0049-control-plane-seams.md | 57 +- docs/rfcs/0051-json-output-via-arrow.md | 14 +- .../0053-offline-data-token-verification.md | 129 +- ...identity-credentials-and-applied-policy.md | 72 +- docs/rfcs/2026-09-16-session-settings.md | 83 +- ...29-server-runtime-and-online-deployment.md | 238 +- docs/user/blobs.md | 3 +- docs/user/cli/index.md | 11 +- docs/user/cli/managed-data.md | 44 +- docs/user/cli/reference.md | 11 +- docs/user/clusters/config.md | 6 +- docs/user/clusters/index.md | 125 +- docs/user/deployment.md | 16 +- docs/user/operations/maintenance.md | 10 +- docs/user/operations/policy.md | 3 +- docs/user/operations/server.md | 33 +- docs/user/operations/troubleshooting.md | 8 +- docs/user/operations/upgrade.md | 4 +- docs/user/queries/explain.md | 4 +- docs/user/queries/settings.md | 4 +- docs/user/search/embeddings.md | 1 - openapi.json | 2728 +++++------------ 101 files changed, 5032 insertions(+), 9594 deletions(-) create mode 100644 changelog.d/canonical-data-interfaces.breaking.md create mode 100644 changelog.d/server-deployment-simplification.breaking.md diff --git a/changelog.d/canonical-data-interfaces.breaking.md b/changelog.d/canonical-data-interfaces.breaking.md new file mode 100644 index 000000000..5b46bcbfa --- /dev/null +++ b/changelog.d/canonical-data-interfaces.breaking.md @@ -0,0 +1,5 @@ +- Removed deprecated HTTP `/read`, `/change`, and `/ingest` routes and CLI `read`, `change`, `ingest`, `check`, `query lint`, and `query check` spellings. Use `query`, `mutate`, `load`, and `lint`; HTTP query and mutation bodies accept only `query` and `name` rather than their old aliases. Export always emits JSONL and no longer accepts `--jsonl`; see the [CLI reference][canonical-data-cli]. +- Removed the unused HTTP `/schema/apply` endpoint. `schema apply` now accepts only standalone storage; deploy served schema changes with `cluster apply --server URL --config DIR`. +- Managed `cluster token` now issues only version-2 identity credentials; `--actions` and version-1 restricted caches/tokens are refused. Run `cluster token` to replace an old local credential explicitly; permissions remain owned by applied Cedar policy. + +[canonical-data-cli]: ../docs/user/cli/reference.md diff --git a/changelog.d/cluster-lifecycle-completion.added.md b/changelog.d/cluster-lifecycle-completion.added.md index 9117156b3..057c16030 100644 --- a/changelog.d/cluster-lifecycle-completion.added.md +++ b/changelog.d/cluster-lifecycle-completion.added.md @@ -1,3 +1,3 @@ -- Cluster apply supports policy grants/revocations and rebinding, embedding-provider changes, and external-Blob rules on existing graphs without restarting the server. Explicit lifecycle input supports graph removal with retained storage, exact adoption, missing-root recreation and targeted catalog repair; plan performs the same effect-free checks as apply, and unrelated unavailable graphs do not block independent changes. See [cluster operations][cluster-lifecycle-completion]. +- Cluster apply supports policy grants/revocations and rebinding, embedding-provider changes, and external-Blob rules on existing graphs without restarting the server. Plan performs the same effect-free checks as apply without taking writer admission, and unrelated unavailable graphs do not block independent changes. Removing a graph declaration uses the same deployment and physically deletes its managed root and retained history after affected work drains. Adoption, missing-root recreation and repair overrides are unsupported. See [cluster operations][cluster-lifecycle-completion]. [cluster-lifecycle-completion]: ../docs/user/clusters/index.md diff --git a/changelog.d/server-deployment-simplification.breaking.md b/changelog.d/server-deployment-simplification.breaking.md new file mode 100644 index 000000000..0c9041dd2 --- /dev/null +++ b/changelog.d/server-deployment-simplification.breaking.md @@ -0,0 +1,19 @@ +- Cluster plan now always observes without taking the writer lock, and migration + previews use the same captured preparation as apply. The removed `--observe`, + `--lifecycle` and `--schema-correction` flags are rejected. Apply supports graph + creation, physical graph deletion and configuration changes. Removing a graph + declaration and applying deletes its managed storage and retained history; + interrupted deletion resumes through the original deployment ID. There is no + unregister mode. Adoption, missing-root recreation, drift acceptance and catalog + repair are no longer apply modes. + +- Deployment activation is reported from the current process runtime snapshot, + without a second ledger write after activation. Durable results no longer store + `activation` or `restart_required`. Finish outstanding deployments with their + originating build and run the stopped-ledger upgrade before starting the new + build on a ledger containing those completed-result fields. Conversion preserves + graph data and exact achieved receipts. + +- The CLI also rejects the obsolete `repair --confirm` and `repair --force` + controls. Repair continues diagnosing foreign linear table commits without + adopting them. diff --git a/crates/omnigraph-api-types/src/lib.rs b/crates/omnigraph-api-types/src/lib.rs index c621670c2..2a8c4d36e 100644 --- a/crates/omnigraph-api-types/src/lib.rs +++ b/crates/omnigraph-api-types/src/lib.rs @@ -66,13 +66,6 @@ pub mod branch_statement_refusals { pub const NAME_OR_PARAMS: &str = "a branch statement takes no name and no parameters"; /// An expected head beside a statement. pub const COMMIT_PRECONDITION: &str = "a branch statement takes no commit precondition"; - /// Any branch statement sent to a deprecated route. - pub const DEPRECATED_ROUTE: &str = - "branch statements are not served on deprecated routes; use POST /mutate or POST /query"; - /// An `explain` statement sent to a deprecated route. - pub const EXPLAIN_DEPRECATED_ROUTE: &str = - "the explain statement is not served on deprecated routes; use POST /query"; - /// Fill the `{statement}` placeholder of the two door refusals. pub fn with_statement(template: &str, statement: &str) -> String { template.replace("{statement}", statement) @@ -87,10 +80,6 @@ pub mod query_file_refusals { /// step, refused at every HTTP route and CLI verb since nothing follows /// the prefix in the same request. pub const ONLY_SETTINGS: &str = "a file of only settings lines carries no statement"; - /// Either carrier — the `settings` field or a `set`/`reset` prefix in the - /// source — at either deprecated route, which serve their legacy bodies - /// under the process defaults alone. - pub const SETTINGS_AT_DEPRECATED_ROUTE: &str = "the deprecated /read and /change routes take no settings, neither a settings field nor a set or reset prefix; use POST /query or POST /mutate"; } /// The `settings` field of a request: one optional value per `request`-scope @@ -471,32 +460,6 @@ pub struct ReadOutput { pub graph_commit_id: Option, } -/// Indefinitely byte-stable envelope of the deprecated `POST /read` route; cell -/// spelling follows the JSON writer. The canonical [`ReadOutput`] may grow -/// additive fields; this legacy envelope deliberately cannot carry them. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct LegacyReadOutput { - pub query_name: String, - pub target: ReadTargetOutput, - pub row_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub columns: Vec, - #[schema(value_type = Value)] - pub rows: Box, -} - -impl From for LegacyReadOutput { - fn from(value: ReadOutput) -> Self { - Self { - query_name: value.query_name, - target: value.target, - row_count: value.row_count, - columns: value.columns, - rows: value.rows, - } - } -} - /// The effect of a branch statement sent to `POST /mutate`, tagged by `kind`. /// A merge conflict has no kind: it is the 409 `POST /branches/merge` answers. /// The statement grammar is `BranchStmt` in `omnigraph-compiler`. @@ -896,43 +859,17 @@ pub struct ChangeErrorOutput { pub change_diff_refusal: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ReadRequest { - /// GQ query source. May declare one or more named queries; pick one with - /// `query_name` if there is more than one. - #[schema( - example = "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}" - )] - pub query_source: String, - /// Name of the query to run when `query_source` declares multiple. Optional - /// when only one query is declared. - pub query_name: Option, - /// JSON object whose keys match the query's declared parameters. - pub params: Option, - /// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`. - pub branch: Option, - /// Snapshot id to read from. Mutually exclusive with `branch`. - pub snapshot: Option, - /// Refused when present: the deprecated route runs under the process - /// defaults. Typed as raw JSON so the refusal names the field instead of - /// a deserialization error. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub settings: Option, -} - /// Inline read-query request for `POST /query`. /// -/// Friendlier-named alternative to [`ReadRequest`] for ad-hoc reads and -/// AI-agent integration. Mutations are rejected with 400 — use `POST -/// /mutate` (or its deprecated alias `POST /change`) for write queries. +/// Mutations are rejected with 400 — use `POST /mutate` for write queries. /// Field names are deliberately short (`query`, `name`) to match the GQ /// keyword and the CLI `-e` flag. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] pub struct QueryRequest { /// GQ read-query source. May declare one or more named queries; pick one /// with `name` when more than one is declared. Mutations - /// (`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its - /// deprecated alias `POST /change`) instead. May instead be the branch + /// (`insert`/`update`/`delete`) get 400 — use `POST /mutate` instead. May be the branch /// statement `branch list`, sent with no `name`, `params`, `branch`, or /// `snapshot`; or one `explain` statement (`explain query …`), which /// answers the v2 plan instead of @@ -1091,22 +1028,18 @@ impl BlobStatOutput { } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] pub struct ChangeRequest { /// GQ mutation source containing `insert`, `update`, or `delete` statements. /// May declare multiple named mutations; pick one with `name`. May instead /// be one branch statement (grammar: `BranchStmt` in `omnigraph-compiler`), /// sent with no `name`, `params`, or `branch`. - /// - /// Accepts the legacy field name `query_source` as a deserialization alias. #[schema( example = "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}" )] - #[serde(alias = "query_source")] pub query: String, /// Name of the mutation to run when `query` declares multiple. - /// - /// Accepts the legacy field name `query_name` as a deserialization alias. - #[serde(default, alias = "query_name")] + #[serde(default)] pub name: Option, /// JSON object whose keys match the mutation's declared parameters. #[serde(default)] @@ -1269,16 +1202,6 @@ pub fn param_descriptor(param: &Param) -> ParamDescriptor { } } -#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] -pub struct SchemaApplyRequest { - /// Project schema in `.pg` source form. The diff against the current - /// schema produces the migration steps that will be applied. - #[schema( - example = "node Person {\n name: String @key\n age: I32?\n}\n\nedge Knows: Person -> Person" - )] - pub schema_source: String, -} - #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SchemaApplyOutput { pub uri: String, diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 2a525cffc..8ce9ad2d7 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -15,10 +15,10 @@ pub(crate) const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN"; #[command(after_help = "\ COMMANDS BY CAPABILITY:\n \ any — run against a graph, served (--server / --profile) or embedded (--store / a \ -URI): query, mutate, load, blob, branch, snapshot, export, commit, changes, schema show/apply.\n \ +URI): query, mutate, load, blob, branch, snapshot, export, commit, changes, schema show.\n \ served — require a server: graphs (registry scope).\n \ direct — direct storage access; reject --server (init, upgrade, optimize, rebuild-full-text-indexes, \ -repair, cleanup, schema plan, lint).\n \ +repair, cleanup, schema plan/apply, lint).\n \ control — manage or inspect a cluster (--config for bundles; explicit --cluster roots for \ Core deployment/recovery; policy & queries via --cluster).\n \ local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\ @@ -28,7 +28,7 @@ Explicit target selectors retain ordinary addressing; competing ambient targets --direct selects ordinary addressing, including operator profiles and defaults.\n\ See the 'Command capabilities' section of the CLI reference for which flags apply where.")] pub(crate) struct Cli { - /// Explicitly use legacy addressing and credentials, ignoring folder context. + /// Use ordinary addressing and operator credentials, ignoring folder context. #[arg(long, global = true)] pub(crate) direct: bool, @@ -49,8 +49,7 @@ pub(crate) struct Cli { /// Select a graph within a multi-graph scope: on a `--server` it appends /// `/graphs/` to the server url; on `--cluster` it picks which cluster /// graph to maintain. Rejected on a single-graph address (a positional URI / - /// `--store`). Required for managed queries, mutations, loads, commit reads, - /// and legacy restricted token issuance with `--actions`. + /// `--store`). Required for managed queries, mutations, loads, and commit reads. #[arg(long, global = true, value_name = "GRAPH_ID")] pub(crate) graph: Option, @@ -104,8 +103,7 @@ pub(crate) enum Command { // ── Data plane ── run against a graph (embedded or via --server). /// Execute a read query, `branch list`, or an `explain` statement against a branch or snapshot. /// - /// Canonical read endpoint, paired with `mutate`; `read` is a visible alias that warns. - #[command(visible_alias = "read")] + /// Run a read query. Query { /// Query name. With no `--query`/`-e`, the stored query to invoke from /// the catalog (served — addressed via --server/--profile). With @@ -144,8 +142,7 @@ pub(crate) enum Command { }, /// Execute a mutation, or one `branch create`/`delete`/`merge` statement. /// - /// Canonical mutation endpoint, paired with `query`; `change` is a visible alias that warns. - #[command(visible_alias = "change")] + /// Run a mutation. Mutate { /// Query name. With no `--query`/`-e`, the stored mutation to invoke /// from the catalog (served — addressed via --server/--profile). With @@ -227,27 +224,6 @@ pub(crate) enum Command { #[arg(long)] json: bool, }, - /// Deprecated permissive loader (defaults: --mode merge, --from main; canonical current output) - #[command(hide = true)] - Ingest { - /// Graph URI - uri: Option, - #[arg(long)] - data: PathBuf, - #[arg(long)] - branch: Option, - #[arg(long)] - from: Option, - #[arg(long, default_value = "merge")] - mode: CliLoadMode, - /// Session setting for this invocation (repeatable): `name=value` in - /// GQ spelling, e.g. `--set stage_write_concurrency=8`. Embedded - /// stores only: no served load route carries a `settings` field. - #[arg(long = "set", value_name = "NAME=VALUE")] - settings: Vec, - #[arg(long)] - json: bool, - }, /// Branch operations Branch { #[command(subcommand)] @@ -268,8 +244,6 @@ pub(crate) enum Command { uri: Option, #[arg(long)] branch: Option, - #[arg(long, hide = true)] - jsonl: bool, #[arg(long = "type")] type_names: Vec, }, @@ -348,18 +322,10 @@ pub(crate) enum Command { #[arg(long)] json: bool, }, - /// Classify and explicitly repair graph-manifest/Lance-HEAD drift + /// Diagnose foreign Lance HEAD movement without adopting it. Repair { /// Graph URI uri: Option, - /// Publish verified maintenance drift. Without this flag, repair only - /// previews what it would do. - #[arg(long)] - confirm: bool, - /// Also publish suspicious or unverifiable drift. Requires - /// `--confirm`; use only after operator review. - #[arg(long, requires = "confirm")] - force: bool, #[arg(long)] json: bool, }, @@ -382,15 +348,6 @@ pub(crate) enum Command { json: bool, }, /// Validate queries against a schema (offline) or repo (repo-backed). - /// - /// Canonical name is `lint` (matches the `omnigraph_compiler::lint` - /// module and the `OG-XXX-NNN` lint-code vocabulary). Replaces the - /// deprecated `omnigraph query lint` / `omnigraph query check` / - /// `omnigraph check` invocations — each is kept as an argv-level - /// shim that prints a one-line stderr warning and rewrites to - /// `omnigraph lint`. Aliases are deliberately *not* exposed via - /// clap's `visible_alias` because that would advertise two - /// equivalent canonical names, which agents emit interchangeably. Lint { /// Graph URI uri: Option, @@ -614,9 +571,6 @@ pub(crate) enum ClusterCommand { config: PathBuf, #[arg(long)] json: bool, - /// Legacy restricted profile: exact comma-separated actions; requires --graph. - #[arg(long, conflicts_with = "clear")] - actions: Option, /// Credential lifetime, 60 seconds to 24 hours (default 1h). #[arg(long, value_parser = crate::managed::data::parse_ttl, conflicts_with = "clear")] ttl: Option, @@ -641,16 +595,6 @@ pub(crate) enum ClusterCommand { /// Emit JSON instead of human text. #[arg(long)] json: bool, - /// Plan without taking the cluster lock: read the ledger once, report - /// any lock instead of refusing, and label the output `observed`. - #[arg(long)] - observe: bool, - /// Core: exact lifecycle confirmations and repair options as JSON (at most 16 MiB). - #[arg(long)] - lifecycle: Option, - /// Core: exact observed schema contracts to correct. - #[arg(long, conflicts_with = "lifecycle")] - schema_correction: Option, /// Managed: select a pushed revision; omission uses the bound head. #[arg(long = "rev", alias = "revision")] revision: Option, @@ -674,13 +618,6 @@ pub(crate) enum ClusterCommand { /// Original durable Core deployment identity; never allocates a retry. #[arg(long, conflicts_with = "plan")] deployment_id: Option, - /// Core config apply only: JSON map from graph id to the exact observed - /// SchemaContractDigest being corrected (at most 16 MiB). - #[arg(long, conflicts_with_all = ["plan", "lifecycle"])] - schema_correction: Option, - /// Core: exact graph deletion/adoption/recreation confirmations and catalog repair options. - #[arg(long, conflicts_with = "plan")] - lifecycle: Option, /// Attest prior writers and accepted graph/control I/O are quiescent. #[arg(long, requires = "deployment_id")] writers_stopped: bool, @@ -862,7 +799,8 @@ pub(crate) enum SchemaCommand { #[arg(long)] json: bool, }, - /// Apply a supported schema migration. + /// Apply a supported schema migration to a standalone graph. + /// Deploy served schemas with `cluster apply --server URL --config DIR`. /// /// A drop removes the property or type from the current graph-manifest /// version and reclaims nothing at apply: older commits still read the diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index f81259132..05786da47 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -5,7 +5,7 @@ //! into two arms here. //! //! Phase 3a put the factory + the uniform read verbs in place. Phase 3b -//! adds the data-plane writes (`load`/`ingest`/`mutate`/`branch_*`/ +//! adds the data-plane writes (`load`/`mutate`/`branch_*`/ //! `apply_schema`) and `query`. The wrinkle 3a deferred: writes open the //! local engine WITH policy (`open_local_db_with_policy`) and carry a //! resolved actor, while reads/`query` open WITHOUT policy. So the @@ -35,10 +35,9 @@ use omnigraph_api_types::{ BranchOutcomeOutput, ChangeBaselineOutput, ChangeBaselineRecord, ChangeBaselineRequest, ChangeFeedOutput, ChangeOpOutput, ChangeOutput, ChangeRequest, CommitChangesOutput, CommitListOutput, CommitOutput, EntityKindOutput, ExportRequest, GraphBatchLoadOutput, - GraphDiscoveryResponse, GraphListResponse, IngestOutput, IngestRequest, - InvokeStoredQueryRequest, QueryRequest, ReadOutput, SchemaApplyOutput, SchemaApplyRequest, - SchemaOutput, SettingsRequest, SnapshotOutput, branch_list_read_output, change_baseline_output, - change_feed_output, change_scope, commit_changes_output, commit_output, ingest_receipt_output, + GraphDiscoveryResponse, GraphListResponse, InvokeStoredQueryRequest, QueryRequest, ReadOutput, + SchemaApplyOutput, SchemaOutput, SettingsRequest, SnapshotOutput, branch_list_read_output, + change_baseline_output, change_feed_output, change_scope, commit_changes_output, commit_output, read_output, schema_apply_output, show_read_output, snapshot_payload, }; use omnigraph_compiler::catalog::Catalog; @@ -56,10 +55,9 @@ use crate::cli::CliLoadMode; use crate::graph_http::{ApiContractError, GraphHttpClient}; use crate::helpers::{ apply_bearer_token, apply_server_flag, branch_statement_change_request, - branch_statement_query_request, is_remote_uri, legacy_change_request_body, - precondition_failed_cli, query_params_from_json, remote_json, remote_json_bounded, - remote_response_json_bounded, remote_url, resolve_cli_actor, resolve_cli_graph, - resolve_remote_bearer_token, resolve_server_flag, select_named_query, + branch_statement_query_request, is_remote_uri, precondition_failed_cli, query_params_from_json, + remote_json, remote_json_bounded, remote_response_json_bounded, remote_url, resolve_cli_actor, + resolve_cli_graph, resolve_remote_bearer_token, resolve_server_flag, select_named_query, }; use crate::output::{LoadOutput, load_output_from_graph_batch, load_output_from_receipt}; @@ -168,10 +166,10 @@ fn blob_transport_error(error: color_eyre::Report) -> color_eyre::Report { } } -/// Why a served `load`/`ingest` refuses a `--set`: neither route's request +/// Why a served `load` refuses a `--set`: neither load route's request /// type carries a `settings` field, so a value could only be dropped. -const SETTINGS_AT_SERVED_LOAD: &str = "load and ingest take --set only on an embedded store; \ - the served load and ingest routes carry no settings field"; +const SETTINGS_AT_SERVED_LOAD: &str = "load takes --set only on an embedded store; \ + the served load routes carry no settings field"; /// The `--set name=value` flags of one invocation, each checked against the /// settings definition (the Session settings RFC). Scope is the transport's: the embedded @@ -247,7 +245,7 @@ fn reject_positional_remote(via_server: bool, uri: &str) -> Result<()> { } impl GraphClient { - /// An already validated managed credential never enters legacy scope or token resolution. + /// A validated managed credential does not resolve operator profiles or tokens. pub(crate) fn managed(endpoint: &str, graph: &str, token: String) -> Result { Self::managed_url( endpoint, @@ -991,68 +989,7 @@ impl GraphClient { } } - /// `ingest` — the deprecated loader-compatible path. Unlike canonical - /// `load`, it retains the historical permissive parser and `/ingest` - /// endpoint. The embedded arm echoes `actor_id: None` in the output - /// exactly as the legacy arm did (the actor is still attributed on the - /// commit via `load_file_as_with_receipt`). - pub(crate) async fn ingest( - &self, - branch: &str, - from: &str, - data: &str, - mode: CliLoadMode, - settings: &[(SettingId, SettingValue)], - ) -> Result { - match self { - GraphClient::Remote { - http, - base_url, - token, - .. - } => { - if !settings.is_empty() { - bail!("{}", SETTINGS_AT_SERVED_LOAD); - } - let data = std::fs::read_to_string(data)?; - remote_json( - http, - Method::POST, - remote_url(base_url, &["ingest"], &[])?, - Some(serde_json::to_value(IngestRequest { - branch: Some(branch.to_string()), - from: Some(from.to_string()), - mode: Some(mode.into()), - data, - })?), - token.as_deref(), - ) - .await - } - GraphClient::Embedded { uri, actor } => { - let session = Self::open_write_session(uri, settings).await?; - let receipt = session - .load_file_as_with_receipt( - branch, - Some(from), - data, - mode.into(), - actor.as_deref(), - ) - .await?; - Ok(ingest_receipt_output( - uri, - &receipt, - &session.catalog(), - mode.into(), - None, - )) - } - } - } - - /// `mutate` — run a change query against `branch`. Folds - /// `execute_change` / `execute_change_remote` + the legacy request body. + /// Run a mutation against `branch`. /// /// `expected_head` is the `--if-commit` compare-and-swap precondition: /// the write runs only if the branch head commit still equals it. A @@ -1060,9 +997,7 @@ impl GraphClient { /// The command boundary reserves exit 4 for verified remote refusals /// without earlier whole-command effects. /// - /// A `--set` value travels in the `settings` field of `POST /mutate` - /// (the deprecated `/change` route refuses the field), so the legacy - /// body is sent only when there is neither a precondition nor a setting. + /// A `--set` value travels in the `settings` field of `POST /mutate`. pub(crate) async fn mutate( &self, branch: &str, @@ -1079,28 +1014,19 @@ impl GraphClient { token, response_limit, } => { - let (url, body) = if expected_head.is_some() || !settings.is_empty() { - let route: &[&str] = if expected_head.is_some() { - &["mutate", "if-graph-commit"] - } else { - &["mutate"] - }; - ( - remote_url(base_url, route, &[])?, - serde_json::to_value(ChangeRequest { - query: query_source.to_string(), - name: query_name.map(ToOwned::to_owned), - params: params_json.cloned(), - branch: Some(branch.to_string()), - settings: Self::remote_settings(settings)?, - })?, - ) + let route: &[&str] = if expected_head.is_some() { + &["mutate", "if-graph-commit"] } else { - ( - remote_url(base_url, &["change"], &[])?, - legacy_change_request_body(query_source, query_name, branch, params_json), - ) + &["mutate"] }; + let url = remote_url(base_url, route, &[])?; + let body = serde_json::to_value(ChangeRequest { + query: query_source.to_string(), + name: query_name.map(ToOwned::to_owned), + params: params_json.cloned(), + branch: Some(branch.to_string()), + settings: Self::remote_settings(settings)?, + })?; remote_json_bounded( http, Method::POST, @@ -1582,12 +1508,8 @@ impl GraphClient { Ok(output) } - /// `apply_schema` — apply `schema_source`. The embedded arm runs the - /// caller's catalog validator (stored-query registry check) inside the - /// engine's `apply_schema_as_with_catalog_check`; the remote arm runs - /// the server's own check and IGNORES `validate`. The `impl FnOnce` - /// validator is exactly why this is an enum, not a trait (non-object- - /// safe). + /// Apply a standalone graph's schema with the caller's catalog validator. + /// Served schemas are deployed through the cluster configuration. pub(crate) async fn apply_schema( &self, schema_source: &str, @@ -1597,25 +1519,10 @@ impl GraphClient { F: FnOnce(&Catalog) -> omnigraph::error::Result<()>, { match self { - GraphClient::Remote { - http, - base_url, - token, - .. - } => { - // The server's `server_schema_apply` runs its own catalog - // check, so `validate` does not apply here. - remote_json::( - http, - Method::POST, - remote_url(base_url, &["schema", "apply"], &[])?, - Some(serde_json::to_value(SchemaApplyRequest { - schema_source: schema_source.to_string(), - })?), - token.as_deref(), - ) - .await - } + GraphClient::Remote { .. } => bail!( + "schema apply requires a standalone storage URI; deploy a served schema with \ + `cluster apply --server --config `" + ), GraphClient::Embedded { uri, actor } => { let db = Self::open_write(uri).await?; let result = db @@ -2037,7 +1944,7 @@ mod tests { let error = remote_json::( &http, Method::POST, - format!("{}/graphs/knowledge/change", server.origin), + format!("{}/graphs/knowledge/mutate", server.origin), Some(json!({"query":"mutation m() {}"})), Some("secret-bearer"), ) @@ -2272,7 +2179,7 @@ mod tests { let error = remote_json::( &http, Method::POST, - format!("{}/graphs/knowledge/change", server.origin), + format!("{}/graphs/knowledge/mutate", server.origin), Some(json!({})), Some("data-bearer"), ) @@ -2480,7 +2387,7 @@ mod tests { if form == "conditional" { "/graphs/knowledge/mutate/if-graph-commit" } else { - "/graphs/knowledge/change" + "/graphs/knowledge/mutate" }, ), }; @@ -2734,8 +2641,9 @@ mod tests { let server = IntentApiFixture::graph(vec![ IntentReply::json(200, read), IntentReply::json(200, change.clone()), - IntentReply::json(200, change), + IntentReply::json(200, change.clone()), IntentReply::json(200, merged), + IntentReply::json(200, change), ]); let client = GraphClient::managed(&server.origin, "knowledge", "data-credential".into()).unwrap(); @@ -2772,14 +2680,24 @@ mod tests { .await .unwrap(); + client + .mutate("main", "mutation m() {}", Some("m"), None, None, &[]) + .await + .unwrap(); let requests = server.workflow_requests(); - assert_eq!(requests.len(), 4); + assert_eq!(requests.len(), 5); + assert_eq!(requests[4].path, "/graphs/knowledge/mutate"); + assert_eq!(requests[4].body["query"], "mutation m() {}"); + assert_eq!(requests[4].body["name"], "m"); + assert!(requests[4].body.get("query_source").is_none()); + assert!(requests[4].body.get("query_name").is_none()); + assert!(requests[4].body.get("settings").is_none()); let field = json!({"merge_lineage": "off", "ann_nprobes": 1}); assert_eq!(requests[0].path, "/graphs/knowledge/query"); assert_eq!(requests[0].body["settings"], field); assert_eq!( requests[1].path, "/graphs/knowledge/mutate", - "a setting selects the canonical route over the legacy /change" + "mutations use the canonical route" ); assert_eq!(requests[1].body["settings"], field); assert!( diff --git a/crates/omnigraph-cli/src/cluster_remote.rs b/crates/omnigraph-cli/src/cluster_remote.rs index 77f62e681..8723b030b 100644 --- a/crates/omnigraph-cli/src/cluster_remote.rs +++ b/crates/omnigraph-cli/src/cluster_remote.rs @@ -76,11 +76,8 @@ impl RemoteCluster { &self, config: &Path, requested_id: Option<&str>, - correction: Option<&Path>, - lifecycle: Option<&Path>, json_output: bool, ) -> Result<()> { - let options = crate::read_deployment_options(lifecycle, correction)?; let status: StatusResponse = self .request(Method::GET, &["cluster", "deployments"], None) .await?; @@ -88,11 +85,7 @@ impl RemoteCluster { // captured bytes to the authenticated server's root without opening // that storage root or resolving it on the client. let deployment = crate::core_deployment_result( - omnigraph_cluster::capture_deployment_for_server_with_options( - config, - &options, - &status.status.canonical_root, - ), + omnigraph_cluster::capture_deployment_for_server(config, &status.status.canonical_root), json_output, )?; if status.status.canonical_root != deployment.canonical_root() { @@ -215,19 +208,11 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { ClusterCommand::Apply { config, deployment_id, - schema_correction, - lifecycle, json, .. } => { remote - .apply( - config, - deployment_id.as_deref(), - schema_correction.as_deref(), - lifecycle.as_deref(), - *json, - ) + .apply(config, deployment_id.as_deref(), *json) .await?; } ClusterCommand::Status { deployment_id, .. } => { diff --git a/crates/omnigraph-cli/src/command_outcome.rs b/crates/omnigraph-cli/src/command_outcome.rs index 27619a90c..9ff9754ee 100644 --- a/crates/omnigraph-cli/src/command_outcome.rs +++ b/crates/omnigraph-cli/src/command_outcome.rs @@ -29,7 +29,6 @@ pub(crate) fn applies(cli: &Cli) -> bool { matches!( &cli.command, Command::Load { .. } - | Command::Ingest { .. } | Command::Mutate { .. } | Command::Branch { command: BranchCommand::Create { .. } diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index be7af7778..3d0208585 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -15,52 +15,6 @@ use omnigraph_compiler::settings::SettingId; use super::*; use crate::operator; -pub(crate) fn read_schema_corrections( - path: Option<&std::path::Path>, -) -> Result> { - use std::io::Read; - - let Some(path) = path else { - return Ok(Default::default()); - }; - let limit = omnigraph_cluster::MAX_BUNDLE_BYTES; - let mut bytes = Vec::new(); - fs::File::open(path)? - .take(limit as u64 + 1) - .read_to_end(&mut bytes)?; - if bytes.len() > limit { - bail!("schema correction file exceeds the {limit}-byte deployment input limit"); - } - serde_json::from_slice(&bytes) - .map_err(|error| color_eyre::eyre::eyre!("invalid schema correction JSON: {error}")) -} - -pub(crate) fn read_deployment_options( - lifecycle: Option<&std::path::Path>, - correction: Option<&std::path::Path>, -) -> Result { - use std::io::Read; - if let Some(path) = lifecycle { - if correction.is_some() { - bail!("--lifecycle and --schema-correction cannot be combined"); - } - let limit = omnigraph_cluster::MAX_BUNDLE_BYTES; - let mut bytes = Vec::new(); - fs::File::open(path)? - .take(limit as u64 + 1) - .read_to_end(&mut bytes)?; - if bytes.len() > limit { - bail!("lifecycle file exceeds the {limit}-byte deployment input limit"); - } - return serde_json::from_slice(&bytes) - .map_err(|error| color_eyre::eyre::eyre!("invalid lifecycle JSON: {error}")); - } - Ok(omnigraph_cluster::DeploymentOptions { - schema_corrections: read_schema_corrections(correction)?, - ..Default::default() - }) -} - pub(crate) fn ensure_local_graph_parent(uri: &str) -> Result<()> { if !uri.contains("://") { fs::create_dir_all(uri)?; @@ -1323,69 +1277,6 @@ pub(crate) async fn execute_queries_list( Ok(()) } -pub(crate) fn legacy_change_request_body( - query_source: &str, - query_name: Option<&str>, - branch: &str, - params_json: Option<&Value>, -) -> Value { - let mut body = serde_json::json!({ - "query_source": query_source, - "branch": branch, - }); - if let Some(name) = query_name { - body["query_name"] = Value::String(name.to_string()); - } - if let Some(params) = params_json { - body["params"] = params.clone(); - } - body -} - -pub(crate) fn rewrite_deprecated_argv(args: Vec) -> Vec { - if args.len() >= 3 { - let sub = args[1].to_str(); - let sub2 = args[2].to_str(); - if sub == Some("query") && matches!(sub2, Some("lint") | Some("check")) { - let suffix = sub2.unwrap(); - eprintln!( - "warning: `omnigraph query {suffix}` is deprecated; use `omnigraph lint` instead" - ); - // Drop the leading `query` token AND normalize `check` -> `lint`. - // `check` is no longer a clap visible_alias (MR-981 §6), so the - // rewritten argv must reach the canonical `lint` subcommand - // directly. Result for `omnigraph query check --query foo.gq`: - // `omnigraph lint --query foo.gq`. - let mut out = Vec::with_capacity(args.len() - 1); - out.push(args[0].clone()); - out.push(OsString::from("lint")); - out.extend(args[3..].iter().cloned()); - return out; - } - } - if let Some(sub) = args.get(1).and_then(|s| s.to_str()) { - match sub { - "read" => { - eprintln!("warning: `omnigraph read` is deprecated; use `omnigraph query` instead") - } - "change" => eprintln!( - "warning: `omnigraph change` is deprecated; use `omnigraph mutate` instead" - ), - "check" => { - eprintln!("warning: `omnigraph check` is deprecated; use `omnigraph lint` instead"); - // Rewrite the top-level subcommand to `lint`; pass through the rest. - let mut out = Vec::with_capacity(args.len()); - out.push(args[0].clone()); - out.push(OsString::from("lint")); - out.extend(args[2..].iter().cloned()); - return out; - } - _ => {} - } - } - args -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index a63170ae1..1896a1e2d 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -6,11 +6,11 @@ use omnigraph::db::{Omnigraph, ReadTarget, SnapshotId}; use omnigraph::loader::LoadMode; use omnigraph_api_types::{ BlobContentKindOutput, BlobStatOutput, BranchOutcomeOutput, ChangeOutput, CommitOutput, - ErrorOutput, GraphBatchDeclarationOutput, GraphBatchLoadOutput, IngestOutput, ReadOutput, - SchemaApplyOutput, SnapshotDatasetOutput, query_file_refusals, + ErrorOutput, GraphBatchDeclarationOutput, GraphBatchLoadOutput, ReadOutput, SchemaApplyOutput, + SnapshotDatasetOutput, query_file_refusals, }; use omnigraph_cluster::{ - DiagnosticSeverity, ForceUnlockOutput, PlanOptions, PlanOutput, StateSyncOutput, StatusOutput, + DiagnosticSeverity, ForceUnlockOutput, PlanOutput, StateSyncOutput, StatusOutput, ValidateOutput, force_unlock_config_dir, observe_config_dir, status_config_dir, validate_config_dir, }; @@ -34,7 +34,6 @@ use reqwest::header::AUTHORIZATION; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; -use std::ffi::OsString; use std::fs; use std::io::{self, Write}; use std::path::PathBuf; @@ -149,7 +148,6 @@ async fn main() -> Result<()> { .display_location_section(false) .install()?; let (cli, machine) = { - let raw_args = rewrite_deprecated_argv(std::env::args_os().collect()); let matches = Cli::command() .arg( Arg::new("version") @@ -158,7 +156,7 @@ async fn main() -> Result<()> { .action(ArgAction::Version) .help("Print version"), ) - .get_matches_from(raw_args); + .get_matches(); let mut command_matches = &matches; while let Some((_, child)) = command_matches.subcommand() { command_matches = child; @@ -258,23 +256,6 @@ fn validate_core_root_arguments( let Command::Cluster { command } = &cli.command else { return Ok(()); }; - if cli.cluster.is_some() - && matches!( - command, - ClusterCommand::Apply { - schema_correction: Some(_), - .. - } | ClusterCommand::Apply { - lifecycle: Some(_), - .. - } - ) - { - return Err(Cli::command().error( - clap::error::ErrorKind::ArgumentConflict, - "--schema-correction and --lifecycle require config-addressed apply; root reconciliation uses only the original captured input", - )); - } if cli.cluster.is_some() && command_matches .try_get_one::("config") @@ -613,44 +594,6 @@ async fn run(cli: Cli) -> Result<()> { print_load_human(&payload); } } - Command::Ingest { - uri, - data, - branch, - from, - mode, - settings, - json, - } => { - let settings = client::parse_set_flags(&settings)?; - // stderr so `--json` consumers reading stdout are unaffected. - eprintln!( - "warning: `omnigraph ingest` is a deprecated loader command; \ - use strict graph-batch `omnigraph load --from --mode ` for new integrations \ - (ingest retains its permissive parser and defaults: --from main --mode merge; output uses current canonical vocabulary)" - ); - let client = client::GraphClient::resolve_with_policy( - capability, - cli.server.as_deref(), - cli.graph.as_deref(), - uri, - cli.as_actor.as_deref(), - cli.profile.as_deref(), - cli.store.as_deref(), - ) - .await?; - let branch = resolve_branch(branch, None, "main"); - let from = resolve_branch(from, None, "main"); - echo_write_target(cli.quiet, "ingest", client.uri(), client.is_remote()); - let payload = client - .ingest(&branch, &from, &data.to_string_lossy(), mode, &settings) - .await?; - if json { - print_json(&payload)?; - } else { - print_ingest_human(&payload); - } - } Command::Branch { command } => match command { BranchCommand::Create { uri, @@ -1127,9 +1070,8 @@ async fn run(cli: Cli) -> Result<()> { // RFC-011 Decision 10: a graph managed by a cluster evolves via // `cluster apply` (deployment ledger), not a direct // `schema apply` against its storage root — that would bypass the - // ledger. Mirrors `init`'s refusal. Only the embedded path can - // address a storage root; a served apply (`--server`) is the - // server's concern. + // ledger. Mirrors `init`'s refusal. This command addresses + // standalone storage only. if !client.is_remote() { if let Some(root) = omnigraph_cluster::cluster_root_for_graph_uri(client.uri()) .await @@ -1152,9 +1094,7 @@ async fn run(cli: Cli) -> Result<()> { let schema_source = fs::read_to_string(&schema)?; // The embedded (direct-store) arm carries no stored-query // registry — the registry is cluster-owned (RFC-011), so a - // direct apply has nothing to validate against. The served arm - // runs the server's own catalog check. So the validator is a - // no-op here on both arms. + // standalone apply has nothing to validate against. echo_write_target(cli.quiet, "schema apply", client.uri(), client.is_remote()); let output = client .apply_schema(&schema_source, |_catalog| Ok(())) @@ -1252,7 +1192,6 @@ async fn run(cli: Cli) -> Result<()> { Command::Export { uri, branch, - jsonl, type_names, } => { let client = client::GraphClient::resolve( @@ -1265,9 +1204,6 @@ async fn run(cli: Cli) -> Result<()> { ) .await?; let branch = resolve_branch(branch, None, "main"); - if jsonl { - eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL"); - } let stdout = io::stdout(); let mut stdout = stdout.lock(); @@ -1754,12 +1690,7 @@ async fn run(cli: Cli) -> Result<()> { } } } - Command::Repair { - uri, - confirm, - force, - json, - } => { + Command::Repair { uri, json } => { let uri = resolve_maintenance_uri( cli.profile.as_deref(), cli.store.as_deref(), @@ -1772,30 +1703,15 @@ async fn run(cli: Cli) -> Result<()> { echo_write_target(cli.quiet, "repair", &uri, false); crate::admission::ensure_graph(&uri).await?; let db = Omnigraph::open(&uri).await?; - let stats = db - .repair(omnigraph::db::RepairOptions { confirm, force }) - .await?; + let stats = db.repair(omnigraph::db::RepairOptions::default()).await?; let refused_count = stats .datasets .iter() .filter(|s| matches!(s.action, omnigraph::db::RepairAction::Refused)) .count(); - let blocked_count = stats - .datasets - .iter() - .filter(|s| { - matches!(s.action, omnigraph::db::RepairAction::Refused) - && matches!( - s.classification, - omnigraph::db::RepairClassification::BlockedPromotion - ) - }) - .count(); if json { let value = serde_json::json!({ "uri": uri, - "confirm": confirm, - "force": force, "graph_manifest_version": stats.graph_manifest_version, "datasets": stats.datasets.iter().map(|s| serde_json::json!({ "type_key": s.type_key, @@ -1809,13 +1725,7 @@ async fn run(cli: Cli) -> Result<()> { }); print_json(&value)?; } else { - let mode = if confirm { "confirm" } else { "preview" }; - println!( - "repair {} — {} mode, {} datasets", - uri, - mode, - stats.datasets.len() - ); + println!("repair {} — {} datasets", uri, stats.datasets.len()); for s in &stats.datasets { let drift = if s.published_dataset_version == s.lance_head_version { format!("{}", s.published_dataset_version) @@ -1842,34 +1752,11 @@ async fn run(cli: Cli) -> Result<()> { err ); } - if !confirm { - println!("rerun with --confirm to publish verified maintenance drift"); - } - } - let drift_refused = refused_count - blocked_count; - if blocked_count > 0 && drift_refused == 0 { - bail!( - "repair reports {} blocked promotion(s), one per table and branch; nothing resolves a \ - blocked pin yet and --force --confirm refuses the same way; reads, mutations \ - and loads on those tables continue", - blocked_count - ); } - if drift_refused > 0 { - let blocked_note = if blocked_count > 0 { - format!( - "; {} more blocked promotion(s), which --force does not resolve", - blocked_count - ) - } else { - String::new() - }; + if refused_count > 0 { bail!( - "repair refused {} suspicious or unverifiable dataset(s); review the preview \ - output and rerun with --force --confirm only if publishing that drift is \ - intentional{}", - drift_refused, - blocked_note + "repair could not diagnose {} dataset(s); inspect the reported errors", + refused_count ); } } @@ -1974,20 +1861,9 @@ async fn run(cli: Cli) -> Result<()> { let output = validate_config_dir(config); finish_cluster_validate(&output, json)?; } - ClusterCommand::Plan { - config, - json, - observe, - lifecycle, - schema_correction, - .. - } => { - let options = - read_deployment_options(lifecycle.as_deref(), schema_correction.as_deref())?; - let output = omnigraph_cluster::plan_config_dir_with_deployment_options( + ClusterCommand::Plan { config, json, .. } => { + let output = omnigraph_cluster::plan_config_dir_as( config, - PlanOptions { observe }, - &options, resolve_cluster_actor(cli.as_actor.as_deref())?, ) .await; @@ -2001,8 +1877,6 @@ async fn run(cli: Cli) -> Result<()> { config, json, deployment_id, - schema_correction, - lifecycle, writers_stopped, .. } => { @@ -2020,15 +1894,8 @@ async fn run(cli: Cli) -> Result<()> { } finish_core_deployment(result, json)?; } else { - let options = read_deployment_options( - lifecycle.as_deref(), - schema_correction.as_deref(), - )?; - let result = omnigraph_cluster::apply_deployment_with_options( - &config, - deployment_id.as_deref(), - &caller, - &options, + let result = omnigraph_cluster::apply_deployment( + &config, deployment_id.as_deref(), &caller, |id, root, lock| { let root = omnigraph::storage::redacted_storage_uri(root); eprintln!( diff --git a/crates/omnigraph-cli/src/main_tests.rs b/crates/omnigraph-cli/src/main_tests.rs index da9b73535..2f9aa9d7a 100644 --- a/crates/omnigraph-cli/src/main_tests.rs +++ b/crates/omnigraph-cli/src/main_tests.rs @@ -3,8 +3,7 @@ use super::{ DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, branch_statement_change_request, - branch_statement_query_request, legacy_change_request_body, normalize_bearer_token, - resolve_remote_bearer_token, sync_dir, + branch_statement_query_request, normalize_bearer_token, resolve_remote_bearer_token, sync_dir, }; use reqwest::header::AUTHORIZATION; use serde_json::json; @@ -22,47 +21,6 @@ fn embedded_cli_preserves_complete_storage_diagnostic_without_reprefixing() { assert!(!report.to_string().contains("storage: storage")); } -#[test] -fn legacy_change_request_body_uses_legacy_field_names() { - // `mutate`'s `POST /change` arm uses the current route DTO spelling: - // required `query_source` and optional `query_name`. - let body = legacy_change_request_body( - "query insert_person($n: String) { insert Person { name: $n } }", - Some("insert_person"), - "main", - Some(&json!({ "n": "Alice" })), - ); - assert_eq!( - body["query_source"].as_str(), - Some("query insert_person($n: String) { insert Person { name: $n } }"), - ); - assert_eq!(body["query_name"].as_str(), Some("insert_person")); - assert_eq!(body["branch"].as_str(), Some("main")); - assert_eq!(body["params"]["n"].as_str(), Some("Alice")); - // Keep this route's exact DTO keys on the wire. - assert!( - body.get("query").is_none(), - "legacy /change body must not carry the renamed `query` key; got {body}" - ); - assert!( - body.get("name").is_none(), - "legacy /change body must not carry the renamed `name` key; got {body}" - ); -} - -#[test] -fn legacy_change_request_body_omits_optional_fields_when_unset() { - let body = legacy_change_request_body( - "query find() { match { $p: Person } return { $p.name } }", - None, - "main", - None, - ); - assert_eq!(body["branch"].as_str(), Some("main")); - assert!(body.get("query_name").is_none()); - assert!(body.get("params").is_none()); -} - #[test] fn branch_statement_requests_carry_the_source_alone() { assert_eq!( diff --git a/crates/omnigraph-cli/src/managed.rs b/crates/omnigraph-cli/src/managed.rs index 249ec9532..46aca0ac6 100644 --- a/crates/omnigraph-cli/src/managed.rs +++ b/crates/omnigraph-cli/src/managed.rs @@ -551,14 +551,8 @@ async fn cluster_command( context: &Context, command: &ClusterCommand, ) -> Result<(Value, i32)> { - if let ClusterCommand::Token { - actions, - ttl, - clear, - .. - } = command - { - return data::token(cli, context, actions.as_deref(), *ttl, *clear) + if let ClusterCommand::Token { ttl, clear, .. } = command { + return data::token(cli, context, *ttl, *clear) .await .map(|body| (body, 0)); } @@ -571,18 +565,6 @@ async fn cluster_command( } | ClusterCommand::Apply { writers_stopped: true, .. - } | ClusterCommand::Apply { - schema_correction: Some(_), - .. - } | ClusterCommand::Apply { - lifecycle: Some(_), - .. - } | ClusterCommand::Plan { - lifecycle: Some(_), - .. - } | ClusterCommand::Plan { - schema_correction: Some(_), - .. } | ClusterCommand::Status { deployment_id: Some(_), .. @@ -595,12 +577,6 @@ async fn cluster_command( } // Reject unsupported verbs and invalid requests before accessing credentials. match command { - ClusterCommand::Plan { observe: true, .. } => { - return Err(Failure::refused( - "managed_command_unsupported", - "managed plan does not accept --observe", - )); - } ClusterCommand::Apply { plan: None, .. } => { return Err(Failure::refused( "plan_required", diff --git a/crates/omnigraph-cli/src/managed/data.rs b/crates/omnigraph-cli/src/managed/data.rs index 9c930ecc6..1014a2b4b 100644 --- a/crates/omnigraph-cli/src/managed/data.rs +++ b/crates/omnigraph-cli/src/managed/data.rs @@ -7,29 +7,10 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::BTreeSet; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; const MAX_CREDENTIAL: usize = 64 * 1024; const MAX_TOKEN: usize = 8192; -const ACTIONS: [&str; 8] = [ - "read", - "export", - "change", - "branch_create", - "branch_delete", - "branch_merge", - "invoke_query", - "graph_list", -]; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct Grant { - graph_id: String, - actions: Vec, -} - #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Credential { @@ -43,8 +24,6 @@ struct Credential { actor: String, #[serde(default, skip_serializing_if = "Option::is_none")] cluster_incarnation: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - grants: Vec, } /// Parsed only to reject mismatched issuance/cache metadata, never to select @@ -96,33 +75,11 @@ fn graph_id(graph: &str) -> Result<()> { Ok(()) } -fn validate_grants(grants: &[Grant]) -> Result<()> { - let mut graphs = BTreeSet::new(); - if grants.is_empty() || grants.len() > 64 { - return Err(invalid()); - } - for grant in grants { - graph_id(&grant.graph_id).map_err(|_| invalid())?; - let mut seen = BTreeSet::new(); - if !graphs.insert(&grant.graph_id) - || grant.actions.is_empty() - || grant.actions.len() > ACTIONS.len() - || grant - .actions - .iter() - .any(|a| !ACTIONS.contains(&a.as_str()) || !seen.insert(a)) - { - return Err(invalid()); - } - } - Ok(()) -} - impl Credential { fn validate(&self, context: &Context) -> Result<()> { let now = OffsetDateTime::now_utc(); let expires = OffsetDateTime::parse(&self.expires_at, &Rfc3339).map_err(|_| invalid())?; - if !matches!(self.version, 1 | 2) + if self.version != 2 || self.api != context.api || self.cluster_id != context.cluster || !canonical_origin(&self.endpoint).is_ok_and(|o| o == self.endpoint) @@ -150,55 +107,43 @@ impl Credential { { return Err(invalid()); } + let claims = parse_identity_claims(&self.token).ok_or_else(invalid)?; + let header = self.token.split('.').next().ok_or_else(invalid)?; + let header: omnigraph_server::data_tokens::DataTokenHeader = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header).map_err(|_| invalid())?) + .map_err(|_| invalid())?; + if claims.iss != self.api + || claims.cluster_id != self.cluster_id + || self.cluster_incarnation.as_deref() != Some(claims.cluster_incarnation.as_str()) + || claims.aud != format!("urn:omnigraph:data:{}", self.cluster_id) + || self.actor != format!("principal:{}", claims.sub) + || i64::try_from(claims.exp).ok() != Some(expires.unix_timestamp()) + || header.kid != self.kid + || header.typ != "JWT" + || header.alg != "ES256" + || !claims + .exp + .checked_sub(claims.iat) + .is_some_and(|ttl| (60..=86400).contains(&ttl)) + || claims.iat + > u64::try_from(now.unix_timestamp()) + .unwrap_or_default() + .saturating_add(30) + { + return Err(invalid()); + } if expires <= now { return Err(Failure::refused( "data_credential_expired", "the data credential has expired; mint a new cluster token", )); } - if self.version == 1 { - if self.cluster_incarnation.is_some() || parse_identity_claims(&self.token).is_some() { - return Err(invalid()); - } - validate_grants(&self.grants) - } else { - let claims = parse_identity_claims(&self.token).ok_or_else(invalid)?; - let header = self.token.split('.').next().ok_or_else(invalid)?; - let header: omnigraph_server::data_tokens::DataTokenHeader = - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header).map_err(|_| invalid())?) - .map_err(|_| invalid())?; - if !self.grants.is_empty() - || claims.iss != self.api - || claims.cluster_id != self.cluster_id - || self.cluster_incarnation.as_deref() != Some(claims.cluster_incarnation.as_str()) - || claims.aud != format!("urn:omnigraph:data:{}", self.cluster_id) - || self.actor != format!("principal:{}", claims.sub) - || i64::try_from(claims.exp).ok() != Some(expires.unix_timestamp()) - || header.kid != self.kid - || header.typ != "JWT" - || header.alg != "ES256" - || !claims - .exp - .checked_sub(claims.iat) - .is_some_and(|ttl| (60..=86400).contains(&ttl)) - || claims.iat - > u64::try_from(now.unix_timestamp()) - .unwrap_or_default() - .saturating_add(30) - { - return Err(invalid()); - } - Ok(()) - } + Ok(()) } fn metadata(&self) -> Value { let mut metadata = json!({"cluster_id":self.cluster_id,"endpoint":self.endpoint,"expires_at":self.expires_at,"kid":self.kid,"actor":self.actor}); - if self.version == 1 { - metadata["grants"] = json!(self.grants); - } else { - metadata["version"] = json!(2); - } + metadata["version"] = json!(2); metadata } } @@ -236,48 +181,14 @@ fn scope(cli: &Cli) -> Result<()> { Ok(()) } -fn requested_grant(graph: Option<&str>, actions: Option<&str>) -> Result { - let graph = - graph.ok_or_else(|| Failure::refused("graph_required", "managed data requires --graph"))?; - graph_id(graph)?; - let actions: Vec = actions - .unwrap_or("") - .split(',') - .map(str::to_string) - .collect(); - let grant = Grant { - graph_id: graph.into(), - actions, - }; - validate_grants(std::slice::from_ref(&grant)).map_err(|_| Failure::refused("data_actions_invalid", "--actions must be a nonempty, duplicate-free comma-separated list of supported data actions"))?; - Ok(grant) -} - -async fn mint( - store: &impl Store, - context: &Context, - api: &Api, - grant: Grant, - ttl: u64, -) -> Result { - mint_profile(store, context, api, Some(grant), ttl).await -} - -async fn mint_profile( - store: &impl Store, - context: &Context, - api: &Api, - grant: Option, - ttl: u64, -) -> Result { - mint_for_principal(store, context, api, grant, ttl, None).await +async fn mint(store: &impl Store, context: &Context, api: &Api, ttl: u64) -> Result { + mint_for_principal(store, context, api, ttl, None).await } async fn mint_for_principal( store: &impl Store, context: &Context, api: &Api, - grant: Option, ttl: u64, expected_principal: Option<&str>, ) -> Result { @@ -287,31 +198,16 @@ async fn mint_for_principal( .request( Method::POST, &format!("/v1/clusters/{}/tokens", context.cluster), - Some(&match &grant { - Some(grant) => json!({"grants":[grant],"ttl_seconds":ttl}), - None => json!({"version":2,"ttl_seconds":ttl}), - }), + Some(&json!({"version":2,"ttl_seconds":ttl})), None, ) .await?; super::cluster_matches(&body, &context.cluster)?; let data = &body["data"]; - if grant.is_some() - && (data.get("version").is_some_and(|version| *version != 1) - || data["token"] - .as_str() - .and_then(parse_identity_claims) - .is_some()) - { - // A response cannot upgrade an explicit restricted request, even if - // it also echoes the requested grants beside an identity credential. - return Err(Failure::protocol()); - } - if grant.is_none() - && (data["version"] != 2 - || ["grants", "roles", "actions", "groups", "policy"] - .iter() - .any(|field| data.get(field).is_some())) + if data["version"] != 2 + || ["grants", "roles", "actions", "groups", "policy"] + .iter() + .any(|field| data.get(field).is_some()) { return Err(Failure::protocol()); } @@ -322,7 +218,7 @@ async fn mint_for_principal( .ok_or_else(Failure::protocol) }; let credential = Credential { - version: if grant.is_some() { 1 } else { 2 }, + version: 2, api: context.api.clone(), cluster_id: context.cluster.clone(), endpoint: string("endpoint")?, @@ -330,32 +226,18 @@ async fn mint_for_principal( expires_at: string("expires_at")?, kid: string("kid")?, actor: string("actor")?, - cluster_incarnation: if grant.is_none() { - Some( - body["meta"]["incarnation"] - .as_str() - .ok_or_else(Failure::protocol)? - .to_owned(), - ) - } else { - None - }, - grants: if grant.is_some() { - serde_json::from_value(data["grants"].clone()).map_err(|_| Failure::protocol())? - } else { - Vec::new() - }, + cluster_incarnation: Some( + body["meta"]["incarnation"] + .as_str() + .ok_or_else(Failure::protocol)? + .to_owned(), + ), }; credential.validate(context)?; if expected_principal.is_some_and(|id| credential.actor != format!("principal:{id}")) { return Err(Failure::protocol()); } - if grant.as_ref().is_some_and(|grant| { - credential.grants.len() != 1 - || credential.grants[0].graph_id != grant.graph_id - || credential.grants[0].actions.iter().collect::>() - != grant.actions.iter().collect::>() - }) || OffsetDateTime::parse(&credential.expires_at, &Rfc3339).map_err(|_| invalid())? + if OffsetDateTime::parse(&credential.expires_at, &Rfc3339).map_err(|_| invalid())? > OffsetDateTime::now_utc() + time::Duration::seconds(ttl as i64 + 30) { return Err(Failure::protocol()); @@ -381,36 +263,27 @@ fn clear(store: &impl Store, context: &Context) -> Result { pub(super) async fn token( cli: &Cli, context: &Context, - actions: Option<&str>, ttl: Option, clear: bool, ) -> Result { scope(cli)?; if clear { - if cli.graph.is_some() || actions.is_some() || ttl.is_some() { + if cli.graph.is_some() || ttl.is_some() { return Err(Failure::refused( "token_clear_conflict", - "--clear forgets the whole cached cluster credential and cannot select a graph, actions, or TTL", + "--clear forgets the whole cached cluster credential and cannot select a graph or TTL", )); } return self::clear(&auth::DATA_STORE, context); } - let grant = if actions.is_some() { - Some(requested_grant(cli.graph.as_deref(), actions)?) - } else { - if cli.graph.is_some() { - return Err(Failure::refused( - "token_profile_conflict", - "identity credentials do not select a graph; use --graph on the graph operation, or pair it with --actions for the legacy restricted profile", - )); - } - None - }; - let api = Api::authenticated(context.api.clone())?; - match grant { - Some(grant) => mint(&auth::DATA_STORE, context, &api, grant, ttl.unwrap_or(3600)).await, - None => mint_profile(&auth::DATA_STORE, context, &api, None, ttl.unwrap_or(3600)).await, + if cli.graph.is_some() { + return Err(Failure::refused( + "token_profile_conflict", + "identity credentials do not select a graph; use --graph on the graph operation", + )); } + let api = Api::authenticated(context.api.clone())?; + mint(&auth::DATA_STORE, context, &api, ttl.unwrap_or(3600)).await } fn load_credential(store: &impl Store, context: &Context) -> Result { @@ -424,38 +297,12 @@ fn load_credential(store: &impl Store, context: &Context) -> Result return Err(invalid()); } let credential: Credential = serde_json::from_str(&raw).map_err(|_| invalid())?; - if credential.version == 2 - && serde_json::from_str::(&raw) - .map_err(|_| invalid())? - .get("grants") - .is_some() - { - return Err(invalid()); - } credential.validate(context)?; Ok(credential) } -fn load( - store: &impl Store, - context: &Context, - graph: &str, - required: &[&str], -) -> Result { +fn load(store: &impl Store, context: &Context, graph: &str) -> Result { let credential = load_credential(store, context)?; - if credential.version == 1 - && !credential.grants.iter().any(|grant| { - grant.graph_id == graph - && required - .iter() - .all(|action| grant.actions.iter().any(|a| a == action)) - }) - { - return Err(Failure::refused( - "data_scope_missing", - "the cached credential does not grant this graph and action; mint a matching cluster token", - )); - } GraphClient::managed(&credential.endpoint, graph, credential.token).map_err(|_| { Failure::new( "transport_failed", @@ -530,12 +377,6 @@ fn resolve( )); } let credential = load_credential(store, &context)?; - if credential.version != 2 { - return Err(Failure::refused( - "data_profile_unsupported", - "managed graph discovery requires an identity credential; legacy restrictions are not widened", - )); - } return GraphClient::managed_registry(&credential.endpoint, credential.token) .map(Some) .map_err(|_| { @@ -546,50 +387,17 @@ fn resolve( ) }); } - let required = match &cli.command { - Command::Query { - query, - query_string, - .. - } => { - if query.is_none() && query_string.is_none() { - vec!["read", "invoke_query"] - } else { - vec!["read"] - } - } - Command::Mutate { - query, - query_string, - .. - } => { - if query.is_none() && query_string.is_none() { - vec!["change", "invoke_query"] - } else { - vec!["change"] - } - } - Command::Load { from, .. } => { - if from.is_some() { - vec!["change", "branch_create"] - } else { - vec!["change"] - } - } - Command::Commit { .. } => vec!["read"], - _ => unreachable!("only implicit query/mutate/load and commit reads consult data context"), - }; scope(cli)?; let graph = cli .graph .as_deref() .ok_or_else(|| Failure::refused("graph_required", "managed data requires --graph"))?; graph_id(graph)?; - load(store, &context, graph, &required).map(Some) + load(store, &context, graph).map(Some) } -/// Acquire authority before constructing the operation request. An expired -/// restricted credential is never silently replaced by a broader identity. +/// Acquire authority before constructing the operation request. Unsupported +/// or malformed caches refuse before contacting the issuer. async fn resolve_with_acquisition( cli: &Cli, cwd: &std::path::Path, @@ -636,20 +444,25 @@ async fn resolve_with_acquisition( // control credential lock is acquired only inside this one. let _lock = auth::cache_lock(&format!("data:{}", key(&context))).await?; if let Some(raw) = store.get(&key(&context))? { - let saved: Credential = serde_json::from_str(&raw).map_err(|_| invalid())?; - if saved.version != 2 { - return Err(failure); + if raw.len() > MAX_CREDENTIAL { + return Err(invalid()); } - if saved.validate(&context).is_ok() - && expected - .as_ref() - .is_none_or(|id| saved.actor == format!("principal:{id}")) - { - return resolve(cli, cwd, store, ambient); + let saved: Credential = serde_json::from_str(&raw).map_err(|_| invalid())?; + match saved.validate(&context) { + Ok(()) => { + if expected + .as_ref() + .is_none_or(|id| saved.actor == format!("principal:{id}")) + { + return resolve(cli, cwd, store, ambient); + } + } + Err(error) if error.body["type"] == "data_credential_expired" => {} + Err(error) => return Err(error), } } let api = api(&context).await?; - mint_for_principal(store, &context, &api, None, 3600, expected.as_deref()).await?; + mint_for_principal(store, &context, &api, 3600, expected.as_deref()).await?; resolve(cli, cwd, store, ambient) } Err(failure) => Err(failure), diff --git a/crates/omnigraph-cli/src/managed/data/tests.rs b/crates/omnigraph-cli/src/managed/data/tests.rs index 090defcc0..0e01e2c81 100644 --- a/crates/omnigraph-cli/src/managed/data/tests.rs +++ b/crates/omnigraph-cli/src/managed/data/tests.rs @@ -23,9 +23,7 @@ async fn ordinary_graph_command_acquires_missing_or_expired_identity_once_before let store = MemoryStore::default(); if expired { let mut saved = identity_credential(&context, "https://data.example"); - saved.expires_at = (OffsetDateTime::now_utc() - time::Duration::seconds(1)) - .format(&Rfc3339) - .unwrap(); + expire_identity(&mut saved); save(&store, &context, &saved); } let dir = tempfile::tempdir().unwrap(); @@ -67,7 +65,7 @@ async fn ordinary_graph_command_acquires_missing_or_expired_identity_once_before } #[tokio::test] -async fn acquisition_preserves_explicit_target_priority_and_never_widens_restricted_credentials() { +async fn acquisition_preserves_explicit_target_priority_and_refuses_retired_credentials() { let context = context(); let dir = tempfile::tempdir().unwrap(); super::super::save_context(dir.path(), &context).unwrap(); @@ -95,24 +93,91 @@ async fn acquisition_preserves_explicit_target_priority_and_never_widens_restric .unwrap() .is_none() ); - let mut restricted = credential(&context, "https://data.example"); - restricted.expires_at = (OffsetDateTime::now_utc() - time::Duration::seconds(1)) - .format(&Rfc3339) - .unwrap(); - save(&store, &context, &restricted); let cli = Cli::try_parse_from(["omnigraph", "query", "q", "--graph", "knowledge"]).unwrap(); - let failure = resolve_with_acquisition( - &cli, - dir.path(), - &store, - || Ok(false), - async |_| Ok(None), - async |_| panic!("restricted credential widened"), - ) - .await - .err() - .unwrap(); - assert_eq!(failure.body["type"], "data_credential_expired"); + for corruption in [ + "retired-cache", + "retired-token", + "permissions", + "malformed-token", + "wrong-actor", + "wrong-expiry", + ] { + let mut cached = credential(&context, "https://data.example"); + expire_identity(&mut cached); + match corruption { + "retired-cache" => cached.version = 1, + "malformed-token" => cached.token = DATA_TOKEN.into(), + "wrong-actor" => cached.actor = "principal:other".into(), + "wrong-expiry" => { + cached.expires_at = (OffsetDateTime::now_utc() - time::Duration::seconds(60)) + .format(&Rfc3339) + .unwrap() + } + _ => { + let parts: Vec<_> = cached.token.split('.').collect(); + let mut claims: Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + if corruption == "retired-token" { + claims["version"] = json!(1); + } + claims["grants"] = json!([]); + cached.token = format!( + "{}.{}.{}", + parts[0], + URL_SAFE_NO_PAD.encode(claims.to_string()), + parts[2] + ); + } + } + save(&store, &context, &cached); + let before = store.get(&key(&context)).unwrap(); + let failure = resolve_with_acquisition( + &cli, + dir.path(), + &store, + || Ok(false), + async |_| panic!("invalid cache consulted identity"), + async |_| panic!("invalid cache called issuer"), + ) + .await + .err() + .unwrap(); + assert_eq!( + failure.body["type"], "data_credential_invalid", + "{corruption}" + ); + assert_eq!(store.get(&key(&context)).unwrap(), before, "{corruption}"); + + // A second process can replace the cache before this caller gets its + // cache lock. Revalidate those bytes rather than treating invalidity + // as permission to mint over them. + let mut initially_expired = credential(&context, "https://data.example"); + expire_identity(&mut initially_expired); + save(&store, &context, &initially_expired); + let failure = resolve_with_acquisition( + &cli, + dir.path(), + &store, + || Ok(false), + async |_| { + save(&store, &context, &cached); + Ok(Some("alice".into())) + }, + async |_| panic!("invalid replacement cache called issuer"), + ) + .await + .err() + .unwrap(); + assert_eq!( + failure.body["type"], "data_credential_invalid", + "replacement: {corruption}" + ); + assert_eq!( + store.get(&key(&context)).unwrap(), + before, + "replacement: {corruption}" + ); + } } #[tokio::test] @@ -160,45 +225,53 @@ fn context() -> Context { } fn credential(context: &Context, endpoint: &str) -> Credential { + identity_credential(context, endpoint) +} + +fn identity_credential(context: &Context, endpoint: &str) -> Credential { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let kid = "a".repeat(64); + let header = json!({"typ":"JWT","alg":"ES256","kid":kid}); + let claims = json!({"version":2,"iss":context.api,"aud":format!("urn:omnigraph:data:{}", context.cluster), + "sub":"alice","account_id":"account-a","cluster_id":context.cluster,"cluster_incarnation":"incarnation-a", + "principal_kind":"human","assurance":"verified_human","iat":now,"exp":now+3600,"jti":"test-credential"}); Credential { - version: 1, + version: 2, api: context.api.clone(), cluster_id: context.cluster.clone(), endpoint: endpoint.into(), - token: DATA_TOKEN.into(), - expires_at: (OffsetDateTime::now_utc() + time::Duration::hours(1)) + token: format!( + "{}.{}.signature", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()) + ), + expires_at: OffsetDateTime::from_unix_timestamp(now + 3600) + .unwrap() .format(&Rfc3339) .unwrap(), - kid: "a".repeat(64), + kid, actor: "principal:alice".into(), - cluster_incarnation: None, - grants: vec![Grant { - graph_id: "knowledge".into(), - actions: vec!["read".into(), "change".into(), "invoke_query".into()], - }], + cluster_incarnation: Some("incarnation-a".into()), } } -fn identity_credential(context: &Context, endpoint: &str) -> Credential { - let mut credential = credential(context, endpoint); - let now = OffsetDateTime::now_utc().unix_timestamp(); - credential.version = 2; - credential.grants.clear(); - credential.cluster_incarnation = Some("incarnation-a".into()); - credential.expires_at = OffsetDateTime::from_unix_timestamp(now + 3600) +fn expire_identity(credential: &mut Credential) { + let expiry = OffsetDateTime::now_utc().unix_timestamp() - 1; + credential.expires_at = OffsetDateTime::from_unix_timestamp(expiry) .unwrap() .format(&Rfc3339) .unwrap(); - let header = json!({"typ":"JWT","alg":"ES256","kid":credential.kid}); - let claims = json!({"version":2,"iss":context.api,"aud":format!("urn:omnigraph:data:{}", context.cluster), - "sub":"alice","account_id":"account-a","cluster_id":context.cluster,"cluster_incarnation":"incarnation-a", - "principal_kind":"human","assurance":"verified_human","iat":now,"exp":now+3600,"jti":"test-credential"}); + let parts: Vec<_> = credential.token.split('.').collect(); + let mut claims: Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + claims["iat"] = json!(expiry - 3600); + claims["exp"] = json!(expiry); credential.token = format!( - "{}.{}.signature", - URL_SAFE_NO_PAD.encode(header.to_string()), - URL_SAFE_NO_PAD.encode(claims.to_string()) + "{}.{}.{}", + parts[0], + URL_SAFE_NO_PAD.encode(claims.to_string()), + parts[2] ); - credential } fn save(store: &MemoryStore, context: &Context, credential: &Credential) { @@ -216,7 +289,7 @@ fn change_reply() -> Value { } #[test] -fn token_arguments_bound_authority_and_keep_direct_compatibility() { +fn token_arguments_bound_lifetime_and_refuse_removed_actions() { for (input, expected) in [ ("60", 60), ("1m", 60), @@ -237,9 +310,7 @@ fn token_arguments_bound_authority_and_keep_direct_compatibility() { ] { assert!(parse_ttl(bad).is_err()); } - for bad in ["", "read,read", "read,admin", "schema_apply", "*", " read"] { - assert!(requested_grant(Some("knowledge"), Some(bad)).is_err()); - } + for bad in [ "../graph", "graph_bad", @@ -249,21 +320,11 @@ fn token_arguments_bound_authority_and_keep_direct_compatibility() { "1graph", "-graph", ] { - assert!(requested_grant(Some(bad), Some("read")).is_err()); + assert!(graph_id(bad).is_err()); } assert!(Cli::try_parse_from(["omnigraph", "cluster", "token", "--clear"]).is_ok()); assert!(Cli::try_parse_from(["omnigraph", "cluster", "token"]).is_ok()); - assert!( - Cli::try_parse_from([ - "omnigraph", - "cluster", - "token", - "--clear", - "--actions", - "read" - ]) - .is_err() - ); + assert!(Cli::try_parse_from(["omnigraph", "cluster", "token", "--actions", "read"]).is_err()); assert!( Cli::try_parse_from(["omnigraph", "cluster", "status", "--direct"]) .unwrap() @@ -291,35 +352,27 @@ async fn minted_data_credential_is_separate_and_works_after_api_stops() { IntentReply::json(200, read_reply()), ]); let mut context = context(); - let mut response_credential = credential(&context, &data.origin); - response_credential.expires_at = (OffsetDateTime::now_utc() + time::Duration::seconds(3629)) - .format(&Rfc3339) - .unwrap(); - let mut response = response_credential.metadata(); - response["token"] = json!(DATA_TOKEN); - response["access_token"] = json!("must-not-be-output"); - let cp = IntentApiFixture::new(vec![IntentReply::json( - 200, - json!({"data":response,"meta":{"cluster_id":context.cluster}}), - )]); - context.api = cp.origin.clone(); + let cp = IntentApiFixture::with_origin(|origin| { + context.api = origin.to_owned(); + let response_credential = credential(&context, &data.origin); + let mut response = response_credential.metadata(); + response["token"] = json!(response_credential.token); + response["access_token"] = json!("must-not-be-output"); + vec![IntentReply::json( + 200, + json!({"data":response,"meta":{"cluster_id":context.cluster,"incarnation":"incarnation-a"}}), + )] + }); let cp_store = MemoryStore::default(); cp_store .put(&context.api, "unrelated-control-session") .unwrap(); let data_store = MemoryStore::default(); let api = Api::new(cp.origin.clone(), Some("control-session-secret".into())).unwrap(); - let output = mint( - &data_store, - &context, - &api, - response_credential.grants[0].clone(), - 3600, - ) - .await - .unwrap(); + let output = mint(&data_store, &context, &api, 3600).await.unwrap(); + let token = load_credential(&data_store, &context).unwrap().token; let rendered = output.to_string(); - assert!(!rendered.contains(DATA_TOKEN)); + assert!(!rendered.contains(&token)); assert!(!rendered.contains("must-not-be-output")); assert_eq!( cp_store.get(&context.api).unwrap().as_deref(), @@ -332,19 +385,10 @@ async fn minted_data_credential_is_separate_and_works_after_api_stops() { requests[0].headers["authorization"], "Bearer control-session-secret" ); - assert_eq!( - requests[0].body, - json!({"grants":response_credential.grants,"ttl_seconds":3600}) - ); + assert_eq!(requests[0].body, json!({"version":2,"ttl_seconds":3600})); cp.assert_complete(); drop(cp); - let client = load( - &data_store, - &context, - "knowledge", - &["read", "change", "invoke_query"], - ) - .unwrap(); + let client = load(&data_store, &context, "knowledge").unwrap(); let result = client .query( ReadTarget::Branch("main".into()), @@ -383,10 +427,7 @@ async fn minted_data_credential_is_separate_and_works_after_api_stops() { ] ); for request in &requests { - assert_eq!( - request.headers["authorization"], - format!("Bearer {DATA_TOKEN}") - ); + assert_eq!(request.headers["authorization"], format!("Bearer {token}")); } assert_eq!(requests[1].headers["omnigraph-if-graph-commit"], "head-a"); data.assert_complete(); @@ -400,10 +441,7 @@ async fn minted_data_credential_is_separate_and_works_after_api_stops() { false ); assert_eq!( - load(&data_store, &context, "knowledge", &["read"]) - .err() - .unwrap() - .body["type"], + load(&data_store, &context, "knowledge").err().unwrap().body["type"], "data_credential_required" ); assert!(cp_store.get(&context.api).unwrap().is_some()); @@ -440,9 +478,7 @@ async fn identity_issuance_caches_no_permissions_and_discovers_without_control_c }); let store = MemoryStore::default(); let api = Api::new(cp.origin.clone(), Some("control-session".into())).unwrap(); - let output = mint_profile(&store, &context, &api, None, 3600) - .await - .unwrap(); + let output = mint(&store, &context, &api, 3600).await.unwrap(); assert_eq!(output["data"]["version"], 2); assert!(output["data"].get("grants").is_none()); assert!(output["data"].get("token").is_none()); @@ -455,7 +491,7 @@ async fn identity_issuance_caches_no_permissions_and_discovers_without_control_c let saved: Value = serde_json::from_str(&store.get(&key(&context)).unwrap().unwrap()).unwrap(); assert!(saved.get("grants").is_none()); assert!( - load(&store, &context, "any-graph", &["schema_apply"]).is_ok(), + load(&store, &context, "any-graph").is_ok(), "Cedar, not the local cache, decides permission" ); let dir = tempfile::tempdir().unwrap(); @@ -554,16 +590,26 @@ async fn identity_issuance_caches_no_permissions_and_discovers_without_control_c saved ); data.assert_complete(); - save(&store, &context, &credential(&context, &data.origin)); + let mut retired = credential(&context, &data.origin); + retired.version = 1; + save(&store, &context, &retired); let failure = resolve(&cli, dir.path(), &store, || Ok(false)) .err() .unwrap(); - assert_eq!(failure.body["type"], "data_profile_unsupported"); + assert_eq!(failure.body["type"], "data_credential_invalid"); } #[tokio::test] async fn identity_issuance_rejects_wrong_profile_and_authority_without_cache_replacement() { - for field in ["version", "grants", "roles", "actor", "incarnation"] { + for field in [ + "version", + "grants", + "roles", + "actor", + "incarnation", + "endpoint", + "token", + ] { let mut context = context(); let cp = IntentApiFixture::with_origin(|origin| { context.api = origin.to_owned(); @@ -576,24 +622,24 @@ async fn identity_issuance_rejects_wrong_profile_and_authority_without_cache_rep "grants" => envelope["data"]["grants"] = json!([]), "roles" => envelope["data"]["roles"] = json!(["admin"]), "actor" => envelope["data"]["actor"] = json!("principal:other"), + "endpoint" => { + envelope["data"]["endpoint"] = json!("https://user:password@data.example/path") + } + "token" => envelope["data"]["token"] = json!("x".repeat(MAX_TOKEN + 1)), _ => envelope["meta"]["incarnation"] = json!("other"), } vec![IntentReply::json(200, envelope)] }); let store = MemoryStore::default(); - store - .put(&key(&context), "existing-restricted-credential") - .unwrap(); + store.put(&key(&context), "existing-credential").unwrap(); let api = Api::new(cp.origin.clone(), Some("control-session".into())).unwrap(); assert!( - mint_profile(&store, &context, &api, None, 3600) - .await - .is_err(), + mint(&store, &context, &api, 3600).await.is_err(), "accepted {field}" ); assert_eq!( store.get(&key(&context)).unwrap().as_deref(), - Some("existing-restricted-credential") + Some("existing-credential") ); cp.assert_complete(); } @@ -604,43 +650,17 @@ fn cached_authority_refuses_wrong_bindings_expiry_and_extra_fields() { let context = context(); let store = MemoryStore::default(); let original = credential(&context, "https://data.example"); - let mut clock_ahead = credential(&context, "https://data.example"); - clock_ahead.expires_at = (OffsetDateTime::now_utc() + time::Duration::seconds(86429)) - .format(&Rfc3339) - .unwrap(); - assert!(clock_ahead.validate(&context).is_ok()); - clock_ahead.expires_at = (OffsetDateTime::now_utc() + time::Duration::seconds(86460)) - .format(&Rfc3339) - .unwrap(); - assert!(clock_ahead.validate(&context).is_err()); save(&store, &context, &original); - assert_eq!( - load(&store, &context, "foreign", &["read"]) - .err() - .unwrap() - .body["type"], - "data_scope_missing" - ); - assert_eq!( - load(&store, &context, "knowledge", &["export"]) - .err() - .unwrap() - .body["type"], - "data_scope_missing" - ); let base = serde_json::to_value(&original).unwrap(); for (field, value) in [ - ("version", json!(2)), + ("version", json!(1)), ("api", json!("https://foreign.example")), ("cluster_id", json!("foreign")), ("endpoint", json!("https://data.example/path")), ("endpoint", json!("http://data.example")), ("endpoint", json!("https://user:secret@data.example")), ("token", json!("a.b")), - ( - "token", - json!(identity_credential(&context, "https://data.example").token), - ), + ("token", json!(DATA_TOKEN)), ("token", json!("x".repeat(MAX_TOKEN + 1))), ("kid", json!("not-a-fingerprint")), ( @@ -669,7 +689,7 @@ fn cached_authority_refuses_wrong_bindings_expiry_and_extra_fields() { corrupt[field] = value; store.put(&key(&context), &corrupt.to_string()).unwrap(); assert!( - load(&store, &context, "knowledge", &["read"]).is_err(), + load(&store, &context, "knowledge").is_err(), "accepted {field}" ); } @@ -677,59 +697,7 @@ fn cached_authority_refuses_wrong_bindings_expiry_and_extra_fields() { .unwrap() .replacen("{", "{\"version\":1,", 1); store.put(&key(&context), &duplicate).unwrap(); - assert!(load(&store, &context, "knowledge", &["read"]).is_err()); -} - -#[tokio::test] -async fn invalid_issuance_never_replaces_cached_authority() { - for corruption in [ - "extra-action", - "foreign-endpoint", - "oversize-token", - "profile-upgrade", - "hidden-profile-upgrade", - ] { - let mut context = context(); - let valid = credential(&context, "https://data.example"); - let mut response = valid.metadata(); - response["token"] = json!(DATA_TOKEN); - match corruption { - "extra-action" => response["grants"][0]["actions"] = json!(["read", "export"]), - "foreign-endpoint" => { - response["endpoint"] = json!("https://user:password@data.example/path") - } - "profile-upgrade" => response["version"] = json!(2), - "hidden-profile-upgrade" => { - response["token"] = - json!(identity_credential(&context, "https://data.example").token) - } - _ => response["token"] = json!("x".repeat(MAX_TOKEN + 1)), - } - let cp = IntentApiFixture::new(vec![IntentReply::json( - 200, - json!({"data":response,"meta":{"cluster_id":context.cluster}}), - )]); - context.api = cp.origin.clone(); - let store = MemoryStore::default(); - store.put(&key(&context), "prior-authority").unwrap(); - let api = Api::new(context.api.clone(), Some("control-only".into())).unwrap(); - assert!( - mint( - &store, - &context, - &api, - requested_grant(Some("knowledge"), Some("read")).unwrap(), - 3600 - ) - .await - .is_err() - ); - assert_eq!( - store.get(&key(&context)).unwrap().as_deref(), - Some("prior-authority") - ); - cp.assert_complete(); - } + assert!(load(&store, &context, "knowledge").is_err()); } #[test] @@ -788,16 +756,6 @@ fn managed_routing_preserves_selected_managed_authority_without_fallback() { .unwrap() .is_some() ); - let mut narrowed = cached; - narrowed.grants[0].actions = vec!["read".into()]; - save(&store, &context, &narrowed); - assert_eq!( - resolve(&query, dir.path(), &store, || Ok(false)) - .err() - .unwrap() - .body["type"], - "data_scope_missing" - ); } struct NoCredentialAccess; @@ -827,9 +785,9 @@ fn managed_data_issue_633_explicit_and_unrelated_commands_skip_context() { } for args in [ vec!["query", "q", "--server", "legacy"], - vec!["read", "q", "--profile", "legacy"], + vec!["query", "q", "--profile", "legacy"], vec!["mutate", "--store", "file:///scratch", "-e", "source"], - vec!["change", "m", "--cluster", "local"], + vec!["mutate", "m", "--cluster", "local"], vec!["query", "q", "--direct"], vec!["init", "--schema", "schema.pg", "file:///scratch"], vec![ @@ -923,6 +881,7 @@ async fn managed_commit_reads_use_exact_cached_read_authority_without_api_or_fal IntentReply::json(200, json!({"commits":[commit.clone()]})), IntentReply::json(200, commit.clone()), ]); + let cached = credential(&context, &server.origin); for command in [vec!["commit", "list"], vec!["commit", "show", "commit-a"]] { let cli = Cli::try_parse_from( ["omnigraph", "--graph", "knowledge"] @@ -944,17 +903,6 @@ async fn managed_commit_reads_use_exact_cached_read_authority_without_api_or_fal .body["type"], "data_credential_required" ); - let mut cached = credential(&context, &server.origin); - cached.grants[0].actions = vec!["change".into()]; - save(&store, &context, &cached); - assert_eq!( - resolve(&cli, dir.path(), &store, || Ok(false)) - .err() - .unwrap() - .body["type"], - "data_scope_missing" - ); - cached.grants[0].actions = vec!["read".into()]; save(&store, &context, &cached); let client = resolve(&cli, dir.path(), &store, || Ok(false)) .unwrap() @@ -986,14 +934,14 @@ async fn managed_commit_reads_use_exact_cached_read_authority_without_api_or_fal assert_eq!(request.path, path); assert_eq!( request.headers["authorization"], - format!("Bearer {DATA_TOKEN}") + format!("Bearer {}", cached.token) ); } server.assert_complete(); } #[test] -fn managed_load_requires_exact_graph_change_and_explicit_fork_authority() { +fn managed_load_uses_identity_and_keeps_target_preflight() { let dir = tempfile::tempdir().unwrap(); let context = context(); super::super::save_context(dir.path(), &context).unwrap(); @@ -1023,30 +971,18 @@ fn managed_load_requires_exact_graph_change_and_explicit_fork_authority() { .body["type"], "managed_target_ambiguous" ); - for (actions, graph, from, allowed) in [ - (vec!["read"], "knowledge", false, false), - (vec!["change"], "other", false, false), - (vec!["change"], "knowledge", false, true), - (vec!["branch_create"], "knowledge", true, false), - (vec!["change"], "knowledge", true, false), - (vec!["change", "branch_create"], "knowledge", true, true), - ] { - let mut cached = credential(&context, "https://data.example"); - cached.grants[0].graph_id = graph.into(); - cached.grants[0].actions = actions.into_iter().map(str::to_string).collect(); - save(&store, &context, &cached); - let extra = if from { - vec!["--branch", "review", "--from", "main"] - } else { - vec![] - }; + save( + &store, + &context, + &credential(&context, "https://data.example"), + ); + for extra in [vec![], vec!["--branch", "review", "--from", "main"]] { let cli = Cli::try_parse_from(args.into_iter().chain(extra)).unwrap(); - let result = resolve(&cli, dir.path(), &store, || Ok(false)); - if allowed { - assert!(result.unwrap().is_some()); - } else { - assert_eq!(result.err().unwrap().body["type"], "data_scope_missing"); - } + assert!( + resolve(&cli, dir.path(), &store, || Ok(false)) + .unwrap() + .is_some() + ); } let cli = Cli::try_parse_from(args.into_iter().chain(["--as", "fake"])).unwrap(); assert_eq!( @@ -1081,7 +1017,7 @@ fn managed_load_requires_exact_graph_change_and_explicit_fork_authority() { fn managed_data_issue_633_ambiguity_and_invalid_context_precede_credentials() { let dir = tempfile::tempdir().unwrap(); super::super::save_context(dir.path(), &context()).unwrap(); - for verb in ["query", "read", "mutate", "change"] { + for verb in ["query", "mutate"] { let cli = Cli::try_parse_from(["omnigraph", verb, "q", "--graph", "knowledge"]).unwrap(); assert_eq!( resolve(&cli, dir.path(), &NoCredentialAccess, || Ok(true)) @@ -1248,8 +1184,7 @@ async fn managed_load_sends_exact_ndjson_and_preserves_the_server_receipt() { std::time::Duration::from_millis(30_750), ); let store = MemoryStore::default(); - let mut cached = credential(&context, &server.origin); - cached.grants[0].actions = vec!["change".into(), "branch_create".into()]; + let cached = credential(&context, &server.origin); save(&store, &context, &cached); let cli = Cli::try_parse_from([ "omnigraph", @@ -1296,7 +1231,7 @@ async fn managed_load_sends_exact_ndjson_and_preserves_the_server_receipt() { assert_eq!(requests[0].headers["content-type"], "application/x-ndjson"); assert_eq!( requests[0].headers["authorization"], - format!("Bearer {DATA_TOKEN}") + format!("Bearer {}", cached.token) ); assert_eq!(requests[0].raw_body, ndjson.as_bytes()); assert!(!requests[0].headers.contains_key("x-actor-id")); diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs index 165806929..7756958f6 100644 --- a/crates/omnigraph-cli/src/output.rs +++ b/crates/omnigraph-cli/src/output.rs @@ -228,21 +228,6 @@ pub(crate) fn print_cluster_plan_human(output: &PlanOutput) { "" }; println!(" {:?} {}{bindings}", change.operation, change.resource); - if let Some(action) = change.lifecycle { - use omnigraph_cluster::PlanLifecycleAction; - let description = match action { - PlanLifecycleAction::RemoveRetainingStorage => { - "remove from cluster; retain storage" - } - PlanLifecycleAction::Adopt => "adopt the confirmed existing graph", - PlanLifecycleAction::Recreate => "create an empty graph with a new identity", - PlanLifecycleAction::RepairCatalog => "restore the verified catalog payload", - PlanLifecycleAction::CorrectSchemaContract => { - "accept the confirmed observed schema contract" - } - }; - println!(" {description}"); - } if let Some(migration) = &change.migration { if !migration.supported { println!(" migration UNSUPPORTED:"); @@ -494,40 +479,6 @@ pub(crate) fn print_load_human(payload: &LoadOutput) { } } -pub(crate) fn print_ingest_human(output: &IngestOutput) { - println!( - "ingested {} entities from {} into branch {} from {} with {} ({})", - output.total_entities, - output.uri, - output.branch, - output.base_branch.as_deref().unwrap_or("main"), - output.mode.as_str(), - if output.branch_created { - "branch created" - } else { - "branch exists" - } - ); - for declaration in &output.nodes { - println!( - "node type '{}': {} entities loaded", - declaration.name, declaration.entities_loaded - ); - } - for declaration in &output.edges { - println!( - "edge type '{}': {} entities loaded", - declaration.name, declaration.entities_loaded - ); - } - if let Some(actor_id) = &output.actor_id { - println!("actor_id: {}", actor_id); - } - if let Some(diagnostic) = output.embedding_generation { - println!("{}", diagnostic.message()); - } -} - pub(crate) fn print_schema_plan_human(uri: &str, plan: &SchemaMigrationPlan) { println!("schema plan for {}", uri); println!("supported: {}", if plan.supported { "yes" } else { "no" }); diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index 54ae067f0..535cd72f8 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -207,7 +207,13 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool .. } ), - Direct => matches!(cmd, Command::RebuildFullTextIndexes { .. }), + Direct => matches!( + cmd, + Command::RebuildFullTextIndexes { .. } + | Command::Schema { + command: SchemaCommand::Apply { .. } + } + ), Served | Local => false, }, // A profile is consumed wherever scope resolution runs: the data/ @@ -248,14 +254,13 @@ 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`/`policy` read cluster applied state). +/// the plane differs per subcommand (`schema plan`/`apply` are storage while +/// `schema show` is data; `queries`/`policy` read cluster applied state). pub(crate) fn command_plane(cmd: &Command) -> Plane { match cmd { Command::Query { .. } | Command::Mutate { .. } | Command::Load { .. } - | Command::Ingest { .. } | Command::Branch { .. } | Command::Snapshot { .. } | Command::Export { .. } @@ -264,10 +269,13 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane { | Command::Changes { .. } | Command::Graphs { .. } => Plane::Data, Command::Schema { - command: SchemaCommand::Show { .. } | SchemaCommand::Apply { .. }, + command: SchemaCommand::Show { .. }, } => Plane::Data, Command::Schema { - command: SchemaCommand::Plan { .. } | SchemaCommand::UpgradeSystemColumns { .. }, + command: + SchemaCommand::Plan { .. } + | SchemaCommand::Apply { .. } + | SchemaCommand::UpgradeSystemColumns { .. }, } => Plane::Storage, // `queries` and `policy` tooling now source their inputs from a // cluster's applied state (`--cluster`), so they live on the control @@ -304,7 +312,6 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str { Command::Embed(_) => "embed", Command::Init { .. } => "init", Command::Load { .. } => "load", - Command::Ingest { .. } => "ingest", Command::Branch { .. } => "branch", Command::Schema { command } => match command { SchemaCommand::Plan { .. } => "schema plan", @@ -436,6 +443,11 @@ fn remediation(capability: Capability, cmd: &Command) -> &'static str { match capability { Capability::Direct => match cmd { Command::Init { .. } => " Pass a storage URI.", + Command::Schema { + command: SchemaCommand::Apply { .. }, + } => { + " Pass a standalone storage URI, or deploy the schema with `cluster apply --server --config `." + } Command::Optimize { .. } | Command::RebuildFullTextIndexes { .. } | Command::Repair { .. } @@ -508,6 +520,10 @@ mod tests { parse(&["omnigraph", "changes", "baseline", "--out", "b.jsonl"]), [true, false, true, true, false, true], ), + ( + parse(&["omnigraph", "schema", "apply", "--schema", "s.pg", "g.omni"]), + [false, false, false, true, true, true], + ), ( parse(&["omnigraph", "optimize", "g.omni"]), [false, true, true, true, false, true], diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index 18edbc480..e48b6428a 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -91,12 +91,6 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { "lock_id":"server-owner", "outstanding_id":null, "lookup":null}, "active":false }); - let lifecycle = temp.path().join("lifecycle.json"); - fs::write( - &lifecycle, - r#"{"repair_catalog":["query.knowledge.find_person"]}"#, - ) - .unwrap(); let schema = fs::read_to_string(temp.path().join("people.pg")).unwrap(); for declared_storage in [Some(remote_root.as_str()), None] { let captured_config = match declared_storage { @@ -126,8 +120,6 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { "--config", ]) .arg(temp.path()) - .arg("--lifecycle") - .arg(&lifecycle) .arg("--json"), ); assert_eq!( @@ -152,10 +144,7 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { ); } assert_eq!(requests[1].body["deployment_id"], id); - assert_eq!( - requests[1].body["deployment"]["options"]["repair_catalog"], - serde_json::json!(["query.knowledge.find_person"]) - ); + assert!(requests[1].body["deployment"].get("options").is_none()); assert_eq!( requests[1].body["deployment"]["canonical_root"], remote_root @@ -871,7 +860,6 @@ fn managed_data_process_refuses_missing_graph_and_actor_override_before_keychain "forged", "--json", ], - vec!["cluster", "token", "--actions", "read", "--json"], vec![ "cluster", "token", @@ -2041,12 +2029,12 @@ fn cluster_plan_json_includes_state_cas_revision_and_lock_observation() { } #[test] -fn cluster_plan_locked_state_exits_nonzero() { +fn cluster_plan_observes_an_existing_lock() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); write_cluster_lock(temp.path(), "held-lock", "plan"); - let output = output_failure( + let output = output_success( cli() .arg("cluster") .arg("plan") @@ -2055,7 +2043,7 @@ fn cluster_plan_locked_state_exits_nonzero() { .arg("--json"), ); let json = parse_stdout_json(&output); - assert_eq!(json["ok"], false); + assert_eq!(json["ok"], true); assert_eq!(json["state_observations"]["locked"], true); assert_eq!(json["state_observations"]["lock_acquired"], false); assert_eq!(json["state_observations"]["lock_id"], "held-lock"); @@ -2066,18 +2054,8 @@ fn cluster_plan_locked_state_exits_nonzero() { "1970-01-01T00:00:00Z" ); assert!(json["state_observations"]["lock_age_seconds"].is_number()); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_lock_held" - && diagnostic["message"] - .as_str() - .unwrap() - .contains("force-unlock held-lock")), - "locked state should produce a useful diagnostic: {json}" - ); + assert_eq!(json["authority"], "observed"); + assert!(temp.path().join("__cluster/lock.json").exists()); } #[test] @@ -2130,12 +2108,12 @@ fn cluster_force_unlock_wrong_id_exits_nonzero() { } #[test] -fn cluster_locked_plan_then_force_unlock_then_plan_succeeds() { +fn cluster_plan_succeeds_before_and_after_force_unlock() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); write_cluster_lock(temp.path(), "held-lock", "plan"); - let locked = parse_stdout_json(&output_failure( + let locked = parse_stdout_json(&output_success( cli() .arg("cluster") .arg("plan") @@ -2143,7 +2121,7 @@ fn cluster_locked_plan_then_force_unlock_then_plan_succeeds() { .arg(temp.path()) .arg("--json"), )); - assert_eq!(locked["ok"], false); + assert_eq!(locked["ok"], true); assert_eq!(locked["state_observations"]["lock_id"], "held-lock"); let unlocked = parse_stdout_json(&output_success( diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs index f076086dc..a12829104 100644 --- a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -306,24 +306,28 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root if let Some(bindings) = &bindings { bindings.exercise(temp.path(), &server); } - let retained = live_graph_removal_and_readoption( - temp.path(), - &server, - &applied["deployment"]["result"]["graphs"]["tools"]["contract"], - ); - if storage_root.is_none() { - live_catalog_repair(temp.path(), &server); - } + let retained = live_graph_removal_deletes_owned_storage(temp.path(), &server, &root); assert_eq!(server.id(), original_pid); assert_eq!(server.base_url, original_url); // Reboot from the durable projection: no source apply is allowed to hide // an activation-only change that was never actually persisted. - live_policy_handoff_before_restart(temp.path(), &server); + let boot_deployment = live_policy_handoff_before_restart(temp.path(), &server); server.stop(); unlock(); let server = spawn_server_with_cluster_env(temp.path(), &server_env); + let boot_status = parse_stdout_json(&output_success(live_policy_cli("next-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--deployment-id", + &boot_deployment, + "--json", + ]))); + assert_eq!(boot_status["active"], true, "{boot_status}"); live_policy_state_survives_restart(temp.path(), &server); + assert_live_graph_absent(&server, "retired"); assert_eq!(live_snapshot(&server, "tools"), retained); assert_live_tool_data(&server); assert_live_person_query(&server); @@ -332,17 +336,13 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root } if storage_root.is_none() { // Simulate missing storage while its owner is stopped, then prove an - // unrelated deployment and explicit recovery both work in one new PID. + // unrelated deployment can complete without hiding unavailable graphs. server.stop(); unlock(); - let state: serde_json::Value = - serde_json::from_slice(&fs::read(temp.path().join("__cluster/state.json")).unwrap()) - .unwrap(); - let prior_contract = state["applied_revision"]["schema_contracts"]["peer"].clone(); fs::remove_dir_all(temp.path().join("graphs/peer.omni")).unwrap(); // Deliberately bypass the ledger while every serving writer is stopped. // Text equality must not hide the changed accepted schema contract. - let drifted = tokio::runtime::Runtime::new().unwrap().block_on(async { + let _drifted = tokio::runtime::Runtime::new().unwrap().block_on(async { let db = Omnigraph::open(temp.path().join("graphs/knowledge.omni").to_str().unwrap()) .await .unwrap(); @@ -364,35 +364,7 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root assert_eq!(read_peer().status(), 503); // Change only the tools query; the unavailable peer is unchanged. fs::write(&tools, format!(" {}", fs::read_to_string(&tools).unwrap())).unwrap(); - let unrelated = parse_stdout_json(&output_success( - cli() - .env("OMNIGRAPH_BEARER_TOKEN", "live-deployment-token") - .args([ - "cluster", - "apply", - "--server", - &recovering.base_url, - "--config", - ]) - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(unrelated["active"], true, "{unrelated}"); - assert_eq!(read_peer().status(), 503); - live_schema_correction( - temp.path(), - &recovering, - &state["applied_revision"]["schema_contracts"]["knowledge"], - &drifted, - ); - let lifecycle = temp.path().join("lifecycle.json"); - fs::write( - &lifecycle, - serde_json::to_vec(&serde_json::json!({"recreate_graphs":{"peer":prior_contract}})) - .unwrap(), - ) - .unwrap(); - let repaired = parse_stdout_json(&output_success( + let unrelated = parse_stdout_json(&output_failure( cli() .env("OMNIGRAPH_BEARER_TOKEN", "live-deployment-token") .args([ @@ -403,35 +375,48 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root "--config", ]) .arg(temp.path()) - .arg("--lifecycle") - .arg(&lifecycle) .arg("--json"), )); - assert_eq!(repaired["active"], true, "{repaired}"); + assert_eq!(unrelated["deployment"]["status"], "complete", "{unrelated}"); assert_eq!( - repaired["deployment"]["result"]["graphs"]["peer"]["outcome"], - "created" - ); - assert_ne!( - repaired["deployment"]["result"]["graphs"]["peer"]["contract"], - prior_contract + unrelated["deployment"]["result"]["converged"], true, + "{unrelated}" ); - assert_eq!(read_peer().status(), 200); + assert_eq!(unrelated["active"], false, "{unrelated}"); + assert_eq!(read_peer().status(), 503); + // Drift and missing storage cannot be repaired by accepting new identity. + for (file, expected) in [ + ("people.pg", "applied_schema_drift"), + ("peer.pg", "graph_unavailable"), + ] { + let path = temp.path().join(file); + let source = fs::read_to_string(&path).unwrap(); + fs::write(&path, format!("{source}\n")).unwrap(); + let before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let refusal = parse_stdout_json(&output_failure( + cli() + .env("OMNIGRAPH_BEARER_TOKEN", "live-deployment-token") + .args([ + "cluster", + "apply", + "--server", + &recovering.base_url, + "--config", + ]) + .arg(temp.path()) + .arg("--json"), + )); + assert!(refusal.to_string().contains(expected), "{refusal}"); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + before + ); + fs::write(&path, source).unwrap(); + } + assert_eq!(read_peer().status(), 503); assert_eq!(recovering.id(), recovery_pid); - let peer_snapshot = live_snapshot(&recovering, "peer"); - let corrected_snapshot = live_snapshot(&recovering, "knowledge"); - recovering.stop(); - unlock(); - let restarted = spawn_server_with_cluster_env(temp.path(), &server_env); - assert_eq!(live_snapshot(&restarted, "peer"), peer_snapshot); - assert_eq!(live_snapshot(&restarted, "knowledge"), corrected_snapshot); - assert_live_schema_matches_source(temp.path(), &restarted); - assert_live_person_query(&restarted); - assert_live_tool_data(&restarted); - bindings - .as_ref() - .unwrap() - .assert_after_restart(temp.path(), &restarted); + assert!(!temp.path().join("graphs/peer.omni").exists()); + assert_live_tool_data(&recovering); } } @@ -484,71 +469,10 @@ fn cluster_e2e_offline_deployment_has_root_only_receipts_and_explicit_unlock() { ) .unwrap(); fs::write(temp.path().join("people.gq"), "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.bio } }\n").unwrap(); - // A true out-of-band writer changes the graph without advancing the - // ledger. Desired schema edits alone must not authorize that drift. - let observed = tokio::runtime::Runtime::new().unwrap().block_on(async { - let db = Omnigraph::open(temp.path().join("graphs/knowledge.omni").to_str().unwrap()) - .await - .unwrap(); - db.apply_schema("node Person { name: String @key age: I32? bypassed: Bool? }\n") - .await - .unwrap(); - db.schema_contract_digest() - }); - let before_refusal = fs::read(temp.path().join("__cluster/state.json")).unwrap(); - let refused = output_failure( - cli() - .args(["cluster", "apply", "--config"]) - .arg(temp.path()) - .arg("--json"), - ); - assert_eq!( - parse_stdout_json(&refused)["diagnostics"][0]["code"], - "applied_schema_drift" - ); - let correction = temp.path().join("schema-correction.json"); - for oversized in [false, true] { - if oversized { - fs::File::create(&correction) - .unwrap() - .set_len(omnigraph_cluster::MAX_BUNDLE_BYTES as u64 + 1) - .unwrap(); - } else { - fs::write(&correction, "{").unwrap(); - } - let output = output_failure( - cli() - .args(["cluster", "apply", "--config"]) - .arg(temp.path()) - .arg("--schema-correction") - .arg(&correction) - .arg("--json"), - ); - assert!( - String::from_utf8_lossy(&output.stderr).contains(if oversized { - "deployment input limit" - } else { - "invalid schema correction JSON" - }), - "{output:?}" - ); - assert_eq!( - fs::read(temp.path().join("__cluster/state.json")).unwrap(), - before_refusal - ); - assert!(!temp.path().join("__cluster/lock.json").exists()); - } - fs::write( - &correction, - serde_json::to_vec(&serde_json::json!({"knowledge": observed})).unwrap(), - ) - .unwrap(); let output = output_success( cli() .args(["--as", "operator:deploy", "cluster", "apply", "--config"]) .arg(temp.path()) - .arg("--schema-correction") - .arg(&correction) .arg("--json"), ); let deployed = parse_stdout_json(&output); @@ -563,7 +487,6 @@ fn cluster_e2e_offline_deployment_has_root_only_receipts_and_explicit_unlock() { fs::remove_file(&config_path).unwrap(); fs::remove_file(temp.path().join("people.pg")).unwrap(); fs::remove_file(temp.path().join("people.gq")).unwrap(); - fs::remove_file(&correction).unwrap(); fs::create_dir(temp.path().join(".omnigraph")).unwrap(); fs::write(temp.path().join(".omnigraph/context"), "malformed").unwrap(); let status = parse_stdout_json(&output_success(&mut rooted(&[ @@ -586,9 +509,7 @@ fn cluster_e2e_offline_deployment_has_root_only_receipts_and_explicit_unlock() { "--json", ])); assert_eq!(refused.status.code(), Some(2)); - assert!( - String::from_utf8_lossy(&refused.stderr).contains("require config-addressed apply") - ); + assert!(String::from_utf8_lossy(&refused.stderr).contains("unexpected argument")); for args in [ vec!["cluster", "upgrade-ledger", "--writers-stopped"], vec!["cluster", "status", "--deployment-id", id], @@ -729,7 +650,7 @@ fn cluster_e2e_force_unlock_unblocks_apply() { fn cluster_e2e_lost_ledger_does_not_adopt_existing_graphs() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); - let initial = apply_cluster_fixture(temp.path()); + apply_cluster_fixture(temp.path()); let graph = temp.path().join("graphs/knowledge.omni"); let before = manifest_dataset_version(&graph); fs::remove_file(temp.path().join("__cluster/state.json")).unwrap(); @@ -740,22 +661,11 @@ fn cluster_e2e_lost_ledger_does_not_adopt_existing_graphs() { .arg("--json"), ); assert!( - parse_stdout_json(&refused)["diagnostics"][0]["code"] == "graph_adoption_required", + parse_stdout_json(&refused)["diagnostics"][0]["code"] == "graph_root_exists", "{refused:?}" ); assert_eq!(manifest_dataset_version(&graph), before); assert!(!temp.path().join("__cluster/lock.json").exists()); - let created = &initial["result"]["graphs"]["knowledge"]; - let lifecycle = temp.path().join("lifecycle.json"); - fs::write(&lifecycle, serde_json::to_vec(&serde_json::json!({"adopt_graphs": {"knowledge": { - "contract": created["contract"], "graph_manifest_version": created["graph_manifest_version"] - }}})).unwrap()).unwrap(); - let adopted = apply_lifecycle_fixture(temp.path(), &lifecycle); - assert_eq!( - adopted["result"]["graphs"]["knowledge"]["outcome"], - "adopted" - ); - assert_eq!(manifest_dataset_version(&graph), before); } #[test] @@ -765,8 +675,6 @@ fn cluster_e2e_destroyed_graph_is_not_silently_recreated() { apply_cluster_fixture(temp.path()); let graph = temp.path().join("graphs/knowledge.omni"); let before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); - let state: serde_json::Value = serde_json::from_slice(&before).unwrap(); - let contract = &state["applied_revision"]["schema_contracts"]["knowledge"]; fs::remove_dir_all(&graph).unwrap(); // A query change makes this graph affected. An unrelated/no-op deployment // must not open every declared graph merely to check its availability. @@ -795,26 +703,10 @@ fn cluster_e2e_destroyed_graph_is_not_silently_recreated() { ); assert!(!graph.exists()); assert!(!temp.path().join("__cluster/lock.json").exists()); - let lifecycle = temp.path().join("lifecycle.json"); - fs::write( - &lifecycle, - serde_json::to_vec(&serde_json::json!({"recreate_graphs":{"knowledge":contract}})).unwrap(), - ) - .unwrap(); - let recreated = apply_lifecycle_fixture(temp.path(), &lifecycle); - assert_eq!( - recreated["result"]["graphs"]["knowledge"]["outcome"], - "created" - ); - assert_ne!( - recreated["result"]["graphs"]["knowledge"]["contract"], - *contract - ); - assert!(graph.join("__manifest").exists()); } #[test] -fn cluster_e2e_declared_graph_requires_exact_removal_and_adoption() { +fn cluster_e2e_declared_graph_removal_deletes_owned_storage() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); apply_cluster_fixture(temp.path()); @@ -840,64 +732,78 @@ fn cluster_e2e_declared_graph_requires_exact_removal_and_adoption() { .join("graphs/engineering.omni/__manifest") .exists() ); + let graph = temp.path().join("graphs/engineering.omni"); + output_success( + cli() + .args([ + "mutate", + "seed", + "-e", + "query seed() { insert Service { name: \"Deleted\" } }", + "--store", + ]) + .arg(&graph), + ); + unlock_cluster_fixture(temp.path()); + output_success( + cli() + .args(["branch", "create", "retained-history", "--uri"]) + .arg(&graph), + ); + unlock_cluster_fixture(temp.path()); + let peer_before = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = + Omnigraph::open_read_only(temp.path().join("graphs/knowledge.omni").to_str().unwrap()) + .await + .unwrap(); + db.schema_contract_digest() + }); + let external = temp.path().join("external-blob"); + fs::write(&external, "external owner").unwrap(); fs::write(&config, &source).unwrap(); - let before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); - let refused = output_failure( + let plan = parse_stdout_json(&output_success( cli() - .args(["cluster", "apply", "--config"]) + .args(["cluster", "plan", "--config"]) .arg(temp.path()) .arg("--json"), + )); + assert!( + plan["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| change["resource"] == "graph.engineering" + && change["operation"] == "delete"), + "{plan}" ); + assert!(graph.exists(), "planning must not delete storage"); + let deleted = apply_cluster_fixture(temp.path()); assert_eq!( - parse_stdout_json(&refused)["diagnostics"][0]["code"], - "graph_delete_confirmation_required" - ); - assert_eq!( - fs::read(temp.path().join("__cluster/state.json")).unwrap(), - before + deleted["result"]["graphs"]["engineering"]["outcome"], "deleted", + "{deleted}" ); + assert!(!graph.exists()); assert!( temp.path() - .join("graphs/engineering.omni/__manifest") + .join("graphs/knowledge.omni/__manifest") .exists() ); - assert!(!temp.path().join("__cluster/lock.json").exists()); - let graph = temp.path().join("graphs/engineering.omni"); - let version = manifest_dataset_version(&graph); - let confirmation = serde_json::json!({ - "contract": created["result"]["graphs"]["engineering"]["contract"], - "graph_manifest_version": version + let peer_after = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = + Omnigraph::open_read_only(temp.path().join("graphs/knowledge.omni").to_str().unwrap()) + .await + .unwrap(); + db.schema_contract_digest() }); - let lifecycle = temp.path().join("lifecycle.json"); - fs::write( - &lifecycle, - serde_json::to_vec(&serde_json::json!({"delete_graphs":{"engineering":confirmation}})) - .unwrap(), - ) - .unwrap(); - let deleted = apply_lifecycle_fixture(temp.path(), &lifecycle); - assert_eq!( - deleted["result"]["graphs"]["engineering"]["outcome"], - "deleted" - ); - assert_eq!( - deleted["result"]["graphs"]["engineering"]["retained_storage"], - true - ); - assert_eq!(manifest_dataset_version(&graph), version); - fs::write(&config, &expanded).unwrap(); - fs::write( - &lifecycle, - serde_json::to_vec(&serde_json::json!({"adopt_graphs":{"engineering":confirmation}})) - .unwrap(), - ) - .unwrap(); - let adopted = apply_lifecycle_fixture(temp.path(), &lifecycle); - assert_eq!( - adopted["result"]["graphs"]["engineering"]["outcome"], - "adopted" + assert_eq!(peer_after, peer_before); + assert_eq!(fs::read_to_string(external).unwrap(), "external owner"); + let repeat = apply_cluster_fixture(temp.path()); + assert!( + repeat["result"]["graphs"].as_object().unwrap().is_empty(), + "{repeat}" ); - assert_eq!(manifest_dataset_version(&graph), version); + assert!(!graph.exists()); + assert!(!temp.path().join("__cluster/lock.json").exists()); } #[test] @@ -917,7 +823,7 @@ fn cluster_e2e_removed_v1_commands_are_not_executable() { } #[test] -fn cluster_e2e_payload_drift_requires_targeted_repair() { +fn cluster_e2e_payload_drift_requires_authoritative_restore() { let temp = tempdir().unwrap(); write_cluster_config_fixture(temp.path()); apply_cluster_fixture(temp.path()); @@ -947,30 +853,9 @@ fn cluster_e2e_payload_drift_requires_targeted_repair() { !blob.exists(), "unchanged configuration must not repair untargeted payloads" ); - let lifecycle = temp.path().join("lifecycle.json"); - fs::write( - &lifecycle, - r#"{"repair_catalog":["query.knowledge.find_person"]}"#, - ) - .unwrap(); - let plan = parse_stdout_json(&output_success( - cli() - .args(["--as", "act-cluster-test", "cluster", "plan", "--config"]) - .arg(temp.path()) - .arg("--lifecycle") - .arg(&lifecycle) - .arg("--json"), - )); - assert!( - plan["changes"] - .as_array() - .unwrap() - .iter() - .any(|change| change["lifecycle"] == "repair_catalog") - ); - assert!(!blob.exists(), "plan must remain effect-free"); - apply_lifecycle_fixture(temp.path(), &lifecycle); - assert_eq!(fs::read(&blob).unwrap(), expected); + // Restore the authoritative immutable payload from backup; apply has no + // repair override that could substitute a different policy or query. + fs::write(&blob, &expected).unwrap(); let clean = cluster_json(temp.path(), "status"); assert!( clean["diagnostics"] @@ -985,32 +870,7 @@ fn cluster_e2e_payload_drift_requires_targeted_repair() { ); } -/// Exercise lifecycle JSON through the same direct owner/unlock discipline as -/// apply_cluster_fixture; no raw ledger or graph mutation bypasses the command. -fn apply_lifecycle_fixture( - root: &std::path::Path, - lifecycle: &std::path::Path, -) -> serde_json::Value { - let result = parse_stdout_json(&output_success( - cli() - .args(["--as", "act-cluster-test", "cluster", "apply", "--config"]) - .arg(root) - .arg("--lifecycle") - .arg(lifecycle) - .arg("--json"), - )); - assert_eq!(result["status"], "complete", "{result}"); - assert_eq!(result["result"]["converged"], true, "{result}"); - unlock_cluster_fixture(root); - result -} - -fn live_apply( - config_dir: &std::path::Path, - server: &TestServer, - token: &str, - lifecycle: Option<&serde_json::Value>, -) -> serde_json::Value { +fn live_apply(config_dir: &std::path::Path, server: &TestServer, token: &str) -> serde_json::Value { let mut command = cli(); command .env("OMNIGRAPH_BEARER_TOKEN", token) @@ -1018,11 +878,6 @@ fn live_apply( .arg(config_dir) .arg("--json") .timeout(std::time::Duration::from_secs(30)); - if let Some(options) = lifecycle { - let path = config_dir.join("live-lifecycle.json"); - fs::write(&path, serde_json::to_vec(options).unwrap()).unwrap(); - command.arg("--lifecycle").arg(path); - } let result = parse_stdout_json(&output_success(&mut command)); assert_eq!(result["active"], true, "{result}"); assert_eq!( @@ -1083,10 +938,20 @@ fn assert_live_person_query(server: &TestServer) { assert_eq!(result["rows"][0]["p.name"], "Alice", "{result}"); } -fn live_graph_removal_and_readoption( +fn assert_live_graph_absent(server: &TestServer, graph: &str) { + let response = graph_http_client() + .get(format!("{}/graphs/{graph}/snapshot", server.base_url)) + .bearer_auth("live-deployment-token") + .timeout(std::time::Duration::from_secs(10)) + .send() + .unwrap(); + assert_eq!(response.status(), 404, "{}", response.text().unwrap()); +} + +fn live_graph_removal_deletes_owned_storage( config_dir: &std::path::Path, server: &TestServer, - contract: &serde_json::Value, + root: &str, ) -> serde_json::Value { output_success(live_policy_cli("live-deployment-token").args([ "mutate", @@ -1104,172 +969,124 @@ fn live_graph_removal_and_readoption( assert_live_tool_data(server); let config_path = config_dir.join("cluster.yaml"); let source = fs::read_to_string(&config_path).unwrap(); - let mut removed: serde_yaml::Value = serde_yaml::from_str(&source).unwrap(); - removed["graphs"] - .as_mapping_mut() + let mut expanded: serde_yaml::Value = serde_yaml::from_str(&source).unwrap(); + expanded["graphs"]["retired"] = expanded["graphs"]["tools"].clone(); + expanded["policies"]["graph_operators"]["applies_to"] + .as_sequence_mut() .unwrap() - .remove(serde_yaml::Value::from("tools")); - for policy in removed["policies"].as_mapping_mut().unwrap().values_mut() { - policy["applies_to"] - .as_sequence_mut() - .unwrap() - .retain(|graph| graph.as_str() != Some("tools")); - } - fs::write(&config_path, serde_yaml::to_string(&removed).unwrap()).unwrap(); + .push(serde_yaml::Value::from("retired")); + fs::write(&config_path, serde_yaml::to_string(&expanded).unwrap()).unwrap(); + live_apply(config_dir, server, "live-deployment-token"); + output_success(live_policy_cli("live-deployment-token").args([ + "mutate", + "seed", + "--server", + &server.base_url, + "--graph", + "retired", + "-e", + "query seed() { insert Tool { name: \"Deleted\" } }", + "--json", + ])); + assert!(live_get(server, "/graphs/retired/commits")["commits"].is_array()); + fs::write(&config_path, &source).unwrap(); + + // Candidate permissions cannot authorize deletion. The current policy + // denies this caller and both serving state and durable files stay intact. let status_before = live_policy_status(server, "live-deployment-token"); - let confirmation = serde_json::json!({ - "contract": contract, - "graph_manifest_version": snapshot["graph_manifest_version"], - }); - let mut stale = confirmation.clone(); - stale["graph_manifest_version"] = - (snapshot["graph_manifest_version"].as_u64().unwrap() + 1).into(); - for (options, expected) in [ - (serde_json::json!({}), "graph_delete_confirmation_required"), - ( - serde_json::json!({"delete_graphs":{"tools":stale}}), - "graph_lifecycle_confirmation_mismatch", - ), - ] { - let path = config_dir.join("live-lifecycle.json"); - fs::write(&path, serde_json::to_vec(&options).unwrap()).unwrap(); - let output = output_failure( - live_policy_cli("live-deployment-token") - .args(["cluster", "apply", "--server", &server.base_url, "--config"]) - .arg(config_dir) - .arg("--lifecycle") - .arg(path) - .arg("--json"), - ); - let refused = parse_stdout_json(&output); - assert!(refused.to_string().contains(expected), "{refused}"); - assert_eq!( - live_policy_status(server, "live-deployment-token"), - status_before - ); - assert_eq!(live_snapshot(server, "tools"), snapshot); - } - let deleted = live_apply( - config_dir, - server, - "live-deployment-token", - Some(&serde_json::json!({"delete_graphs":{"tools":confirmation}})), - ); - assert_eq!( - deleted["deployment"]["result"]["graphs"]["tools"]["outcome"], - "deleted" + let deleted_before = live_snapshot(server, "retired"); + live_policy_expect_forbidden( + live_policy_cli("reader-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json"), ); assert_eq!( - deleted["deployment"]["result"]["graphs"]["tools"]["retained_storage"], - true + live_policy_status(server, "live-deployment-token"), + status_before ); - let response = graph_http_client() - .get(format!("{}/graphs/tools/snapshot", server.base_url)) - .bearer_auth("live-deployment-token") - .timeout(std::time::Duration::from_secs(10)) - .send() - .unwrap(); - assert_eq!(response.status(), 404); - assert_live_person_query(server); + assert_eq!(live_snapshot(server, "retired"), deleted_before); - fs::write(config_path, source).unwrap(); - let adopted = live_apply( - config_dir, - server, - "live-deployment-token", - Some(&serde_json::json!({"adopt_graphs":{"tools":confirmation}})), - ); - assert_eq!( - adopted["deployment"]["result"]["graphs"]["tools"]["outcome"], - "adopted" - ); + let deleted = live_apply(config_dir, server, "live-deployment-token"); assert_eq!( - adopted["deployment"]["result"]["graphs"]["tools"]["contract"], - *contract + deleted["deployment"]["result"]["graphs"]["retired"]["outcome"], "deleted", + "{deleted}" ); + assert_live_graph_absent(server, "retired"); + tokio::runtime::Runtime::new().unwrap().block_on(async { + let storage = omnigraph::storage::storage_for_uri(root).unwrap(); + assert!( + !storage + .exists(&format!("{root}/graphs/retired.omni")) + .await + .unwrap() + ); + }); assert_eq!(live_snapshot(server, "tools"), snapshot); assert_eq!(live_get(server, "/graphs/tools/commits"), history); assert_live_tool_data(server); - snapshot -} -fn live_catalog_repair(config_dir: &std::path::Path, server: &TestServer) { - let ledger: serde_json::Value = - serde_json::from_slice(&fs::read(config_dir.join("__cluster/state.json")).unwrap()) - .unwrap(); - let digest = ledger["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"] - .as_str() - .unwrap(); - let payload = config_dir.join(format!( - "__cluster/resources/query/knowledge/find_person/{digest}.gq" - )); - let expected = fs::read(&payload).unwrap(); - let snapshot = live_snapshot(server, "knowledge"); - // The warm view can still answer; only explicit repair may restore the - // durable catalog that a fresh server must load. - fs::write(&payload, [0xff, 0xfe]).unwrap(); - assert_live_person_query(server); - live_apply(config_dir, server, "live-deployment-token", None); - assert_eq!(fs::read(&payload).unwrap(), [0xff, 0xfe]); - live_apply( - config_dir, - server, - "live-deployment-token", - Some(&serde_json::json!({"repair_catalog":["query.knowledge.find_person"]})), - ); - assert_eq!(fs::read(payload).unwrap(), expected); - assert_eq!(live_snapshot(server, "knowledge"), snapshot); - assert_live_person_query(server); -} - -fn live_schema_correction( - config_dir: &std::path::Path, - server: &TestServer, - stale: &serde_json::Value, - observed: &serde_json::Value, -) { - let before = fs::read(config_dir.join("__cluster/state.json")).unwrap(); - let path = config_dir.join("live-lifecycle.json"); - fs::write( - &path, - serde_json::to_vec(&serde_json::json!({ - "schema_corrections":{"knowledge":stale}, - })) - .unwrap(), - ) - .unwrap(); - let refused = parse_stdout_json(&output_failure( + // Reusing the original ID only returns the deletion receipt. + let id = deleted["deployment"]["result"]["id"].as_str().unwrap(); + let repeat = parse_stdout_json(&output_success( live_policy_cli("live-deployment-token") - .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .args([ + "cluster", + "apply", + "--server", + &server.base_url, + "--deployment-id", + id, + "--config", + ]) .arg(config_dir) - .arg("--lifecycle") - .arg(path) .arg("--json"), )); - assert!( - refused.to_string().contains("applied_schema_drift"), - "{refused}" - ); - assert_eq!( - fs::read(config_dir.join("__cluster/state.json")).unwrap(), - before - ); - live_apply( - config_dir, - server, - "live-deployment-token", - Some(&serde_json::json!({"schema_corrections":{"knowledge":observed}})), - ); - assert_live_schema_matches_source(config_dir, server); - assert_live_person_query(server); -} + assert_eq!(repeat, deleted); -fn assert_live_schema_matches_source(config_dir: &std::path::Path, server: &TestServer) { - let schema = live_get(server, "/graphs/knowledge/schema"); + // A later declaration creates a new, empty graph lifetime. An observation + // of the old deletion ID must never delete this replacement. + fs::write(&config_path, serde_yaml::to_string(&expanded).unwrap()).unwrap(); + let created = live_apply(config_dir, server, "live-deployment-token"); assert_eq!( - schema["schema_source"], - fs::read_to_string(config_dir.join("people.pg")).unwrap() + created["deployment"]["result"]["graphs"]["retired"]["outcome"], + "created" ); + let replacement = live_snapshot(server, "retired"); + assert_ne!(replacement, deleted_before); + let empty = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token").args([ + "query", + "tools", + "--server", + &server.base_url, + "--graph", + "retired", + "--json", + ]), + )); + assert_eq!(empty["row_count"], 0, "{empty}"); + fs::write(&config_path, &source).unwrap(); + let observed = parse_stdout_json(&output_failure( + live_policy_cli("live-deployment-token") + .args([ + "cluster", + "apply", + "--server", + &server.base_url, + "--deployment-id", + id, + "--config", + ]) + .arg(config_dir) + .arg("--json"), + )); + assert_eq!(observed["active"], false, "{observed}"); + assert_eq!(observed["deployment"], deleted["deployment"]); + assert_eq!(live_snapshot(server, "retired"), replacement); + live_apply(config_dir, server, "live-deployment-token"); + assert_live_graph_absent(server, "retired"); + snapshot } /// Exercise policy cutover through the public CLI and a real TCP listener. @@ -1315,7 +1132,7 @@ fn live_policy_grants_revocations_and_management_handoff( // The current administrator can grant data access; the candidate could // not authorize its own submission or publish the attempted mutation. fs::write(&management_path, &original_management).unwrap(); - live_apply(config_dir, server, "live-deployment-token", None); + live_apply(config_dir, server, "live-deployment-token"); let mutation = parse_stdout_json(&output_success(&mut live_policy_mutation( server, "PolicyGranted", @@ -1331,7 +1148,7 @@ fn live_policy_grants_revocations_and_management_handoff( granted.replace("actions: [read, change]", "actions: [read]"), ) .unwrap(); - live_apply(config_dir, server, "live-deployment-token", None); + live_apply(config_dir, server, "live-deployment-token"); let before = live_snapshot(server, "knowledge"); live_policy_expect_forbidden(&mut live_policy_mutation(server, "PolicyAfterRevocation")); assert_eq!(live_snapshot(server, "knowledge"), before); @@ -1344,7 +1161,7 @@ fn live_policy_grants_revocations_and_management_handoff( original_management.replace("[act-live]", "[act-next]"), ) .unwrap(); - let handoff = live_apply(config_dir, server, "live-deployment-token", None); + let handoff = live_apply(config_dir, server, "live-deployment-token"); let current = live_policy_status(server, "next-token"); assert_eq!( current["status"]["result_revision"], @@ -1365,7 +1182,7 @@ fn live_policy_grants_revocations_and_management_handoff( .arg("--json"), ); assert_eq!(live_policy_status(server, "next-token"), current); - live_apply(config_dir, server, "next-token", None); + live_apply(config_dir, server, "next-token"); live_policy_status(server, "live-deployment-token"); live_policy_expect_forbidden(live_policy_cli("next-token").args([ "cluster", @@ -1378,7 +1195,7 @@ fn live_policy_grants_revocations_and_management_handoff( assert_eq!(server.base_url, original_url); } -fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &TestServer) { +fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &TestServer) -> String { let management_path = config_dir.join("live.policy.yaml"); let current = fs::read_to_string(&management_path).unwrap(); let handoff = current.replace("[act-live]", "[act-next]"); @@ -1387,7 +1204,7 @@ fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &Tes "fixture must still grant act-live management" ); fs::write(management_path, handoff).unwrap(); - live_apply(config_dir, server, "live-deployment-token", None); + let result = live_apply(config_dir, server, "live-deployment-token"); live_policy_expect_forbidden(live_policy_cli("live-deployment-token").args([ "cluster", "status", @@ -1396,6 +1213,10 @@ fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &Tes "--json", ])); live_policy_status(server, "next-token"); + result["deployment"]["result"]["id"] + .as_str() + .unwrap() + .to_owned() } fn live_policy_state_survives_restart(config_dir: &std::path::Path, server: &TestServer) { @@ -1416,7 +1237,7 @@ fn live_policy_state_survives_restart(config_dir: &std::path::Path, server: &Tes let restored = handoff.replace("[act-next]", "[act-live]"); assert_ne!(restored, handoff, "restart fixture must retain the handoff"); fs::write(management_path, restored).unwrap(); - live_apply(config_dir, server, "next-token", None); + live_apply(config_dir, server, "next-token"); live_policy_status(server, "live-deployment-token"); live_policy_expect_forbidden(live_policy_cli("next-token").args([ "cluster", @@ -1588,7 +1409,7 @@ impl LiveBindingsFixture { .unwrap() .push("bindings".into()); }); - live_apply(config_dir, server, token, None); + live_apply(config_dir, server, token); let data = config_dir.join("binding-vectors.jsonl"); fs::write(&data, "{\"type\":\"SearchDoc\",\"data\":{\"name\":\"alpha\",\"text\":\"first\",\"embedding\":[1,0,0]}}\n{\"type\":\"SearchDoc\",\"data\":{\"name\":\"beta\",\"text\":\"second\",\"embedding\":[0,1,0]}}\n").unwrap(); output_success( @@ -1617,7 +1438,7 @@ impl LiveBindingsFixture { bindings_edit_config(config_dir, |config| { config["providers"]["embedding"]["first"]["model"] = "space-b".into(); }); - live_apply(config_dir, server, token, None); + live_apply(config_dir, server, token); bindings_nearest(server, "beta"); self.assert_embedding_requests( &self.first, @@ -1639,7 +1460,7 @@ impl LiveBindingsFixture { config["providers"]["embedding"]["replacement"] = replacement; config["graphs"]["bindings"]["embedding_provider"] = "replacement".into(); }); - live_apply(config_dir, server, token, None); + live_apply(config_dir, server, token); bindings_nearest(server, "alpha"); self.assert_embedding_requests(&self.replacement, &["space-c"], "/v2/embeddings"); self.first.assert_complete(); @@ -1690,7 +1511,7 @@ impl LiveBindingsFixture { config["graphs"]["bindings"]["external_blobs"]["allow"][0]["base"] = "s3://lifecycle-assets/second".into(); }); - live_apply(config_dir, server, token, None); + live_apply(config_dir, server, token); assert_eq!(live_snapshot(server, "bindings"), first_blob); bindings_external_load(server, "s3://lifecycle-assets/first/asset.bin", false); assert_eq!(live_snapshot(server, "bindings"), first_blob); @@ -1708,7 +1529,7 @@ impl LiveBindingsFixture { config["graphs"]["bindings"]["external_blobs"]["allow"][0]["scope"] = "embedded_only".into(); }); - live_apply(config_dir, server, token, None); + live_apply(config_dir, server, token); bindings_external_load(server, "s3://lifecycle-assets/second/asset.bin", false); assert_eq!(live_snapshot(server, "bindings"), final_snapshot); bindings_edit_config(config_dir, |config| { @@ -1717,7 +1538,7 @@ impl LiveBindingsFixture { .unwrap() .remove(serde_yaml::Value::from("external_blobs")); }); - live_apply(config_dir, server, token, None); + live_apply(config_dir, server, token); bindings_external_load(server, "s3://lifecycle-assets/second/asset.bin", false); bindings_external_redirect(server, "s3://lifecycle-assets/second/asset.bin"); assert_eq!(live_snapshot(server, "bindings"), final_snapshot); diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index b78c49976..fa8c1fb99 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -1,4 +1,4 @@ -//! Data commands: load/read/change/branch/commit/export/snapshot/policy/embed/maintenance. +//! Data commands: load/query/mutate/branch/commit/export/snapshot/policy/embed/maintenance. //! Moved verbatim from tests/cli.rs in the modularization. use std::fs; @@ -950,8 +950,8 @@ fn repair_json_reports_noop_on_clean_graph() { let output = output_success(cli().arg("repair").arg("--json").arg(&graph)); let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["confirm"], false); - assert_eq!(payload["force"], false); + assert!(payload.get("confirm").is_none()); + assert!(payload.get("force").is_none()); assert_eq!(payload["graph_manifest_version"], Value::Null); assert!(payload.get("manifest_version").is_none()); assert!(payload.get("tables").is_none()); @@ -962,7 +962,7 @@ fn repair_json_reports_noop_on_clean_graph() { })); let human = stdout_string(&output_success(cli().arg("repair").arg(&graph))); - assert!(human.contains("preview mode, 4 datasets"), "{human}"); + assert!(human.contains("4 datasets"), "{human}"); assert!(human.contains("node type 'Person'"), "{human}"); assert!(!human.contains("node:Person"), "{human}"); } @@ -999,7 +999,7 @@ fn rebuild_full_text_indexes_json_noops_without_full_text_properties() { } #[test] -fn repair_confirm_json_reports_foreign_drift_and_publishes_nothing_even_when_forced() { +fn repair_reports_foreign_drift_and_refuses_removed_flags() { let temp = tempdir().unwrap(); let graph = graph_path(temp.path()); init_graph(&graph); @@ -1007,42 +1007,36 @@ fn repair_confirm_json_reports_foreign_drift_and_publishes_nothing_even_when_for let graph_manifest_before = manifest_dataset_version(&graph); let (table_manifest_before, table_head_before) = forge_person_foreign_commit(&graph); - for force in [false, true] { - let mut command = cli(); - command.arg("repair").arg("--confirm"); - if force { - command.arg("--force"); - } - let output = output_success(command.arg("--json").arg(&graph)); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!( - payload["graph_manifest_version"], - Value::Null, - "force {force}" - ); - let person = payload["datasets"] - .as_array() - .unwrap() - .iter() - .find(|dataset| dataset["type_key"] == "node:Person") - .unwrap(); - assert_eq!(person["classification"], "foreign_drift", "force {force}"); - assert_eq!(person["action"], "no_op", "force {force}"); - assert_eq!(person["published_dataset_version"], table_manifest_before); - assert_eq!(person["lance_head_version"], table_head_before); - assert!( - person["operations"][0] - .as_str() - .is_some_and(|operation| operation.contains("foreign linear version")), - "force {force}: {}", - person["operations"] - ); + let output = output_success(cli().arg("repair").arg("--json").arg(&graph)); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["graph_manifest_version"], Value::Null); + let person = payload["datasets"] + .as_array() + .unwrap() + .iter() + .find(|dataset| dataset["type_key"] == "node:Person") + .unwrap(); + assert_eq!(person["classification"], "foreign_drift"); + assert_eq!(person["action"], "no_op"); + assert_eq!(person["published_dataset_version"], table_manifest_before); + assert_eq!(person["lance_head_version"], table_head_before); + assert!( + person["operations"][0] + .as_str() + .is_some_and(|operation| operation.contains("foreign linear version")), + "{}", + person["operations"] + ); + assert_eq!(manifest_dataset_version(&graph), graph_manifest_before); + for flag in ["--confirm", "--force"] { + let refused = output_failure(cli().arg("repair").arg(flag).arg(&graph)); + assert_eq!(refused.status.code(), Some(2)); assert_eq!(manifest_dataset_version(&graph), graph_manifest_before); } } #[test] -fn query_lint_json_with_schema_reports_warnings() { +fn lint_json_with_schema_reports_warnings() { let temp = tempdir().unwrap(); let schema_path = temp.path().join("schema.pg"); let query_path = temp.path().join("queries.gq"); @@ -1072,7 +1066,6 @@ query list_policies() { let output = output_success( cli() - .arg("query") .arg("lint") .arg("--query") .arg(&query_path) @@ -1121,7 +1114,7 @@ query list_policies() { } #[test] -fn query_lint_json_omits_operation_after_compile_failure() { +fn lint_json_omits_operation_after_compile_failure() { let temp = tempdir().unwrap(); let schema_path = temp.path().join("schema.pg"); let query_path = temp.path().join("queries.gq"); @@ -1144,7 +1137,6 @@ query broken($slug: String) { let output = output_failure( cli() - .arg("query") .arg("lint") .arg("--query") .arg(&query_path) @@ -1160,187 +1152,46 @@ query broken($slug: String) { } #[test] -fn lint_top_level_matches_deprecated_query_lint_output() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Person { - name: String -} -"#, - ); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let canonical = output_success( - cli() - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let deprecated_lint = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let deprecated_check = output_success( - cli() - .arg("query") - .arg("check") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - - assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_lint)); - assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); - - // Canonical form must NOT emit the deprecation warning. - let canonical_stderr = String::from_utf8(canonical.stderr).unwrap(); - assert!( - !canonical_stderr.contains("deprecated"), - "`omnigraph lint` is canonical and must not warn; got stderr: {canonical_stderr}" - ); - - // Deprecated forms MUST emit the one-line warning, pointing at the - // new top-level `omnigraph lint`. - let lint_stderr = String::from_utf8(deprecated_lint.stderr).unwrap(); - assert!( - lint_stderr.contains("`omnigraph query lint` is deprecated") - && lint_stderr.contains("`omnigraph lint`"), - "expected deprecation warning pointing at `omnigraph lint`; got: {lint_stderr}" - ); - let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); - assert!( - check_stderr.contains("`omnigraph query check` is deprecated") - && check_stderr.contains("`omnigraph lint`"), - "expected deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" - ); -} - -#[test] -fn deprecated_check_top_level_rewrites_to_lint() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Person { - name: String -} -"#, - ); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let canonical = output_success( - cli() - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let deprecated_check = output_success( - cli() - .arg("check") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - - assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); - - let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); - assert!( - check_stderr.contains("`omnigraph check` is deprecated") - && check_stderr.contains("`omnigraph lint`"), - "expected `omnigraph check` deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" - ); - - // `check` must NOT appear in the canonical `omnigraph --help` output — - // agents copy the surface from help text and would otherwise emit both - // names interchangeably. - let help = cli().arg("--help").output().unwrap(); - let stdout = String::from_utf8(help.stdout).unwrap(); - let check_aliased = stdout - .lines() - .any(|line| line.trim_start().starts_with("lint") && line.contains("check")); - assert!( - !check_aliased, - "`check` must not be advertised as a visible alias of `lint`; help output: {stdout}" - ); -} - -#[test] -fn deprecated_read_and_change_subcommands_emit_warnings() { - // Both subcommands require `--query`/`--query-string`, so invoking them - // with no args will exit non-zero. That's fine -- we only care that the - // deprecation warning is printed before the argument-required error. - let output = cli().arg("read").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("`omnigraph read` is deprecated") && stderr.contains("`omnigraph query`"), - "expected `omnigraph read` deprecation warning; got: {stderr}" - ); - - let output = cli().arg("change").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("`omnigraph change` is deprecated") - && stderr.contains("`omnigraph mutate`"), - "expected `omnigraph change` deprecation warning; got: {stderr}" - ); - - // Sanity check the inverse: the canonical names must NOT print the - // deprecation banner. - let output = cli().arg("query").arg("--help").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - !stderr.contains("deprecated"), - "`omnigraph query` is canonical and must not warn; got: {stderr}" - ); - let output = cli().arg("mutate").arg("--help").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - !stderr.contains("deprecated"), - "`omnigraph mutate` is canonical and must not warn; got: {stderr}" - ); +fn removed_cli_spellings_refuse_and_canonical_commands_remain_available() { + for args in [ + vec!["read"], + vec!["change"], + vec!["ingest"], + vec!["check"], + vec![ + "query", + "lint", + "--query", + "missing.gq", + "--schema", + "missing.pg", + ], + vec![ + "query", + "check", + "--query", + "missing.gq", + "--schema", + "missing.pg", + ], + vec!["export", "--jsonl"], + ] { + let output = output_failure(cli().args(&args)); + assert_eq!(output.status.code(), Some(2), "{args:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("unrecognized subcommand") || stderr.contains("unexpected argument"), + "{args:?}: {stderr}" + ); + assert!(output.stdout.is_empty(), "{args:?}: {output:?}"); + } + for command in ["query", "mutate", "load", "lint", "export"] { + output_success(cli().args([command, "--help"])); + } } #[test] -fn query_lint_can_use_local_graph_via_positional_uri() { +fn lint_can_use_local_graph_via_positional_uri() { let temp = tempdir().unwrap(); let graph = graph_path(temp.path()); let query_path = temp.path().join("queries.gq"); @@ -1357,7 +1208,6 @@ query list_people() { let output = output_success( cli() - .arg("query") .arg("lint") .arg("--query") .arg(&query_path) @@ -1375,7 +1225,7 @@ query list_people() { } #[test] -fn query_lint_can_resolve_graph_from_store_scope() { +fn lint_can_resolve_graph_from_store_scope() { // RFC-011: lint resolves its graph target through `--store` (the direct // scope), not omnigraph.yaml's cli.graph; the .gq path is plain cwd-relative. let temp = tempdir().unwrap(); @@ -1394,7 +1244,6 @@ query list_people() { let output = output_success( cli() - .arg("query") .arg("lint") .arg("--query") .arg(&query_path) @@ -1413,7 +1262,7 @@ query list_people() { } #[test] -fn query_lint_rejects_http_targets_without_schema() { +fn lint_rejects_http_targets_without_schema() { let temp = tempdir().unwrap(); let query_path = temp.path().join("queries.gq"); write_query_file( @@ -1428,7 +1277,6 @@ query list_people() { let output = output_failure( cli() - .arg("query") .arg("lint") .arg("--query") .arg(&query_path) @@ -1446,7 +1294,7 @@ query list_people() { } #[test] -fn query_lint_requires_schema_or_resolvable_graph_target() { +fn lint_requires_schema_or_resolvable_graph_target() { let temp = tempdir().unwrap(); let query_path = temp.path().join("queries.gq"); write_query_file( @@ -1459,13 +1307,7 @@ query list_people() { "#, ); - let output = output_failure( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path), - ); + let output = output_failure(cli().arg("lint").arg("--query").arg(&query_path)); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("lint requires --schema ") @@ -1475,7 +1317,7 @@ query list_people() { } #[test] -fn query_lint_human_output_reports_warnings() { +fn lint_human_output_reports_warnings() { let temp = tempdir().unwrap(); let schema_path = temp.path().join("schema.pg"); let query_path = temp.path().join("queries.gq"); @@ -1500,7 +1342,6 @@ query update_policy($slug: String, $name: String) { let output = output_success( cli() - .arg("query") .arg("lint") .arg("--query") .arg(&query_path) @@ -1519,7 +1360,7 @@ query update_policy($slug: String, $name: String) { } #[test] -fn query_lint_human_output_reports_strict_validation_errors() { +fn lint_human_output_reports_strict_validation_errors() { let temp = tempdir().unwrap(); let schema_path = temp.path().join("schema.pg"); let query_path = temp.path().join("queries.gq"); @@ -1543,7 +1384,6 @@ query bad_update($slug: String) { let output = output_failure( cli() - .arg("query") .arg("lint") .arg("--query") .arg(&query_path) @@ -1676,7 +1516,7 @@ fn read_json_outputs_rows_for_named_query() { let output = output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -1788,8 +1628,7 @@ fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() { .arg("--branch") .arg("feature") .arg("--type") - .arg("Person") - .arg("--jsonl"), + .arg("Person"), ); let rows = stdout_string(&output) .lines() @@ -1911,7 +1750,7 @@ fn read_resolves_uri_from_default_store_scope() { let output = output_success( cli() .env("OMNIGRAPH_HOME", home.path()) - .arg("read") + .arg("query") .arg("--query") .arg(fixture("test.gq")) .arg("get_person") @@ -1932,7 +1771,7 @@ fn read_csv_format_outputs_header_and_row_values() { let output = output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -1969,7 +1808,7 @@ fn read_uses_operator_default_output_format() { let mut command = cli(); command .env("OMNIGRAPH_HOME", operator_home.path()) - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -2004,7 +1843,7 @@ fn read_jsonl_format_outputs_metadata_header_first() { let output = output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -2039,7 +1878,7 @@ query insert_person($name: String, $age: I32) { let output = output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(&graph) .arg("--query") @@ -2058,7 +1897,7 @@ query insert_person($name: String, $age: I32) { let verify = output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -2431,14 +2270,11 @@ fn data_write_outcomes_and_retry_permission_issue_466() { let temp = tempdir().unwrap(); let data = temp.path().join("batch.ndjson"); fs::write(&data, "").unwrap(); - let schema = temp.path().join("schema.pg"); - fs::write(&schema, "node Person { name: String }").unwrap(); let data = data.to_str().unwrap(); - let schema = schema.to_str().unwrap(); let commands = [ vec!["mutate", "stored_write"], vec!["mutate", "-e", "mutation write() {}"], - vec!["change", "-e", "branch create review"], + vec!["mutate", "-e", "branch create review"], vec!["branch", "create", "review"], vec!["branch", "delete", "review", "--yes"], vec!["branch", "merge", "review"], @@ -2446,8 +2282,6 @@ fn data_write_outcomes_and_retry_permission_issue_466() { vec![ "load", "--data", data, "--mode", "merge", "--branch", "review", "--from", "main", ], - vec!["ingest", "--data", data], - vec!["schema", "apply", "--schema", schema], ]; let cases = [ ( @@ -2738,7 +2572,7 @@ query insert_person($name: String, $age: I32) { let output = output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(&graph) .arg("--query") @@ -2761,7 +2595,7 @@ fn read_requires_name_for_multi_query_files() { let output = output_failure( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -2783,7 +2617,7 @@ fn read_refuses_an_empty_source_as_no_query() { let output = output_failure( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -2803,7 +2637,7 @@ fn read_supports_inline_query_string() { let output = output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&repo) .arg("-e") @@ -2901,7 +2735,7 @@ fn change_supports_inline_query_string() { let output = output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(&repo) .arg("--query-string") @@ -2916,7 +2750,7 @@ fn change_supports_inline_query_string() { let verify = output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&repo) .arg("-e") @@ -2938,7 +2772,7 @@ fn read_rejects_query_string_combined_with_query() { let output = output_failure( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&repo) .arg("--query") @@ -2962,7 +2796,7 @@ fn read_rejects_empty_query_string() { let output = output_failure( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&repo) .arg("-e") diff --git a/crates/omnigraph-cli/tests/cli_queries.rs b/crates/omnigraph-cli/tests/cli_queries.rs index 1898cdd49..3c5457425 100644 --- a/crates/omnigraph-cli/tests/cli_queries.rs +++ b/crates/omnigraph-cli/tests/cli_queries.rs @@ -555,7 +555,7 @@ fn a_source_this_cli_cannot_parse_is_sent_to_the_server_verbatim() { }); for (verb, route, source_field) in [ ("query", "/graphs/g/query", "query"), - ("mutate", "/graphs/g/change", "query_source"), + ("mutate", "/graphs/g/mutate", "query"), ] { let server = IntentApiFixture::graph(vec![IntentReply::json(400, refusal.clone())]); let output = output_failure( @@ -844,53 +844,6 @@ fn set_flag_refusals_happen_before_any_open_or_round_trip() { } } -#[test] -fn query_check_alias_matches_lint_output() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Person { - name: String -} -"#, - ); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let lint_output = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let check_output = output_success( - cli() - .arg("query") - .arg("check") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - - assert_eq!(stdout_string(&lint_output), stdout_string(&check_output)); -} - // Legacy `omnigraph.yaml` `aliases:` invoked via the `--alias` flag were // removed in RFC-011 D4 — operator aliases now live under `omnigraph alias // ` (the happy path is covered by system_local's operator-alias e2e). diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index e0fe44cc8..478c93782 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -78,14 +78,9 @@ fn graph_vocabulary_help_exposes_only_canonical_export_selection() { !root_help .lines() .any(|line| line.trim_start().starts_with("ingest")), - "the compatibility command must remain hidden from root help:\n{root_help}" + "removed commands must be absent from root help:\n{root_help}" ); - let ingest_help = stdout_string(&output_success(cli().arg("ingest").arg("--help"))); - assert!(ingest_help.contains("Deprecated permissive loader")); - assert!(ingest_help.contains("--from")); - assert!(ingest_help.contains("--mode")); - let export_help = stdout_string(&output_success(cli().arg("export").arg("--help"))); assert!(export_help.contains("--type")); assert!(!export_help.contains("--table")); @@ -191,24 +186,32 @@ fn schema_plan_json_reports_supported_additive_change() { #[test] fn schema_plan_with_server_flag_errors_wrong_plane() { - // RFC-010 Slice 1: `schema plan` is storage-plane while `schema show/apply` - // are data-plane — the guard rejects --server on plan with the per-subcommand - // label (proving command_plane/command_label descend into the nested enum). - let output = output_failure( - cli() - .arg("schema") - .arg("plan") - .arg("--schema") - .arg(fixture("test.pg")) - .arg("--server") - .arg("prod"), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("`schema plan` is a direct (storage-native) command") - && stderr.contains("Pass a storage URI."), - "schema plan wrong-capability message not found; got: {stderr}" - ); + let server = support::managed_http::IntentApiFixture::graph(Vec::new()); + for command in ["plan", "apply"] { + let output = output_failure( + cli() + .args(["schema", command, "--schema"]) + .arg(fixture("test.pg")) + .args(["--server", &server.origin]), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!( + "`schema {command}` is a direct (storage-native) command" + )), + "schema {command} wrong-capability message not found; got: {stderr}" + ); + if command == "apply" { + assert!(stderr.contains("cluster apply --server"), "{stderr}"); + } else { + assert!(stderr.contains("Pass a storage URI."), "{stderr}"); + } + assert!( + server.requests().is_empty(), + "refuse before discovery or dispatch" + ); + } + server.assert_complete(); } #[test] @@ -250,6 +253,18 @@ fn schema_apply_json_applies_supported_migration() { let graph = graph_path(temp.path()); let schema_path = temp.path().join("next.pg"); init_graph(&graph); + load_fixture(&graph); + let read = |source: &str| { + parse_stdout_json(&output_success( + cli() + .args(["query", "people", "-e", source, "--json", "--store"]) + .arg(&graph), + )) + }; + let before = read( + "query people() { match { $p: Person } return { $p.name, $p.age } order { $p.name } }", + ); + assert!(before["row_count"].as_u64().unwrap() > 0); let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( " age: I32?\n}", @@ -272,6 +287,21 @@ fn schema_apply_json_applies_supported_migration() { assert_eq!(payload["applied"], true); assert_eq!(payload["step_count"], 1); + // This preserves the old HTTP mirror's AddProperty row-preservation + // assertion at the supported standalone CLI boundary. + let after = read( + "query people() { match { $p: Person } return { $p.name, $p.age, $p.nickname is null as nickname_is_null } order { $p.name } }", + ); + assert_eq!(after["row_count"], before["row_count"]); + let mut rows = after["rows"].as_array().unwrap().clone(); + for row in &mut rows { + assert_eq!( + row.as_object_mut().unwrap().remove("nickname_is_null"), + Some(Value::Bool(true)) + ); + } + assert_eq!(Value::Array(rows), before["rows"]); + let db = tokio::runtime::Runtime::new() .unwrap() .block_on(Omnigraph::open(graph.to_string_lossy().as_ref())) diff --git a/crates/omnigraph-cli/tests/parity_matrix.rs b/crates/omnigraph-cli/tests/parity_matrix.rs index a6ebcd1bb..752872ea5 100644 --- a/crates/omnigraph-cli/tests/parity_matrix.rs +++ b/crates/omnigraph-cli/tests/parity_matrix.rs @@ -687,7 +687,8 @@ fn parity_load_embedding_diagnostics() { "\n", r#"{"type":"Doc","data":{"slug":"supplied","body":"keep vector","embedding":[0.25,0.75]}}"#, )).unwrap(); - for verb in ["load", "ingest"] { + { + let verb = "load"; for structured in [true, false] { let mut args = vec![ verb, @@ -719,7 +720,7 @@ fn parity_load_embedding_diagnostics() { assert!(human.contains("omnigraph embed"), "{verb} {arm}: {human}"); } } - if verb == "load" && structured { + if structured { assert_write_parity("load embedding diagnostics", &local, &remote); } } @@ -748,7 +749,8 @@ fn parity_load_embedding_diagnostics() { r#"{"type":"Person","data":{"name":"Plain","age":1}}"#, ) .unwrap(); - for verb in ["load", "ingest"] { + { + let verb = "load"; let (local, remote) = p.run(&[ verb, "--branch", @@ -1169,9 +1171,6 @@ fn parity_errors_share_exit_codes() { // // - `graphs list`: server-only today; becomes Both-capability when the // embedded arm enumerates the cluster catalog (RFC-009 open Q3, answered). -// - `ingest`: deprecated permissive loader; its remote arm rides the -// deprecated JSON /ingest route. Canonical `load` is strict graph-batch on -// both arms; the remote arm sends raw NDJSON to `/load/ndjson`. // - `init`, `optimize`, `repair`, `cleanup`, `cluster *`: storage-plane by // design (must work with the server down); Phase 4 declares this. #[allow(dead_code)] diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index 4d7cc9781..48cf34bac 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -208,7 +208,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() { let read_before = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -223,7 +223,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() { let change_payload = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(graph.path()) .arg("--query") @@ -239,7 +239,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() { let read_after = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -257,7 +257,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() { // engine identically, including param binding and `branch=main` defaults. let inline_change = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(graph.path()) .arg("-e") @@ -272,7 +272,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() { let inline_read = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query-string") @@ -304,7 +304,7 @@ fn local_cli_end_to_end_branch_change_merge_flow() { let change_payload = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(graph.path()) .arg("--query") @@ -321,7 +321,7 @@ fn local_cli_end_to_end_branch_change_merge_flow() { let feature_read = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -349,7 +349,7 @@ fn local_cli_end_to_end_branch_change_merge_flow() { let main_read = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -386,24 +386,18 @@ fn local_cli_ingest_creates_review_branch_and_keeps_it_readable() { let ingest_output = output_success( cli() - .arg("ingest") + .arg("load") + .arg("--mode") + .arg("merge") .arg("--data") .arg(&ingest_data) .arg("--branch") .arg("feature-ingest") + .arg("--from") + .arg("main") .arg(graph.path()) .arg("--json"), ); - // The deprecation warning goes to stderr so --json stdout stays clean. - let ingest_stderr = String::from_utf8_lossy(&ingest_output.stderr); - assert!( - ingest_stderr.contains("deprecated loader command"), - "ingest must describe its deprecated parser/default status on stderr: {ingest_stderr}" - ); - assert!( - !ingest_stderr.contains("will be removed"), - "ingest must not promise removal: {ingest_stderr}" - ); let ingest_payload = parse_stdout_json(&ingest_output); assert_eq!(ingest_payload["branch"], "feature-ingest"); assert_eq!(ingest_payload["base_branch"], "main"); @@ -418,15 +412,19 @@ fn local_cli_ingest_creates_review_branch_and_keeps_it_readable() { let human_ingest = output_success( cli() - .arg("ingest") + .arg("load") + .arg("--mode") + .arg("merge") .arg("--data") .arg(&ingest_data) .arg("--branch") .arg("feature-ingest-human") + .arg("--from") + .arg("main") .arg(graph.path()), ); let human_stdout = stdout_string(&human_ingest); - assert!(human_stdout.contains("node type 'Person': 2 entities loaded")); + assert!(human_stdout.contains("2 entities across 1 node types and 0 edge types")); assert!(!human_stdout.contains("node:Person")); assert!(!human_stdout.contains("rows_loaded")); @@ -442,7 +440,7 @@ fn local_cli_ingest_creates_review_branch_and_keeps_it_readable() { let zoe = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -459,7 +457,7 @@ fn local_cli_ingest_creates_review_branch_and_keeps_it_readable() { let bob = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -598,8 +596,7 @@ fn local_cli_export_round_trips_full_branch_graph() { .arg("export") .arg(graph.path()) .arg("--branch") - .arg("feature") - .arg("--jsonl"), + .arg("feature"), )); let export_path = graph.write_jsonl("system-local-exported.jsonl", &exported); let imported_graph = graph.path().parent().unwrap().join("imported-export.omni"); @@ -640,7 +637,7 @@ fn local_cli_export_round_trips_full_branch_graph() { let eve = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&imported_graph) .arg("--query") @@ -655,7 +652,7 @@ fn local_cli_export_round_trips_full_branch_graph() { let friends = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&imported_graph) .arg("--query") @@ -721,7 +718,7 @@ fn cli_object_store_end_to_end_init_load_read_flow(graph_uri: &str) { let read = parse_stdout_json(&output_success( cli() .current_dir(query_root) - .arg("read") + .arg("query") .arg("--store") .arg(graph_uri) .arg("--query") @@ -786,7 +783,7 @@ fn local_cli_failed_change_keeps_target_state_unchanged() { let output = output_failure( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(graph.path()) .arg("--query") @@ -799,7 +796,7 @@ fn local_cli_failed_change_keeps_target_state_unchanged() { let friends_payload = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -857,7 +854,7 @@ query get_person($name: String) { let payload = parse_stdout_json(&output_success( cli() .current_dir(&cwd_dir) - .arg("read") + .arg("query") .arg("--store") .arg(graph.path()) .arg("--query") @@ -969,7 +966,7 @@ query get_task($slug: String) { let filtered = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -994,7 +991,7 @@ query get_task($slug: String) { let insert_payload = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(&graph) .arg("--query") @@ -1010,7 +1007,7 @@ query get_task($slug: String) { let update_payload = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--store") .arg(&graph) .arg("--query") @@ -1024,7 +1021,7 @@ query get_task($slug: String) { let gamma = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -1112,7 +1109,7 @@ query vector_search($q: String) { // auto-embedded `$q` lands in the same vector space. .env("OMNIGRAPH_EMBED_PROVIDER", "gemini") .env("OMNIGRAPH_EMBED_MODEL", "gemini-embedding-2-preview") - .arg("read") + .arg("query") .arg("--store") .arg(&graph) .arg("--query") @@ -1226,7 +1223,7 @@ fn local_cli_change_enforces_engine_layer_policy() { // Case 1: no token → the server refuses before any policy check. let no_token = cli() - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1246,7 +1243,7 @@ fn local_cli_change_enforces_engine_layer_policy() { // Case 2: bruno token against protected main → denied by the server. let denied = cli() .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1271,7 +1268,7 @@ fn local_cli_change_enforces_engine_layer_policy() { let allowed = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1293,7 +1290,7 @@ fn local_cli_change_enforces_engine_layer_policy() { let verify = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1324,7 +1321,7 @@ fn local_cli_direct_store_write_is_unpoliced_regardless_of_actor() { cli() .arg("--as") .arg("act-bruno") - .arg("change") + .arg("mutate") .arg("--store") .arg(graph.path()) .arg("--query") @@ -1451,7 +1448,9 @@ fn local_cli_ingest_enforces_engine_layer_policy() { let denied = cli() .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") - .arg("ingest") + .arg("load") + .arg("--mode") + .arg("merge") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1460,6 +1459,8 @@ fn local_cli_ingest_enforces_engine_layer_policy() { .arg(&data) .arg("--branch") .arg("policy-ingest-feature") + .arg("--from") + .arg("main") .arg("--json") .output() .unwrap(); @@ -1475,7 +1476,9 @@ fn local_cli_ingest_enforces_engine_layer_policy() { let allowed = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok") - .arg("ingest") + .arg("load") + .arg("--mode") + .arg("merge") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1484,6 +1487,8 @@ fn local_cli_ingest_enforces_engine_layer_policy() { .arg(&data) .arg("--branch") .arg("policy-ingest-feature") + .arg("--from") + .arg("main") .arg("--json"), )); assert_eq!(allowed["branch"], "policy-ingest-feature"); @@ -1708,7 +1713,7 @@ fn local_cli_actor_from_config_used_when_no_flag() { let allowed = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_HOME", home.path()) - .arg("change") + .arg("mutate") .arg("--store") .arg(graph.path()) .arg("--query") @@ -1736,7 +1741,7 @@ fn local_cli_actor_flag_overrides_config_actor() { .env("OMNIGRAPH_HOME", home.path()) .arg("--as") .arg("act-bruno") - .arg("change") + .arg("mutate") .arg("--store") .arg(graph.path()) .arg("--query") @@ -2058,11 +2063,11 @@ fn skip_system_e2e(test_name: &str) -> bool { /// The whole control-plane story in one test: declare two graphs → converge /// (apply creates them) → serve → direct schema/query evolution → serve the new -/// shape → exact acknowledged drift correction → unconfirmed deletion refuses. +/// shape → delete one graph and its storage → refuse foreign schema drift. /// The live same-PID journey is owned by cli_cluster_e2e. #[test] -fn local_cluster_full_lifecycle_declare_serve_evolve_refuse_delete() { - if skip_system_e2e("local_cluster_full_lifecycle_declare_serve_evolve_refuse_delete") { +fn local_cluster_full_lifecycle_declare_serve_evolve_delete() { + if skip_system_e2e("local_cluster_full_lifecycle_declare_serve_evolve_delete") { return; } let temp = tempfile::tempdir().unwrap(); @@ -2153,16 +2158,45 @@ fn local_cluster_full_lifecycle_declare_serve_evolve_refuse_delete() { ); } + unlock_cluster_fixture(dir); + // Removing a declaration deletes its entire owned root. Reboot from the + // achieved catalog: the removed graph stays absent and its peer keeps data. + fs::write(dir.join("cluster.yaml"), "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries:\n find_person:\n file: ./people.gq\n").unwrap(); + let deleted = cluster_cli(dir, &["apply"]); + assert_eq!( + deleted["result"]["graphs"]["engineering"]["outcome"], "deleted", + "{deleted}" + ); + assert!(!dir.join("graphs/engineering.omni").exists()); + { + let server = spawn_server_with_cluster(dir); + let (status, body) = invoke_query( + &client, + &server.base_url, + "engineering", + "find_service", + serde_json::json!({"name":"billing"}), + ); + assert_eq!(status, 404, "{body}"); + let (status, body) = invoke_query( + &client, + &server.base_url, + "knowledge", + "find_person", + serde_json::json!({"name":"Ada"}), + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["rows"][0]["p.name"], "Ada", "{body}"); + } unlock_cluster_fixture(dir); // A raw storage holder deliberately changes the accepted contract. Desired - // text alone must not adopt that drift; acknowledgement binds its identity. + // text alone must not adopt that drift. let rogue_pg = "node Person { name: String @key bio: String? rogue: String? }\n"; - let observed = tokio::runtime::Runtime::new().unwrap().block_on(async { + tokio::runtime::Runtime::new().unwrap().block_on(async { let db = Omnigraph::open(dir.join("graphs/knowledge.omni").to_string_lossy().as_ref()) .await .unwrap(); db.apply_schema(rogue_pg).await.unwrap(); - db.schema_contract_digest() }); // Apply validates only affected graphs. Change this graph's query payload // so its exact achieved contract must be checked before catalog publication. @@ -2174,60 +2208,29 @@ fn local_cluster_full_lifecycle_declare_serve_evolve_refuse_delete() { assert_eq!(refused["ok"], false, "{refused}"); assert_eq!(refused["diagnostics"][0]["code"], "applied_schema_drift"); assert_eq!(fs::read(dir.join("__cluster/state.json")).unwrap(), before); - let correction = dir.join("correction.json"); - fs::write( - &correction, - serde_json::to_vec(&serde_json::json!({"knowledge":observed})).unwrap(), - ) - .unwrap(); - let heal = cluster_cli( - dir, - &["apply", "--schema-correction", correction.to_str().unwrap()], - ); - assert_eq!(heal["result"]["converged"], true, "{heal}"); let schema_show = cli() .arg("schema") .arg("show") .arg(dir.join("graphs/knowledge.omni")) .output() .unwrap(); - assert!( - schema_show.status.success(), - "schema show failed: {}", - String::from_utf8_lossy(&schema_show.stderr) - ); - let shown = String::from_utf8_lossy(&schema_show.stdout); + assert!(!schema_show.status.success()); + assert!(String::from_utf8_lossy(&schema_show.stderr).contains("applied_schema_drift")); + let shown = tokio::runtime::Runtime::new().unwrap().block_on(async { + Omnigraph::open_read_only(dir.join("graphs/knowledge.omni").to_string_lossy().as_ref()) + .await + .unwrap() + .schema_source() + .to_owned() + }); assert!( shown.contains("Person"), "schema show produced no schema: {shown}" ); assert!( - !shown.contains("rogue"), - "drift must be dropped back to the declared schema: {shown}" - ); - - // Removal requires an exact lifecycle confirmation. Refusal preserves the - // whole achieved projection and both graph roots. - fs::write(dir.join("cluster.yaml"), "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries:\n find_person:\n file: ./people.gq\n").unwrap(); - let before = fs::read(dir.join("__cluster/state.json")).unwrap(); - let blocked = cluster_cli(dir, &["apply"]); - assert_eq!(blocked["ok"], false, "{blocked}"); - assert_eq!( - blocked["diagnostics"][0]["code"], "graph_delete_confirmation_required", - "{blocked}" - ); - assert_eq!(fs::read(dir.join("__cluster/state.json")).unwrap(), before); - assert!(dir.join("graphs/engineering.omni").exists()); - let server = spawn_server_with_cluster(dir); - let (status, body) = invoke_query( - &client, - &server.base_url, - "engineering", - "find_service", - serde_json::json!({"name":"billing"}), + shown.contains("rogue"), + "refusing drift must not silently rewrite the graph: {shown}" ); - assert_eq!(status, 200, "{body}"); - assert_eq!(body["rows"][0]["s.name"], "billing"); } /// Applied policy bundles gate serving per their bindings: the cluster-bound @@ -2415,7 +2418,7 @@ fn local_cli_keyed_credentials_authenticate_url_matched_server() { command.env(name, value); } command - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") diff --git a/crates/omnigraph-cli/tests/system_remote.rs b/crates/omnigraph-cli/tests/system_remote.rs index 1c04a10c2..67aaa7f1d 100644 --- a/crates/omnigraph-cli/tests/system_remote.rs +++ b/crates/omnigraph-cli/tests/system_remote.rs @@ -105,7 +105,7 @@ query insert_person($name: String, $age: I32) { let local_read = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&served_root) .arg("--query") @@ -117,7 +117,7 @@ query insert_person($name: String, $age: I32) { )); let read_payload = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -141,7 +141,7 @@ query insert_person($name: String, $age: I32) { // is `--unauthenticated`, so the actor is the server default). let change_payload = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -158,11 +158,11 @@ query insert_person($name: String, $age: I32) { let query_source = fs::read_to_string(fixture("test.gq")).unwrap(); let http_read = client - .post(format!("{}/graphs/{GRAPH_ID}/read", server.base_url)) + .post(format!("{}/graphs/{GRAPH_ID}/query", server.base_url)) .json(&json!({ "branch": "main", - "query_source": query_source, - "query_name": "get_person", + "query": query_source, + "name": "get_person", "params": { "name": "Mina" } })) .send() @@ -174,13 +174,13 @@ query insert_person($name: String, $age: I32) { assert_eq!(http_read["row_count"], 1); assert_eq!(http_read["rows"][0]["p.name"], "Mina"); assert!( - http_read.get("graph_commit_id").is_none(), - "deprecated /read must preserve its byte-stable legacy envelope" + http_read["graph_commit_id"].is_string(), + "query returns the graph position pinned with its rows" ); let local_verify = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&served_root) .arg("--query") @@ -198,7 +198,7 @@ query insert_person($name: String, $age: I32) { // queries. let inline_remote_read = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -214,7 +214,7 @@ query insert_person($name: String, $age: I32) { let inline_remote_change = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -296,12 +296,17 @@ fn assert_cluster_schema_apply_refused(server: &TestServer, schema: &std::path:: .arg("--json"), ); let refusal = parse_stdout_json(&output); - assert_eq!(refusal["code"], "conflict", "{refusal}"); + assert_eq!( + refusal["command_outcome"]["execution"], "not_started", + "{refusal}" + ); + assert_eq!(refusal["command_outcome"]["effects"], "none", "{refusal}"); + assert!(refusal.get("http_status").is_none(), "{refusal}"); assert!( refusal["error"] .as_str() .unwrap() - .contains("server-side schema apply is disabled for cluster-backed serving"), + .contains("`schema apply` is a direct (storage-native) command"), "{refusal}" ); assert_eq!( @@ -400,7 +405,7 @@ query ordered_person($name: String) { let json_payload = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -422,7 +427,7 @@ query ordered_person($name: String) { let csv = stdout_string(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -498,7 +503,7 @@ query insert_person($name: String, $age: I32) { let changed = parse_stdout_json(&output_success( cli() - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -533,7 +538,7 @@ query insert_person($name: String, $age: I32) { let verify = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -801,7 +806,7 @@ query add_friend($from: String, $to: String) { output_success( cli() - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -817,7 +822,7 @@ query add_friend($from: String, $to: String) { ); output_success( cli() - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -840,8 +845,7 @@ query add_friend($from: String, $to: String) { .arg("--graph") .arg(GRAPH_ID) .arg("--branch") - .arg("feature") - .arg("--jsonl"), + .arg("feature"), )); let export_path = temp.path().join("system-remote-exported.jsonl"); fs::write(&export_path, &exported).unwrap(); @@ -890,7 +894,7 @@ query add_friend($from: String, $to: String) { let eve = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--store") .arg(&imported_graph) .arg("--query") @@ -919,7 +923,9 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() { let ingest_payload = parse_stdout_json(&output_success( cli() - .arg("ingest") + .arg("load") + .arg("--mode") + .arg("merge") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -928,6 +934,8 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() { .arg(&ingest_data) .arg("--branch") .arg("feature-ingest") + .arg("--from") + .arg("main") .arg("--json"), )); assert_eq!(ingest_payload["branch"], "feature-ingest"); @@ -955,7 +963,7 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() { let zoe = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -974,7 +982,7 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() { } /// The unified `load` works against remote graphs through the server's -/// `/ingest` endpoint: without `--from` a missing branch is a hard error +/// `load` behavior: without `--from` a missing branch is a hard error /// (no implicit fork), with `--from` it forks like ingest did. #[test] fn remote_load_round_trips_and_requires_from_for_new_branches() { @@ -1078,7 +1086,9 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() { let ingest_payload = parse_stdout_json(&output_success( cli() - .arg("ingest") + .arg("load") + .arg("--mode") + .arg("merge") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1101,7 +1111,7 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() { let bob = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1120,7 +1130,7 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() { let zoe = parse_stdout_json(&output_success( cli() - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1180,7 +1190,7 @@ query insert_person($name: String, $age: I32) { let denied_main_change = output_failure( cli() .env("OMNIGRAPH_BEARER_TOKEN", "team-token") - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1222,7 +1232,7 @@ query insert_person($name: String, $age: I32) { let changed = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_BEARER_TOKEN", "team-token") - .arg("change") + .arg("mutate") .arg("--server") .arg(&server.base_url) .arg("--graph") @@ -1316,7 +1326,7 @@ query insert_person($name: String, $age: I32) { let verify = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_BEARER_TOKEN", "team-token") - .arg("read") + .arg("query") .arg("--server") .arg(&server.base_url) .arg("--graph") diff --git a/crates/omnigraph-cluster/src/admission.rs b/crates/omnigraph-cluster/src/admission.rs index 08150e91c..040dc6f45 100644 --- a/crates/omnigraph-cluster/src/admission.rs +++ b/crates/omnigraph-cluster/src/admission.rs @@ -43,6 +43,7 @@ struct AdmissionOwner { guard: StateLockGuard, canonical_root: String, schema_contracts: BTreeMap, + serving_deployment: Option, purpose: ClusterAdmissionPurpose, } @@ -67,6 +68,28 @@ impl ClusterAdmission { "this owner cannot deploy", )); } + self.validate_current_lock().await + } + + /// Completion uses the accepted deployment's current base, not the graph + /// inventory captured when a long-lived server first acquired this owner. + pub(crate) async fn validate_completion(&self, deployment_id: &str) -> Result<(), Diagnostic> { + match &self.0.purpose { + ClusterAdmissionPurpose::Serve | ClusterAdmissionPurpose::Deployment => {} + ClusterAdmissionPurpose::Reconcile { + deployment_id: original, + } if original == deployment_id => {} + _ => { + return Err(crate::deployment::refusal( + "cluster_admission_purpose_mismatch", + "this owner cannot complete the accepted deployment", + )); + } + } + self.validate_current_lock().await + } + + async fn validate_current_lock(&self) -> Result<(), Diagnostic> { let mut observations = self.0.store.observations(); let mut diagnostics = Vec::new(); self.0 @@ -113,6 +136,12 @@ impl ClusterAdmission { .expect("admitted graph")) } + /// The current converged receipt captured under this admission. This is + /// startup input, not evidence that this process has installed its bindings. + pub fn serving_deployment(&self) -> Option<&crate::DeploymentResult> { + self.0.serving_deployment.as_ref() + } + /// Explicitly release a uniquely owned admission after the caller has /// proved all graph/native effects and accepted control I/O settled. /// @@ -295,13 +324,21 @@ pub(crate) async fn acquire_with_store( "only reconciliation of the exact outstanding deployment is admitted; serving and other writers are refused", )); } - Ok::<_, Diagnostic>(state + let serving_deployment = state.deployment_results.as_ref().and_then(|results| { + results.iter().rev().find(|result| { + result.converged + && Some(result.result_revision) == state.applied_revision.result_revision + && result.config_digest.is_some() + && result.config_digest == state.applied_revision.config_digest + }).cloned() + }); + Ok::<_, Diagnostic>((state .applied_revision .schema_contracts - .expect("validated v2 state has exact achieved contracts")) + .expect("validated v2 state has exact achieved contracts"), serving_deployment)) }.await; - let schema_contracts = match captured { - Ok(contracts) => contracts, + let (schema_contracts, serving_deployment) = match captured { + Ok(captured) => captured, Err(error) => return Err(release_refused_preflight(store, guard.lock_id(), error).await), }; Ok(Some(ClusterAdmission(Arc::new(AdmissionOwner { @@ -309,6 +346,7 @@ pub(crate) async fn acquire_with_store( guard, canonical_root, schema_contracts, + serving_deployment, purpose, })))) } diff --git a/crates/omnigraph-cluster/src/authorization.rs b/crates/omnigraph-cluster/src/authorization.rs index b98cd6a06..900d08180 100644 --- a/crates/omnigraph-cluster/src/authorization.rs +++ b/crates/omnigraph-cluster/src/authorization.rs @@ -1,7 +1,6 @@ //! Configuration-owned authorization for callers that already authenticated //! an identity. The applied ledger owns policy; candidate files never do. -use std::io::Read; use std::sync::Arc; use omnigraph_policy::{PolicyAction, PolicyEngine, PolicyRequest}; @@ -219,17 +218,6 @@ impl AppliedPolicies { pub(crate) async fn load_optional( backend: &ClusterStore, state: &ClusterState, - ) -> Result { - Self::load_optional_with_sources(backend, state, &BTreeMap::new()).await - } - - /// Repair authority is restricted to a storage owner. Replacement source - /// must hash to the currently applied policy; candidate policy cannot grant - /// authority to repair itself. This method does not write catalog bytes. - pub(crate) async fn load_optional_with_sources( - backend: &ClusterStore, - state: &ClusterState, - repair_sources: &BTreeMap, ) -> Result { if state.applied_revision.resources.len() > MAX_AUTHORIZATION_RESOURCES { return Err(refusal( @@ -261,13 +249,7 @@ impl AppliedPolicies { address, MAX_POLICY_BYTES.min(MAX_POLICY_TOTAL_BYTES.saturating_sub(total_bytes)), ) - .await - .or_else(|error| match repair_sources.get(&entry.digest) { - Some(source) if sha256_hex(source.as_bytes()) == entry.digest => { - Ok(source.clone()) - } - _ => Err(error), - })?; + .await?; total_bytes = total_bytes.saturating_add(source.len()); if source.len() > MAX_POLICY_BYTES || total_bytes > MAX_POLICY_TOTAL_BYTES { return Err(refusal( @@ -390,12 +372,13 @@ fn check( pub(crate) async fn authorize_candidate( backend: &ClusterStore, desired: &DesiredCluster, - state: Option<&ClusterState>, - observations: &StateObservations, + captured: &CapturedDeployment, + snapshot: &store::StateSnapshot, changes: &[PlanChange], identity: &IdentityAuthorization, applying: bool, ) -> Result<(PlanAuthorization, Option), Diagnostic> { + let state = snapshot.state.as_ref(); if !desired.state_lock { return Err(refusal( "authorization_requires_lock", @@ -423,13 +406,7 @@ pub(crate) async fn authorize_candidate( "initial configuration differs from the explicitly authorized initialization", )); } - if state.is_some_and(|state| { - state.state_revision > 1 - || !state.applied_revision.resources.is_empty() - || !state.approval_records.is_empty() - || !state.recovery_records.is_empty() - || state.applied_revision.config_digest.as_deref() != Some(&desired.config_digest) - }) { + if state.is_some_and(|state| !pristine_bootstrap_state(state)) { return Err(refusal( "bootstrap_already_initialized", "bootstrap", @@ -439,7 +416,7 @@ pub(crate) async fn authorize_candidate( // The explicit capability authorizes installation, not candidate-policy // self-authorization. Validate that it actually installs an initial // management policy for the authenticated creator. - validate_initial_policy(desired, &identity.actor)?; + validate_initial_policy(desired, captured, &identity.actor)?; None } else { let state = state.ok_or_else(|| { @@ -554,6 +531,22 @@ pub(crate) async fn authorize_candidate( }) .collect::>(); for graph in affected_graphs { + if !captured.resources.contains_key(&graph_address(&graph)) + && !backend + .graph_root_exists(&backend.graph_root(&graph)) + .await + .map_err(|error| { + refusal( + "graph_unavailable", + graph_address(&graph), + error.to_string(), + ) + })? + { + // The applied identity still authorizes removing an already + // absent graph; it must never be recreated to inspect recovery. + continue; + } Omnigraph::ensure_no_pending_recovery(&backend.graph_root(&graph)) .await .map_err(|error| { @@ -570,9 +563,9 @@ pub(crate) async fn authorize_candidate( version: 1, actor: identity.actor.clone(), canonical_root: backend.canonical_root()?, - state_revision: observations.state_revision, - state_cas: observations.state_cas.clone(), - applied_config_digest: observations.applied_config_digest.clone(), + state_revision: state.map_or(0, |state| state.state_revision), + state_cas: snapshot.state_cas.clone(), + applied_config_digest: state.and_then(|state| state.applied_revision.config_digest.clone()), desired_config_digest: desired.config_digest.clone(), policy_digests: policies .as_ref() @@ -585,7 +578,11 @@ pub(crate) async fn authorize_candidate( Ok((evidence, policies)) } -fn validate_initial_policy(desired: &DesiredCluster, actor: &str) -> Result<(), Diagnostic> { +fn validate_initial_policy( + desired: &DesiredCluster, + captured: &CapturedDeployment, + actor: &str, +) -> Result<(), Diagnostic> { let (address, _) = desired .policy_bindings .iter() @@ -597,29 +594,20 @@ fn validate_initial_policy(desired: &DesiredCluster, actor: &str) -> Result<(), "initialization must explicitly declare a cluster management policy", ) })?; - let resource = desired - .resources - .iter() - .find(|resource| &resource.address == address) - .ok_or_else(|| { - refusal( - "bootstrap_policy_required", - address, - "initial policy source missing", - ) - })?; - let file = fs::File::open(resource.path.as_ref().ok_or_else(|| { + let resource = captured.resources.get(address).ok_or_else(|| { refusal( "bootstrap_policy_required", address, "initial policy source missing", ) - })?) - .map_err(|err| refusal("bootstrap_policy_required", address, err.to_string()))?; - let mut source = String::new(); - file.take(MAX_POLICY_BYTES as u64 + 1) - .read_to_string(&mut source) - .map_err(|err| refusal("bootstrap_policy_required", address, err.to_string()))?; + })?; + let source = captured.sources.get(&resource.digest).ok_or_else(|| { + refusal( + "bootstrap_policy_required", + address, + "initial policy source missing", + ) + })?; if source.len() > MAX_POLICY_BYTES || sha256_hex(source.as_bytes()) != resource.digest { return Err(refusal( "resource_content_changed", @@ -627,7 +615,7 @@ fn validate_initial_policy(desired: &DesiredCluster, actor: &str) -> Result<(), "initial policy source differs from its authorized digest", )); } - let policy = PolicyEngine::load_cluster_from_source(&source) + let policy = PolicyEngine::load_cluster_from_source(source) .map_err(|err| refusal("bootstrap_policy_required", address, err.to_string()))?; check( &policy, @@ -762,7 +750,8 @@ pub async fn authorize_apply_plan( identity: &IdentityAuthorization, expected: &PlanAuthorization, ) -> Result { - let outcome = load_desired(config_dir.as_ref()); + let sources = config::capture_desired(config_dir.as_ref()); + let outcome = sources.outcome; if let Some(diagnostic) = outcome .diagnostics .into_iter() @@ -780,14 +769,7 @@ pub async fn authorize_apply_plan( let backend = store_for(&desired.config_dir, desired.storage_root.as_deref())?; let mut observations = backend.observations(); let snapshot = backend.read_state(&mut observations).await?; - let captured = crate::capture_deployment(config_dir.as_ref(), &BTreeMap::new())?; - if captured.config_digest() != desired.config_digest { - return Err(refusal( - "resource_content_changed", - "configuration", - "candidate changed during authorization capture", - )); - } + let captured = capture_desired_deployment(&desired, sources.sources, None)?; if let Some(state) = &snapshot.state { if state.version != 2 { return Err(refusal( @@ -811,13 +793,7 @@ pub async fn authorize_apply_plan( append_policy_binding_changes(&mut changes, snapshot.state.as_ref(), &desired); append_embedding_profile_changes(&mut changes, snapshot.state.as_ref(), &desired); let (authorization, _) = authorize_candidate( - &backend, - &desired, - snapshot.state.as_ref(), - &observations, - &changes, - identity, - true, + &backend, &desired, &captured, &snapshot, &changes, identity, true, ) .await?; compare_authorization(expected, &authorization)?; diff --git a/crates/omnigraph-cluster/src/config.rs b/crates/omnigraph-cluster/src/config.rs index c5a07c53f..fe752a5af 100644 --- a/crates/omnigraph-cluster/src/config.rs +++ b/crates/omnigraph-cluster/src/config.rs @@ -682,23 +682,6 @@ pub(crate) async fn observe_declared_graphs( graph_error_count } -/// RFC-004 §D7: the data-aware preview — the engine's migration plan for a -/// desired schema against the live graph, computed read-only (no lock). -pub(crate) async fn preview_schema_migration( - graph_uri: &str, - schema_path: &str, -) -> Result { - let source = fs::read_to_string(schema_path).map_err(|err| err.to_string())?; - let db = Omnigraph::open_read_only(graph_uri) - .await - .map_err(|err| err.to_string())?; - let preview = db - .preview_schema_apply(&source) - .await - .map_err(|err| err.to_string())?; - Ok(preview.plan) -} - pub(crate) struct LiveGraphObservation { graph_manifest_version: u64, schema_digest: String, diff --git a/crates/omnigraph-cluster/src/deployment.rs b/crates/omnigraph-cluster/src/deployment.rs index fb7ff2350..0178519d6 100644 --- a/crates/omnigraph-cluster/src/deployment.rs +++ b/crates/omnigraph-cluster/src/deployment.rs @@ -21,7 +21,23 @@ pub(crate) const MAX_RESULTS: usize = 32; pub(crate) const MAX_RESOURCES: usize = 4096; pub(crate) const MAX_DIAGNOSTIC_BYTES: usize = 4096; pub(crate) const GRAPH_COMPLETION_RESERVE_BYTES: usize = 8192; -pub(crate) const ACTIVATION_RESERVE_BYTES: usize = 1024; + +/// Exact v2 first-initialization base, shared by planning and execution. +pub(crate) fn pristine_bootstrap_state(state: &ClusterState) -> bool { + state.version == 2 + && state.state_revision == 1 + && state.next_sequence == Some(1) + && state.outstanding.is_none() + && state.deployment_results.as_ref().is_some_and(Vec::is_empty) + && state.applied_revision.result_revision == Some(0) + && state + .applied_revision + .schema_contracts + .as_ref() + .is_some_and(BTreeMap::is_empty) + && state.applied_revision.config_digest.is_none() + && state.applied_revision.resources.is_empty() +} /// The caller still owns authentication. A stored authority record never /// grants permission to invoke an effect or disclose an earlier result. @@ -97,27 +113,6 @@ pub(crate) struct DeploymentAuthorization { pub effects: Vec, } -/// Explicit acknowledgements for operations that cannot be inferred safely from -/// a config diff. These bytes are captured into the original deployment input. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct DeploymentOptions { - pub schema_corrections: BTreeMap, - pub delete_graphs: BTreeMap, - pub adopt_graphs: BTreeMap, - pub recreate_graphs: BTreeMap, - pub repair_catalog: BTreeSet, -} - -/// Exact accepted graph incarnation and observed main manifest. A reused name -/// or equal schema text is insufficient confirmation for adoption or removal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GraphLifecycleConfirmation { - pub contract: omnigraph::db::SchemaContractDigest, - pub graph_manifest_version: u64, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CapturedDeployment { @@ -127,14 +122,10 @@ pub struct CapturedDeployment { pub(crate) config_semantics: String, pub(crate) resources: BTreeMap, pub(crate) sources: BTreeMap, - pub(crate) options: DeploymentOptions, } /// Frozen, bounded source input. The server never opens caller-supplied paths. impl CapturedDeployment { - pub fn options(&self) -> &DeploymentOptions { - &self.options - } pub fn canonical_root(&self) -> &str { &self.canonical_root } @@ -168,12 +159,10 @@ pub(crate) struct OutstandingDeployment { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct GraphDeployment { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub adopt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delete: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub create: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delete: Option, pub intent: Option, pub observed_manifest_version: u64, pub state: GraphDeploymentState, @@ -181,6 +170,15 @@ pub(crate) struct GraphDeployment { pub recovery_executor: Option, } +/// Exact managed root accepted for deletion. This authority survives a partial +/// purge, when opening a graph manifest is no longer possible. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct GraphDeletion { + pub root: String, + pub contract: omnigraph::db::SchemaContractDigest, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum GraphDeploymentState { @@ -192,14 +190,8 @@ pub(crate) enum GraphDeploymentState { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] pub enum GraphDeploymentResult { - Adopted { - graph_manifest_version: u64, - contract: omnigraph::db::SchemaContractDigest, - }, Deleted { - graph_manifest_version: u64, contract: omnigraph::db::SchemaContractDigest, - retained_storage: bool, }, Created { graph_manifest_version: u64, @@ -227,7 +219,6 @@ impl GraphDeploymentResult { | SchemaApplySettlement::NoOp { .. } } | Self::QueryOnly { .. } | Self::Created { .. } - | Self::Adopted { .. } | Self::Deleted { .. } ) } @@ -254,19 +245,6 @@ pub struct DeploymentResult { pub graphs: BTreeMap, pub recovery_executors: BTreeMap, pub converged: bool, - pub restart_required: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub activation: Option, -} - -/// Durable observation of activation by one server incarnation. A later -/// process must prove its own runtime before reporting this result active. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DeploymentActivation { - pub process_incarnation: String, - pub result_revision: u64, - pub config_digest: String, } /// A bounded snapshot. Lookup never opens a graph or resumes execution. @@ -618,13 +596,6 @@ pub(crate) fn validate_state(state: &ClusterState) -> Result<(), Diagnostic> { || id.sequence >= next || !seen.insert(id.sequence) || encoded_size(result)? > MAX_RESULT_BYTES - || result.activation.as_ref().is_some_and(|activation| { - !canonical_ulid(&activation.process_incarnation) - || activation.result_revision != result.result_revision - || result.config_digest.as_deref() != Some(activation.config_digest.as_str()) - || !result.converged - || result.restart_required - }) || result.graphs.len() > MAX_RESOURCES || result.graphs.values().any(|outcome| !outcome.terminal()) || result @@ -636,30 +607,21 @@ pub(crate) fn validate_state(state: &ClusterState) -> Result<(), Diagnostic> { }) .sum::() > MAX_DIAGNOSTIC_BYTES - || result.graphs.values().any(|outcome| match outcome { + || result.graphs.iter().any(|(graph, outcome)| match outcome { GraphDeploymentResult::Refused { code } => { code.len() > 128 || !code .bytes() .all(|byte| byte.is_ascii_lowercase() || byte == b'_') } - GraphDeploymentResult::Deleted { - retained_storage: false, - .. - } => true, GraphDeploymentResult::Created { graph_manifest_version, contract, - } - | GraphDeploymentResult::Adopted { - graph_manifest_version, - contract, - } - | GraphDeploymentResult::Deleted { - graph_manifest_version, - contract, - .. } => *graph_manifest_version == 0 || !valid_contract(contract), + GraphDeploymentResult::Deleted { contract } => { + !valid_contract(contract) + || result.base.schema_contracts.get(graph) != Some(contract) + } GraphDeploymentResult::QueryOnly { graph_manifest_version, schema_digest, @@ -726,7 +688,6 @@ pub(crate) fn validate_state(state: &ClusterState) -> Result<(), Diagnostic> { || pending.graphs.iter().any(|(graph, entry)| { !valid_resource_name(graph) || (entry.create.is_none() - && entry.adopt.is_none() && !state .applied_revision .resources @@ -736,32 +697,24 @@ pub(crate) fn validate_state(state: &ClusterState) -> Result<(), Diagnostic> { .as_ref() .is_some_and(|create| create.validate().is_err()) || (entry.create.is_some() - && (entry.intent.is_some() || entry.observed_manifest_version != 0)) - || [ - entry.create.is_some(), - entry.intent.is_some(), - entry.adopt.is_some(), - entry.delete.is_some(), - ] - .into_iter() - .filter(|present| *present) - .count() - > 1 - || entry - .adopt - .iter() - .chain(entry.delete.iter()) - .any(|confirmation| { - confirmation.graph_manifest_version != entry.observed_manifest_version - || !valid_contract(&confirmation.contract) - }) - || (entry.create.is_none() && entry.observed_manifest_version == 0) + && (entry.intent.is_some() || entry.delete.is_some() || entry.observed_manifest_version != 0)) + || (entry.create.is_none() && entry.delete.is_none() && entry.observed_manifest_version == 0) + || entry.delete.as_ref().is_some_and(|delete| { + !valid_contract(&delete.contract) + || delete.root.len() > 4096 || !delete.root.ends_with(&format!("/graphs/{graph}.omni")) + || pending.authorization.base.schema_contracts.get(graph) != Some(&delete.contract) + || entry.intent.is_some() + || entry.settlement.is_some() + || matches!(&entry.state, GraphDeploymentState::Settled { result } if !matches!(result.as_ref(), GraphDeploymentResult::Deleted { contract } if contract == &delete.contract)) + }) + || (entry.delete.is_none() && matches!(&entry.state, GraphDeploymentState::Settled { result } if matches!(result.as_ref(), GraphDeploymentResult::Deleted { .. }))) || entry.intent.as_ref().is_some_and(|intent| { intent.actor() != pending.authorization.authority.actor.as_deref() || intent.base_manifest_version() != entry.observed_manifest_version }) || (entry.intent.is_none() && entry.create.is_none() + && entry.delete.is_none() && (entry.settlement.is_some() || matches!(entry.state, GraphDeploymentState::Started))) || entry diff --git a/crates/omnigraph-cluster/src/deployment/execution.rs b/crates/omnigraph-cluster/src/deployment/execution.rs index 9bc5a9533..f9ebc39a5 100644 --- a/crates/omnigraph-cluster/src/deployment/execution.rs +++ b/crates/omnigraph-cluster/src/deployment/execution.rs @@ -98,25 +98,6 @@ fn authorize_bootstrap_bundle( Ok(()) } -fn repair_policy_sources( - state: &ClusterState, - bundle: &DeploymentBundle, -) -> BTreeMap { - bundle - .options - .repair_catalog - .iter() - .filter_map(|address| { - if !matches!(resource_kind(address), ResourceKind::Policy(_)) { - return None; - } - let entry = state.applied_revision.resources.get(address)?; - let source = bundle.sources.get(&entry.digest)?; - Some((entry.digest.clone(), source.clone())) - }) - .collect() -} - async fn deployment_policies( store: &ClusterStore, state: &ClusterState, @@ -126,13 +107,7 @@ async fn deployment_policies( if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { if identity.has_bootstrap_authority() { authorize_bootstrap_bundle(bundle, caller)?; - if state.state_revision != 1 - || state.next_sequence != Some(1) - || state.outstanding.is_some() - || !state.deployment_results.as_ref().unwrap().is_empty() - || state.applied_revision.config_digest.is_some() - || !state.applied_revision.resources.is_empty() - { + if !pristine_bootstrap_state(state) { return Err(refusal( "bootstrap_already_initialized", "bootstrap authority cannot operate on an initialized cluster", @@ -142,14 +117,6 @@ async fn deployment_policies( } } if let DeploymentCaller::StorageOwner { .. } = caller { - if !bundle.options.repair_catalog.is_empty() { - return AppliedPolicies::load_optional_with_sources( - store, - state, - &repair_policy_sources(state, bundle), - ) - .await; - } return AppliedPolicies::load_optional(store, state).await; } let applied = AppliedPolicies::load(store, state).await?; @@ -166,24 +133,6 @@ async fn lookup_policies( caller: &DeploymentCaller, id: Option<&str>, ) -> Result { - if matches!(caller, DeploymentCaller::StorageOwner { .. }) { - if let Some(pending) = state - .outstanding - .as_ref() - .filter(|pending| Some(pending.id.as_str()) == id) - { - let bundle = store.read_deployment_bundle(&pending.input_digest).await?; - validate_bundle(&bundle, &store.canonical_root()?)?; - if !bundle.options.repair_catalog.is_empty() { - return AppliedPolicies::load_optional_with_sources( - store, - state, - &repair_policy_sources(state, &bundle), - ) - .await; - } - } - } if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { if identity.has_bootstrap_authority() && state.applied_revision.resources.is_empty() { let pending = state @@ -363,8 +312,17 @@ pub async fn upgrade_deployment_ledger( )); } let store = ClusterStore::for_storage_root(root)?; - let (before, _) = read_existing(&store).await?; - if before.version == 2 { + let (before, conversion) = store.read_state_for_ledger_upgrade().await?; + let before = before + .state + .ok_or_else(|| refusal("state_missing", "cluster ledger is missing"))?; + if before.outstanding.is_some() { + return Err(refusal( + "ledger_upgrade_pending", + "complete the outstanding deployment with its originating build before ledger conversion", + )); + } + if before.version == 2 && !conversion { return deployment_status(root, None, caller).await; } // Refuse unauthorized conversion before acquiring durable admission; a @@ -379,25 +337,33 @@ pub async fn upgrade_deployment_ledger( // All work in this phase is awaited control reads and read-only graph // validation. Only the later replacement may write converted state. let preflight = async { - let (mut state, cas) = read_existing(&store).await?; - if state.version == 2 { + let (snapshot, conversion) = store.read_state_for_ledger_upgrade().await?; + let mut state = snapshot + .state + .ok_or_else(|| refusal("state_missing", "cluster ledger is missing"))?; + let cas = snapshot + .state_cas + .ok_or_else(|| refusal("state_missing", "cluster ledger is missing"))?; + if state.version != before.version || (state.version == 2 && !conversion) { return Err(refusal( "ledger_changed", "ledger changed during conversion; inspect original root", )); } policies(&store, &state, caller).await?; - authorization::refuse_pending_recovery(&store).await?; - serve::read_snapshot_for_ledger_upgrade(&store) - .await - .map_err(|mut diagnostics| diagnostics.remove(0))?; - state.applied_revision.schema_contracts = - Some(capture_applied_graph_contracts(&store, &state).await?); - state.version = 2; - state.ledger_id = Some(Ulid::new().to_string()); - state.next_sequence = Some(1); - state.applied_revision.result_revision = Some(0); - state.deployment_results = Some(Vec::new()); + if state.version == 1 { + authorization::refuse_pending_recovery(&store).await?; + serve::read_snapshot_for_ledger_upgrade(&store) + .await + .map_err(|mut diagnostics| diagnostics.remove(0))?; + state.applied_revision.schema_contracts = + Some(capture_applied_graph_contracts(&store, &state).await?); + state.version = 2; + state.ledger_id = Some(Ulid::new().to_string()); + state.next_sequence = Some(1); + state.applied_revision.result_revision = Some(0); + state.deployment_results = Some(Vec::new()); + } // Validate the exact representation replace() will write, including // its incremented revision, while this is still a read-only phase. let previous_revision = state.state_revision; @@ -421,7 +387,12 @@ pub async fn upgrade_deployment_ledger( return Err(admission::release_refused_preflight(&store, guard.lock_id(), error).await); } }; - replace(&store, &mut state, &cas) + state.state_revision = state + .state_revision + .checked_add(1) + .ok_or_else(|| refusal("revision_exhausted", "ledger revision exhausted"))?; + store + .write_state_for_ledger_upgrade(&state, &cas) .await .map_err(|error| retained_error(error, guard.lock_id()))?; store @@ -430,7 +401,7 @@ pub async fn upgrade_deployment_ledger( deployment_status(root, None, caller).await } -fn empty_ledger() -> ClusterState { +pub(super) fn empty_ledger() -> ClusterState { ClusterState { version: 2, ledger_id: Some(Ulid::new().to_string()), @@ -478,9 +449,8 @@ async fn bootstrap_ledger( "cluster initialized while acquiring bootstrap ownership", )); } - // Validate the same captured lifecycle input before the first ledger - // write. Existing roots require exact adoption confirmation, including - // after a lost ledger; failed confirmation leaves no initialized state. + // Validate the captured input before the first ledger write. Existing + // unmanaged roots refuse, including after a lost cluster ledger. let state = empty_ledger(); let id = format!("{}:1:{}", state.ledger_id.as_ref().unwrap(), Ulid::new()); let input_digest = sha256_hex( @@ -491,7 +461,7 @@ async fn bootstrap_ledger( store, bundle, caller, - Some((state.clone(), format!("sha256:{}", sha256_hex(b"")))), + (state.clone(), format!("sha256:{}", sha256_hex(b""))), &BTreeMap::new(), &id, &input_digest, @@ -584,7 +554,6 @@ fn bundle_from_capture( config_digest: desired.config_digest.clone(), config_semantics: desired.config_semantics.clone(), resources, - options: DeploymentOptions::default(), sources: sources .into_iter() .map(|(digest, source)| (digest, source.to_string())) @@ -607,41 +576,6 @@ fn validate_bundle_with_root( || bundle.canonical_root != root || root.len() > 4096 || bundle.resources.len() > MAX_RESOURCES - || bundle.options.repair_catalog.len() > MAX_RESOURCES - || bundle - .options - .repair_catalog - .iter() - .any(|address| !valid_address(address)) - || bundle.options.delete_graphs.len() - + bundle.options.adopt_graphs.len() - + bundle.options.recreate_graphs.len() - > MAX_RESOURCES - || bundle - .options - .delete_graphs - .iter() - .chain(bundle.options.adopt_graphs.iter()) - .any(|(graph, confirmation)| { - !valid_resource_name(graph) - || !valid_contract(&confirmation.contract) - || confirmation.graph_manifest_version == 0 - }) - || bundle - .options - .recreate_graphs - .iter() - .any(|(graph, contract)| !valid_resource_name(graph) || !valid_contract(contract)) - || bundle.options.schema_corrections.len() > MAX_RESOURCES - || bundle - .options - .schema_corrections - .iter() - .any(|(graph, contract)| { - !valid_resource_name(graph) - || !valid_contract(contract) - || !bundle.resources.contains_key(&graph_address(graph)) - }) || encoded_size(bundle)? > MAX_BUNDLE_BYTES || bundle .sources @@ -857,81 +791,13 @@ pub(crate) fn preview_deployment_scope( bundle: &DeploymentBundle, ) -> Result, Diagnostic> { let before = &state.applied_revision.resources; - let options = &bundle.options; - for address in &options.repair_catalog { - if !bundle.resources.contains_key(address) - || !matches!( - resource_kind(address), - ResourceKind::Query { .. } | ResourceKind::Policy(_) - ) - { - return Err(refusal( - "catalog_repair_target_invalid", - format!("{address} must name a desired policy or stored-query payload"), - )); - } - } - for graph in options.delete_graphs.keys() { - if !before.contains_key(&graph_address(graph)) - || bundle.resources.contains_key(&graph_address(graph)) - { - return Err(refusal( - "graph_delete_confirmation_unused", - format!( - "graph {graph} must be currently managed and absent from desired configuration" - ), - )); - } - } - for graph in options.adopt_graphs.keys() { - if before.contains_key(&graph_address(graph)) - || !bundle.resources.contains_key(&graph_address(graph)) - { - return Err(refusal( - "graph_adopt_confirmation_unused", - format!("graph {graph} must be unmanaged and declared in desired configuration"), - )); - } - } - for (graph, contract) in &options.recreate_graphs { - if !bundle.resources.contains_key(&graph_address(graph)) - || state - .applied_revision - .schema_contracts - .as_ref() - .and_then(|contracts| contracts.get(graph)) - != Some(contract) - { - return Err(refusal( - "graph_recreate_confirmation_mismatch", - format!("graph {graph} recreation must acknowledge its exact achieved contract"), - )); - } - } - for graph in options.schema_corrections.keys() { - if !before.contains_key(&graph_address(graph)) - || !bundle.resources.contains_key(&graph_address(graph)) - || options.recreate_graphs.contains_key(graph) - { - return Err(refusal( - "schema_correction_unneeded", - format!("graph {graph} has no existing contract to correct in this deployment"), - )); - } - } let addresses: BTreeSet<_> = before.keys().chain(bundle.resources.keys()).collect(); let mut effects = Vec::new(); for address in addresses { let old = before.get(address); let new = bundle.resources.get(address); let unchanged = serde_json::to_value(old).unwrap() == serde_json::to_value(new).unwrap(); - let explicit = options.repair_catalog.contains(address) - || match resource_kind(address) { - ResourceKind::Schema(graph) => options.schema_corrections.contains_key(&graph), - ResourceKind::Graph(graph) => options.recreate_graphs.contains_key(&graph), - _ => false, - }; - if unchanged && !explicit { + if unchanged { continue; } if matches!(resource_kind(address), ResourceKind::Unknown) { @@ -987,8 +853,7 @@ fn affected_graphs( .collect(); let source_unchanged = old .zip(new) - .is_some_and(|(old, new)| old.digest == new.digest) - && !bundle.options.repair_catalog.contains(&effect.resource); + .is_some_and(|(old, new)| old.digest == new.digest); let bindings: BTreeSet<_> = if source_unchanged { old_bindings .symmetric_difference(&new_bindings) @@ -1091,8 +956,6 @@ fn result_from_pending(state: &ClusterState, pending: &OutstandingDeployment) -> }) .collect(), converged: false, - restart_required: true, - activation: None, } } @@ -1162,8 +1025,7 @@ pub(crate) fn reserve_completion( .ok_or_else(|| refusal("deployment_bounds", "completion capacity overflow"))?; let result_bytes = encoded_size(&result_from_pending(state, pending))? .saturating_add(growth) - .saturating_add(MAX_DIAGNOSTIC_BYTES) - .saturating_add(ACTIVATION_RESERVE_BYTES); + .saturating_add(MAX_DIAGNOSTIC_BYTES); if result_bytes > MAX_RESULT_BYTES { return Err(refusal( "deployment_bounds", @@ -1242,25 +1104,8 @@ pub(crate) fn reserve_completion( } /// Capture all source bytes once, resolving storage identity on this host. -pub fn capture_deployment( - config_dir: impl AsRef, - schema_corrections: &BTreeMap, -) -> Result { - capture_deployment_with_options( - config_dir, - &DeploymentOptions { - schema_corrections: schema_corrections.clone(), - ..DeploymentOptions::default() - }, - ) -} - -/// Capture the complete explicit lifecycle input for direct deployment. -pub fn capture_deployment_with_options( - config_dir: impl AsRef, - options: &DeploymentOptions, -) -> Result { - capture_deployment_input(config_dir.as_ref(), options, None) +pub fn capture_deployment(config_dir: impl AsRef) -> Result { + capture_deployment_input(config_dir.as_ref(), None) } /// Capture local sources for the root advertised by an authenticated server. @@ -1270,23 +1115,6 @@ pub fn capture_deployment_with_options( /// The receiving executor still verifies canonical identity on the server. pub fn capture_deployment_for_server( config_dir: impl AsRef, - schema_corrections: &BTreeMap, - server_canonical_root: &str, -) -> Result { - capture_deployment_for_server_with_options( - config_dir, - &DeploymentOptions { - schema_corrections: schema_corrections.clone(), - ..DeploymentOptions::default() - }, - server_canonical_root, - ) -} - -/// Capture explicit lifecycle input without opening the server's storage here. -pub fn capture_deployment_for_server_with_options( - config_dir: impl AsRef, - options: &DeploymentOptions, server_canonical_root: &str, ) -> Result { if remote_root_identity(server_canonical_root)? != server_canonical_root { @@ -1295,7 +1123,7 @@ pub fn capture_deployment_for_server_with_options( "the server must advertise a canonical storage root", )); } - capture_deployment_input(config_dir.as_ref(), options, Some(server_canonical_root)) + capture_deployment_input(config_dir.as_ref(), Some(server_canonical_root)) } fn remote_root_identity(root: &str) -> Result { @@ -1321,7 +1149,6 @@ fn remote_root_identity(root: &str) -> Result { fn capture_deployment_input( config_dir: &Path, - options: &DeploymentOptions, server_canonical_root: Option<&str>, ) -> Result { let captured = config::capture_desired(config_dir); @@ -1337,6 +1164,14 @@ fn capture_deployment_input( .outcome .desired .ok_or_else(|| refusal("configuration_invalid", "configuration unavailable"))?; + capture_desired_deployment(&desired, captured.sources, server_canonical_root) +} + +pub(crate) fn capture_desired_deployment( + desired: &DesiredCluster, + sources: BTreeMap>, + server_canonical_root: Option<&str>, +) -> Result { if !desired.state_lock { return Err(refusal( "deployment_requires_lock", @@ -1349,8 +1184,7 @@ fn capture_deployment_input( store_for(&desired.config_dir, desired.storage_root.as_deref())?.canonical_root()? } }; - let mut bundle = bundle_from_capture(&desired, captured.sources, root.clone()); - bundle.options = options.clone(); + let bundle = bundle_from_capture(desired, sources, root.clone()); if server_canonical_root.is_some() { validate_bundle_with_root(&bundle, &root, remote_root_identity)?; } else { @@ -1390,13 +1224,18 @@ fn verify_recorded_input( Ok(()) } -/// Determine which graph runtimes need to stop admitting work. The same scope -/// and authorization checks repeat under the writer before acceptance. -pub async fn deployment_affected_graphs( +/// Runtime preview derived from one captured ledger and one preparation. +/// It is descriptive only; execution prepares again after the affected requests drain. +pub struct DeploymentPreview { + pub affected_graphs: Vec, + pub serving: ServingSnapshot, +} + +pub async fn prepare_deployment_preview( bundle: &CapturedDeployment, admission: &ClusterAdmission, caller: &DeploymentCaller, -) -> Result, Diagnostic> { +) -> Result { if bundle.canonical_root() != admission.canonical_root() { return Err(refusal( "cluster_admission_root_mismatch", @@ -1406,34 +1245,44 @@ pub async fn deployment_affected_graphs( validate_bundle(bundle, admission.canonical_root())?; admission.validate_deployment().await?; let store = ClusterStore::for_storage_root(admission.canonical_root())?; - let (state, _) = read_existing(&store).await?; + let (state, cas) = read_existing(&store).await?; require_v2(&state)?; - deployment_policies(&store, &state, bundle, caller).await?; - if state.outstanding.is_some() { - return Err(refusal( - "cluster_deployment_outstanding", - "original deployment must be reconciled before a new deployment", - )); - } - let effects = preview_deployment_scope(&state, bundle)?; - Ok(affected_graphs(&state, bundle, &effects) - .into_iter() - .collect()) -} - -/// Validate the captured input, current authorization and supported scope, -/// then project candidate serving bindings through the ordinary serving loader. -/// This is read-only preflight, not an achieved deployment or activation proof. -pub async fn preview_deployment_serving_snapshot( - bundle: &CapturedDeployment, - admission: &ClusterAdmission, - caller: &DeploymentCaller, -) -> Result { - let affected = deployment_affected_graphs(bundle, admission, caller).await?; - let store = ClusterStore::for_storage_root(admission.canonical_root())?; - serve::preview_snapshot_with_store(&store, bundle, &affected) + let id = format!( + "{}:{}:{}", + state.ledger_id.as_ref().unwrap(), + state.next_sequence.unwrap(), + Ulid::new() + ); + let input_digest = sha256_hex( + &serde_json::to_vec(bundle) + .map_err(|error| refusal("deployment_encode", error.to_string()))?, + ); + let prepared = prepare_deployment( + &store, + bundle, + caller, + (state.clone(), cas.clone()), + &BTreeMap::new(), + &id, + &input_digest, + ) + .await?; + let affected_graphs: Vec<_> = prepared + .state + .outstanding + .as_ref() + .unwrap() + .graphs + .keys() + .cloned() + .collect(); + let serving = serve::preview_snapshot_with_store(&store, bundle, &affected_graphs, state, cas) .await - .map_err(|mut diagnostics| diagnostics.remove(0)) + .map_err(|mut diagnostics| diagnostics.remove(0))?; + Ok(DeploymentPreview { + affected_graphs, + serving, + }) } /// Submit a frozen deployment under direct writer admission. `report_id` runs before acceptance and @@ -1443,31 +1292,9 @@ pub async fn apply_deployment( config_dir: impl AsRef, requested_id: Option<&str>, caller: &DeploymentCaller, - schema_corrections: &BTreeMap, - report_id: impl FnOnce(&str, &str, &str), -) -> Result { - apply_deployment_with_options( - config_dir, - requested_id, - caller, - &DeploymentOptions { - schema_corrections: schema_corrections.clone(), - ..DeploymentOptions::default() - }, - report_id, - ) - .await -} - -/// Direct deployment including explicit lifecycle and repair confirmations. -pub async fn apply_deployment_with_options( - config_dir: impl AsRef, - requested_id: Option<&str>, - caller: &DeploymentCaller, - options: &DeploymentOptions, report_id: impl FnOnce(&str, &str, &str), ) -> Result { - let bundle = capture_deployment_with_options(config_dir, options)?; + let bundle = capture_deployment(config_dir)?; let store = ClusterStore::for_storage_root(bundle.canonical_root())?; bootstrap_ledger(&store, &bundle, caller).await?; if let Some(id) = requested_id { @@ -1507,6 +1334,9 @@ pub async fn apply_deployment_with_options( /// Execute under the existing sole writer. The server supplies its paused live /// handles; direct execution opens graphs under the same root admission. +/// Every affected live handle must be drained with healthy native operations +/// before invocation. Root admission excludes participating writers; it does +/// not fence excluded/raw writers or prove remote I/O settlement after failure. pub async fn apply_captured_deployment( bundle: &CapturedDeployment, requested_id: Option<&str>, @@ -1553,7 +1383,7 @@ async fn execute_captured_deployment( ); // Existing identities are lookup-only, including while their owner holds // the lock. Input equality is required before exposing the original result. - let (before, _) = read_existing(&store).await?; + let (before, before_cas) = read_existing(&store).await?; require_v2(&before)?; if let Some(id) = requested_id { let existing = lookup(&before, id)?; @@ -1601,11 +1431,16 @@ async fn execute_captured_deployment( )); } report_id(&id, admission.canonical_root(), admission.lock_id()); - let (mut state, mut cas, policy) = match prepare_deployment( + let PreparedDeployment { + mut state, + mut cas, + policy, + .. + } = match prepare_deployment( &store, bundle, caller, - None, + (before, before_cas), live_graphs, &id, &input_digest, @@ -1629,16 +1464,21 @@ async fn execute_captured_deployment( seams::fail(&DEPLOYMENT_AFTER_ACCEPTANCE)?; install_catalog_payloads(&store, &state, bundle).await?; for (graph, entry) in state.outstanding.as_ref().unwrap().graphs.clone() { - let result = if let Some(confirmation) = &entry.delete { + let result = if let Some(delete) = &entry.delete { + state + .outstanding + .as_mut() + .unwrap() + .graphs + .get_mut(&graph) + .unwrap() + .state = GraphDeploymentState::Started; + cas = replace(&store, &mut state, &cas).await?; + seams::fail(&DEPLOYMENT_AFTER_STARTED)?; + complete_graph_deletion(&store, &graph, delete, &id, admission).await?; + seams::fail(&DEPLOYMENT_AFTER_SCHEMA)?; GraphDeploymentResult::Deleted { - graph_manifest_version: confirmation.graph_manifest_version, - contract: confirmation.contract.clone(), - retained_storage: true, - } - } else if let Some(confirmation) = &entry.adopt { - GraphDeploymentResult::Adopted { - graph_manifest_version: confirmation.graph_manifest_version, - contract: confirmation.contract.clone(), + contract: delete.contract.clone(), } } else if let Some(create) = &entry.create { state @@ -1721,6 +1561,13 @@ async fn execute_captured_deployment( Ok(DeploymentLookup::Complete { result }) } +struct PreparedDeployment { + state: ClusterState, + cas: String, + policy: AppliedPolicies, + migrations: BTreeMap, +} + /// Awaited read-only preflight. Engine preparation only reads accepted /// authority and plans the migration; all opened handles are read-only and /// dropped before this returns. No payload, ledger or native write is issued. @@ -1728,15 +1575,12 @@ async fn prepare_deployment( store: &ClusterStore, bundle: &DeploymentBundle, caller: &DeploymentCaller, - captured_state: Option<(ClusterState, String)>, + captured_state: (ClusterState, String), live_graphs: &BTreeMap>, id: &str, input_digest: &str, -) -> Result<(ClusterState, String, AppliedPolicies), Diagnostic> { - let (mut state, cas) = match captured_state { - Some(snapshot) => snapshot, - None => read_existing(store).await?, - }; +) -> Result { + let (mut state, cas) = captured_state; require_v2(&state)?; let policy = deployment_policies(store, &state, bundle, caller).await?; if state.outstanding.is_some() { @@ -1760,7 +1604,7 @@ async fn prepare_deployment( "new deployment must use this ledger's exact next sequence", )); } - let graphs = Box::pin(prepare_graphs( + let (graphs, migrations) = Box::pin(prepare_graphs( store, &state, bundle, @@ -1795,7 +1639,12 @@ async fn prepare_deployment( }); reserve_completion(&mut state, bundle)?; validate_state(&state)?; - Ok((state, cas, policy)) + Ok(PreparedDeployment { + state, + cas, + policy, + migrations, + }) } /// Full read-only deployment preflight. It acquires no writer admission and @@ -1803,10 +1652,20 @@ async fn prepare_deployment( pub async fn preflight_deployment( bundle: &CapturedDeployment, caller: &DeploymentCaller, -) -> Result<(), Diagnostic> { +) -> Result, Diagnostic> { let store = ClusterStore::for_storage_root(bundle.canonical_root())?; validate_bundle(bundle, &store.canonical_root()?)?; let snapshot = store.read_state(&mut store.observations()).await?; + preflight_deployment_at(bundle, caller, snapshot).await +} + +pub(crate) async fn preflight_deployment_at( + bundle: &CapturedDeployment, + caller: &DeploymentCaller, + snapshot: crate::store::StateSnapshot, +) -> Result, Diagnostic> { + let store = ClusterStore::for_storage_root(bundle.canonical_root())?; + validate_bundle(bundle, &store.canonical_root()?)?; let state = snapshot.state.unwrap_or_else(empty_ledger); require_v2(&state)?; let id = format!( @@ -1822,49 +1681,18 @@ pub async fn preflight_deployment( &serde_json::to_vec(bundle) .map_err(|error| refusal("deployment_encode", error.to_string()))?, ); - prepare_deployment( + let prepared = prepare_deployment( &store, bundle, caller, - Some((state, cas)), + (state, cas), &BTreeMap::new(), &id, &input_digest, ) .await?; - Ok(()) -} - -pub(super) async fn graph_confirmation( - db: &Omnigraph, -) -> Result { - let snapshot = db - .snapshot_of(ReadTarget::branch("main")) - .await - .map_err(|error| refusal("graph_unavailable", error.to_string()))?; - Ok(GraphLifecycleConfirmation { - contract: db.schema_contract_digest(), - graph_manifest_version: snapshot.graph_manifest_version(), - }) -} - -fn check_confirmation( - graph: &str, - operation: &str, - expected: &GraphLifecycleConfirmation, - observed: &GraphLifecycleConfirmation, -) -> Result<(), Diagnostic> { - if expected != observed { - let input = serde_json::json!({ operation: { graph: observed } }); - return Err(refusal( - "graph_lifecycle_confirmation_mismatch", - format!( - "graph {graph} no longer matches its exact {operation} confirmation; inspect the graph before using --lifecycle FILE: {input}" - ), - )); - } - Ok(()) + Ok(prepared.migrations) } async fn prepare_graphs( @@ -1875,56 +1703,65 @@ async fn prepare_graphs( policy: &AppliedPolicies, live_graphs: &BTreeMap>, effects: &[AuthorizedEffect], -) -> Result, Diagnostic> { +) -> Result< + ( + BTreeMap, + BTreeMap, + ), + Diagnostic, +> { let affected = affected_graphs(state, bundle, effects); - for (address, resource) in &bundle.resources { + // Validate the applied payload even when this deployment replaces or removes + // it. New desired bytes cannot grant an implicit catalog-repair capability. + let mut checked = BTreeSet::new(); + for (address, resource, applied) in state + .applied_revision + .resources + .iter() + .map(|(address, resource)| (address, resource, true)) + .chain( + bundle + .resources + .iter() + .map(|(address, resource)| (address, resource, false)), + ) + { let kind = resource_kind(address); let relevant = match &kind { ResourceKind::Query { graph, .. } => affected.contains(graph), ResourceKind::Policy(_) => true, _ => false, }; - if !relevant { - continue; - } - // Explicit repair already supplies the single digest-verified value. - // Never read a damaged slot merely to prove that restoring it is safe. - if bundle.options.repair_catalog.contains(address) { + if !relevant || !checked.insert((address, &resource.digest)) { continue; } let prior = store .read_payload(&kind, &resource.digest) .await .map_err(|error| refusal("resource_payload_read_error", error))?; - let previously_applied = state - .applied_revision - .resources - .get(address) - .is_some_and(|old| old.digest == resource.digest); let invalid = prior .as_ref() .is_some_and(|source| sha256_hex(source.as_bytes()) != resource.digest) - || (prior.is_none() && previously_applied); - if invalid && !bundle.options.repair_catalog.contains(address) { + || (prior.is_none() && applied); + if invalid { return Err(refusal( - "catalog_repair_required", + "catalog_payload_invalid", format!( - "{address} is missing or corrupt; verify the desired source and explicitly request repair_catalog in --lifecycle FILE" + "{address} is missing or corrupt; restore its exact recorded payload before deployment" ), )); } } let mut graphs = BTreeMap::new(); + let mut migrations = BTreeMap::new(); for graph in affected { let existing = state .applied_revision .resources .contains_key(&graph_address(&graph)); - let desired = bundle.resources.contains_key(&graph_address(&graph)); let schema_address = schema_address(&graph); let mut entry = GraphDeployment { create: None, - adopt: None, delete: None, intent: None, observed_manifest_version: 0, @@ -1932,7 +1769,7 @@ async fn prepare_graphs( settlement: None, recovery_executor: None, }; - if !existing || bundle.options.recreate_graphs.contains_key(&graph) { + if !existing { let uri = store.graph_root(&graph); let canonical = admission::canonical_graph_uri(&uri)?; let expected_uri = format!( @@ -1945,132 +1782,104 @@ async fn prepare_graphs( "graph root is outside the admitted cluster's canonical graph layout", )); } - if let Some(expected) = bundle.options.adopt_graphs.get(&graph) { - let db = open_graph(store, &graph, policy, true).await?; - let observed = graph_confirmation(&db).await?; - check_confirmation(&graph, "adopt_graphs", expected, &observed)?; - if observed.contract.source_hash != bundle.resources[&schema_address].digest { - return Err(refusal( - "graph_adopt_schema_mismatch", - format!( - "graph {graph} adoption requires its exact current schema source; apply schema changes after adoption" - ), - )); - } + if store + .graph_root_exists(&uri) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string()))? + { + return Err(refusal( + "graph_root_exists", + format!( + "graph {graph} already has unmanaged storage; ordinary deployment never adopts or overwrites it" + ), + )); + } + let create = + Omnigraph::prepare_graph_create(&uri, source_for(bundle, &schema_address)?) + .await + .map_err(|error| refusal("graph_create_preflight_failed", error.to_string()))?; + admission::admitted_graph_id( + &bundle.canonical_root, + &BTreeMap::from([(graph.clone(), create.desired_contract().clone())]), + create.root(), + )?; + entry.create = Some(create); + graphs.insert(graph, entry); + continue; + } + if !bundle.resources.contains_key(&graph_address(&graph)) { + authorize_graph_deletion(policy, caller, &graph)?; + let root = store.canonical_managed_graph_root(&graph)?; + let contract = + state.applied_revision.schema_contracts.as_ref().unwrap()[&graph].clone(); + if store + .graph_root_exists(&root) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string()))? + { + let db = match live_graphs.get(&graph) { + Some(db) => db.clone(), + None => std::sync::Arc::new(open_graph(store, &graph, policy, true).await?), + }; admission::admitted_graph_id( &bundle.canonical_root, - &BTreeMap::from([(graph.clone(), observed.contract.clone())]), + state.applied_revision.schema_contracts.as_ref().unwrap(), db.uri(), )?; - entry.observed_manifest_version = observed.graph_manifest_version; - entry.adopt = Some(observed); - } else { - let exists = store - .graph_root_exists(&uri) - .await - .map_err(|error| refusal("graph_unavailable", error.to_string()))?; - if exists { - if existing { - return Err(refusal( - "graph_recreate_root_present", - format!( - "graph {graph} recreation requires a fully absent root; existing or partial storage is never overwritten" - ), - )); - } - let detail = match open_graph(store, &graph, policy, true).await { - Ok(db) => serde_json::to_string(&serde_json::json!({ "adopt_graphs": { graph.clone(): graph_confirmation(&db).await? } })).unwrap(), - Err(_) => "existing root cannot be opened; repair or move it explicitly".to_owned(), - }; + if db.schema_contract_digest() != contract { return Err(refusal( - "graph_adoption_required", - format!( - "graph {graph} already has storage and requires exact adoption with --lifecycle FILE: {detail}" - ), + "applied_schema_drift", + format!("graph {graph} differs from the managed identity being deleted"), )); } - let create = - Omnigraph::prepare_graph_create(&uri, source_for(bundle, &schema_address)?) - .await - .map_err(|error| { - refusal("graph_create_preflight_failed", error.to_string()) - })?; - admission::admitted_graph_id( - &bundle.canonical_root, - &BTreeMap::from([(graph.clone(), create.desired_contract().clone())]), - create.root(), - )?; - entry.create = Some(create); + entry.observed_manifest_version = db + .snapshot_of(ReadTarget::branch("main")) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string()))? + .graph_manifest_version(); } + entry.delete = Some(GraphDeletion { root, contract }); graphs.insert(graph, entry); continue; } - let schema_changed = desired - && state.applied_revision.resources[&schema_address].digest - != bundle.resources[&schema_address].digest; - let correction = bundle.options.schema_corrections.get(&graph); + let schema_changed = state.applied_revision.resources[&schema_address].digest + != bundle.resources[&schema_address].digest; let db = match live_graphs.get(&graph) { Some(db) => db.clone(), - None => std::sync::Arc::new(open_graph(store, &graph, policy, true).await.map_err(|error| { - if error.code == "graph_unavailable" { - refusal(&error.code, format!("{}; if graph {graph} is entirely absent, explicitly acknowledge its achieved contract using recreate_graphs in --lifecycle FILE: {}", error.message, serde_json::to_string(&state.applied_revision.schema_contracts.as_ref().unwrap()[&graph]).unwrap())) - } else { error } - })?), + None => std::sync::Arc::new(open_graph(store, &graph, policy, true).await?), }; admission::admitted_graph_id( &bundle.canonical_root, state.applied_revision.schema_contracts.as_ref().unwrap(), db.uri(), )?; - let observed = graph_confirmation(&db).await?; + let snapshot = db + .snapshot_of(ReadTarget::branch("main")) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string()))?; + let observed = db.schema_contract_digest(); let achieved = &state.applied_revision.schema_contracts.as_ref().unwrap()[&graph]; - if !desired { - let expected = bundle.options.delete_graphs.get(&graph).ok_or_else(|| refusal( - "graph_delete_confirmation_required", - format!("removing graph {graph} retains its storage and requires this exact inspected confirmation in --lifecycle FILE: {}", serde_json::json!({ "delete_graphs": { graph.clone(): observed.clone() } })), - ))?; - check_confirmation(&graph, "delete_graphs", expected, &observed)?; - if &observed.contract != achieved { - return Err(refusal( - "applied_schema_drift", - format!("graph {graph} removal cannot adopt a different incarnation"), - )); - } - entry.observed_manifest_version = observed.graph_manifest_version; - entry.delete = Some(observed); - graphs.insert(graph, entry); - continue; - } - if &observed.contract != achieved { - if correction != Some(&observed.contract) { - let input = - serde_json::to_string(&BTreeMap::from([(graph.clone(), observed.contract)])) - .unwrap(); - return Err(refusal( - "applied_schema_drift", - format!( - "graph {graph} differs from its achieved contract; inspect it and acknowledge the exact observed contract with --schema-correction FILE: {input}" - ), - )); - } - } else if correction.is_some() { + if &observed != achieved { return Err(refusal( - "schema_correction_unneeded", + "applied_schema_drift", format!( - "graph {graph} already matches its achieved contract; remove its correction entry" + "graph {graph} differs from its achieved contract; restore the recorded graph before deployment" ), )); } - if schema_changed || correction.is_some() { + if schema_changed { if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { policy.check_graph(identity.actor(), &graph, PolicyAction::Read)?; policy.check_graph(identity.actor(), &graph, PolicyAction::SchemaApply)?; } - let intent = db - .prepare_schema_apply_as(source_for(bundle, &schema_address)?, caller.actor()) + let (intent, migration) = db + .prepare_schema_apply_with_plan_as( + source_for(bundle, &schema_address)?, + caller.actor(), + ) .await .map_err(|error| refusal("schema_preflight_failed", error.to_string()))?; - if intent.base_contract() != correction.unwrap_or(achieved) { + if intent.base_contract() != achieved { return Err(refusal( "applied_schema_drift", format!("graph {graph} changed during schema preparation"), @@ -2078,12 +1887,54 @@ async fn prepare_graphs( } entry.observed_manifest_version = intent.base_manifest_version(); entry.intent = Some(intent); + migrations.insert(graph.clone(), migration); } else { - entry.observed_manifest_version = observed.graph_manifest_version; + entry.observed_manifest_version = snapshot.graph_manifest_version(); } graphs.insert(graph, entry); } - Ok(graphs) + Ok((graphs, migrations)) +} + +fn authorize_graph_deletion( + policy: &AppliedPolicies, + caller: &DeploymentCaller, + graph: &str, +) -> Result<(), Diagnostic> { + if matches!(caller, DeploymentCaller::AuthenticatedIdentity(_)) || policy.graph(graph).is_some() + { + let actor = caller.actor().ok_or_else(|| { + refusal( + "policy_denied", + "graph deletion requires an actor when policy is installed", + ) + })?; + policy.check_graph(actor, graph, PolicyAction::Read)?; + policy.check_graph(actor, graph, PolicyAction::SchemaApply)?; + } + Ok(()) +} + +/// The caller owns the cluster writer and has drained all affected work. The +/// durable Started record is the completion authority if prefix deletion stops +/// after removing only part of the root. +async fn complete_graph_deletion( + store: &ClusterStore, + graph: &str, + delete: &GraphDeletion, + deployment_id: &str, + admission: &ClusterAdmission, +) -> Result<(), Diagnostic> { + admission.validate_completion(deployment_id).await?; + admission::admitted_graph_id( + admission.canonical_root(), + &BTreeMap::from([(graph.to_owned(), delete.contract.clone())]), + &delete.root, + )?; + store + .delete_managed_graph_root(graph, &delete.root) + .await + .map_err(|error| retained_error(error, admission.lock_id())) } async fn install_catalog_payloads( @@ -2094,22 +1945,19 @@ async fn install_catalog_payloads( for (address, resource) in &bundle.resources { let kind = resource_kind(address); if matches!(kind, ResourceKind::Query { .. } | ResourceKind::Policy(_)) { - if !bundle.options.repair_catalog.contains(address) - && state - .applied_revision - .resources - .get(address) - .is_some_and(|old| old.digest == resource.digest) + if state + .applied_revision + .resources + .get(address) + .is_some_and(|old| old.digest == resource.digest) { continue; } let source = source_for(bundle, address)?; - if bundle.options.repair_catalog.contains(address) { - store.repair_payload(&kind, &resource.digest, source).await - } else { - store.write_payload(&kind, &resource.digest, source).await - } - .map_err(|error| refusal("resource_payload_write_error", error))?; + store + .write_payload(&kind, &resource.digest, source) + .await + .map_err(|error| refusal("resource_payload_write_error", error))?; } } Ok(()) @@ -2160,7 +2008,6 @@ fn validate_pending_input( .base .resource_digests .contains_key(&graph_address(graph)) - && !bundle.options.recreate_graphs.contains_key(graph) { return Err(refusal( "deployment_authority_changed", @@ -2169,46 +2016,36 @@ fn validate_pending_input( } continue; } - if let Some(confirmation) = &entry.adopt { - if bundle.options.adopt_graphs.get(graph) != Some(confirmation) - || confirmation.contract.source_hash != bundle.resources[&address].digest - || state - .applied_revision - .resources - .contains_key(&graph_address(graph)) + if let Some(delete) = &entry.delete { + if bundle.resources.contains_key(&graph_address(graph)) + || pending.authorization.base.schema_contracts.get(graph) != Some(&delete.contract) + || delete.root + != format!( + "{}/graphs/{graph}.omni", + bundle.canonical_root.trim_start_matches("file://") + ) + || entry.create.is_some() + || entry.intent.is_some() { return Err(refusal( "deployment_authority_changed", - "adoption differs from its exact immutable confirmation", + "graph deletion differs from original input and managed identity", )); } continue; } - if let Some(confirmation) = &entry.delete { - if bundle.options.delete_graphs.get(graph) != Some(confirmation) - || pending.authorization.base.schema_contracts.get(graph) - != Some(&confirmation.contract) - || bundle.resources.contains_key(&graph_address(graph)) - { - return Err(refusal( - "deployment_authority_changed", - "removal differs from its exact immutable confirmation", - )); - } - continue; + if !bundle.resources.contains_key(&graph_address(graph)) { + return Err(refusal( + "deployment_authority_changed", + "removed graph has no accepted deletion authority", + )); } let schema_changed = state.applied_revision.resources[&address].digest != bundle.resources[&address].digest; - if (schema_changed || bundle.options.schema_corrections.contains_key(graph)) - != entry.intent.is_some() + if schema_changed != entry.intent.is_some() || entry.intent.as_ref().is_some_and(|intent| { intent.actor() != pending.authorization.authority.actor.as_deref() - || intent.base_contract() - != bundle - .options - .schema_corrections - .get(graph) - .unwrap_or(&pending.authorization.base.schema_contracts[graph]) + || intent.base_contract() != &pending.authorization.base.schema_contracts[graph] || intent.desired_contract().source_hash != bundle.resources[&address].digest }) { @@ -2239,6 +2076,9 @@ async fn finish( if pending.graphs.values().any(|entry| !matches!(&entry.state, GraphDeploymentState::Settled { result } if result.terminal())) { return Err(refusal("deployment_outcome_unknown", "deployment has unresolved graph effects")); } + if pending.graphs.values().any(|entry| entry.delete.is_some() && !matches!(&entry.state, GraphDeploymentState::Settled { result } if matches!(result.as_ref(), GraphDeploymentResult::Deleted { .. }))) { + return Err(refusal("deployment_outcome_unknown", "accepted graph deletions must complete before deployment settlement")); + } let before_resources = serde_json::to_value(&state.applied_revision.resources).unwrap(); let mut result = result_from_pending(state, &pending); for (graph, outcome) in &result.graphs { @@ -2250,8 +2090,7 @@ async fn finish( SchemaApplySettlement::Committed { contract, .. } | SchemaApplySettlement::NoOp { contract, .. }, } - | GraphDeploymentResult::Created { contract, .. } - | GraphDeploymentResult::Adopted { contract, .. } = outcome + | GraphDeploymentResult::Created { contract, .. } = outcome { state .applied_revision @@ -2429,77 +2268,6 @@ pub async fn applied_deployment_contracts( Ok(state.applied_revision.schema_contracts.unwrap()) } -/// Record activation only after the server atomically installed this achieved -/// revision. A stale callback cannot mark a later deployment active. -pub async fn record_deployment_activation( - root: &str, - id: &str, - admission: &ClusterAdmission, - activation: DeploymentActivation, - installed_contracts: &BTreeMap, -) -> Result { - if !canonical_ulid(&activation.process_incarnation) { - return Err(refusal( - "activation_invalid", - "server incarnation must be a canonical ULID", - )); - } - admission.validate_serving()?; - admission.validate_deployment().await?; - let store = ClusterStore::for_storage_root(root)?; - if store.canonical_root()? != admission.canonical_root() { - return Err(refusal( - "cluster_admission_root_mismatch", - "activation root differs from admission", - )); - } - let (mut state, cas) = read_existing(&store).await?; - require_v2(&state)?; - if state.outstanding.is_some() - || state.applied_revision.result_revision != Some(activation.result_revision) - || state.applied_revision.config_digest.as_deref() - != Some(activation.config_digest.as_str()) - || state.applied_revision.schema_contracts.as_ref() != Some(installed_contracts) - { - return Err(refusal( - "activation_stale", - "achieved deployment changed before activation recording", - )); - } - let result = state - .deployment_results - .as_mut() - .unwrap() - .iter_mut() - .find(|result| result.id == id) - .ok_or_else(|| { - refusal( - "activation_result_missing", - "original deployment result is unavailable", - ) - })?; - if !result.converged - || result.result_revision != activation.result_revision - || result.config_digest.as_deref() != Some(activation.config_digest.as_str()) - { - return Err(refusal( - "activation_stale", - "activation differs from original achieved result", - )); - } - result.activation = Some(activation); - result.restart_required = false; - let result = result.clone(); - if encoded_size(&result)? > MAX_RESULT_BYTES { - return Err(refusal( - "deployment_bounds", - "activation exceeds result bound", - )); - } - replace(&store, &mut state, &cas).await?; - Ok(result) -} - /// Reconcile only the original accepted invocation. Never resumes its schema /// execution and never reads local desired configuration. pub async fn reconcile_deployment( @@ -2555,114 +2323,142 @@ pub async fn reconcile_deployment( validate_pending_input(&state, &pending, &bundle)?; install_catalog_payloads(&store, &state, &bundle).await?; for (graph, entry) in pending.graphs { - let result = match entry.state { - GraphDeploymentState::Settled { .. } => continue, - GraphDeploymentState::NotStarted if entry.delete.is_some() || entry.adopt.is_some() => { - let confirmation = entry.delete.as_ref().or(entry.adopt.as_ref()).unwrap(); - admission::admitted_graph_id( - admission.canonical_root(), - &BTreeMap::from([(graph.clone(), confirmation.contract.clone())]), - &store.graph_root(&graph), - )?; - let db = open_graph(&store, &graph, &policy, true).await?; - let observed = graph_confirmation(&db).await?; - check_confirmation( - &graph, - if entry.delete.is_some() { - "delete_graphs" - } else { - "adopt_graphs" - }, - confirmation, - &observed, - )?; - if entry.delete.is_some() { - GraphDeploymentResult::Deleted { - graph_manifest_version: observed.graph_manifest_version, - contract: observed.contract, - retained_storage: true, - } + let result = if let Some(delete) = &entry.delete { + authorize_graph_deletion(&policy, caller, &graph)?; + if matches!(entry.state, GraphDeploymentState::Settled { .. }) { + continue; + } + if store.canonical_managed_graph_root(&graph)? != delete.root { + return Err(refusal( + "cluster_graph_root_mismatch", + "graph deletion root differs from original authority", + )); + } + // Before Started, a readable complete managed graph is still required. + // Afterwards a partial root is expected and only durable root authority + // can authorize completion; never recreate it to perform this check. + if store + .graph_root_exists(&delete.root) + .await + .map_err(|error| refusal("deployment_outcome_unknown", error.to_string()))? + { + let opened = if matches!(entry.state, GraphDeploymentState::NotStarted) { + open_graph(&store, &graph, &policy, true).await } else { - GraphDeploymentResult::Adopted { - graph_manifest_version: observed.graph_manifest_version, - contract: observed.contract, + // A partial purge need not have a complete recovery catalog. + // Still reject a readable foreign manifest before deleting it. + Omnigraph::open_read_only(&delete.root) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string())) + }; + match opened { + Ok(db) if db.schema_contract_digest() != delete.contract => { + return Err(refusal( + "deployment_outcome_unknown", + "graph deletion root contains a foreign schema identity", + )); } + Err(error) if matches!(entry.state, GraphDeploymentState::NotStarted) => { + return Err(error); + } + _ => {} } } - GraphDeploymentState::NotStarted - if entry.create.is_none() && entry.intent.is_none() => - { - admission - .validate_graph_uri(&store.graph_root(&graph)) - .await?; - let db = open_graph(&store, &graph, &policy, true).await?; - let observed = graph_confirmation(&db).await?; - if pending.authorization.base.schema_contracts.get(&graph) - != Some(&observed.contract) - { - return Err(refusal( - "deployment_outcome_unknown", - "graph identity changed before original catalog settlement", - )); - } - GraphDeploymentResult::QueryOnly { - graph_manifest_version: observed.graph_manifest_version, - schema_digest: bundle.resources[&schema_address(&graph)].digest.clone(), - } + let current = state + .outstanding + .as_mut() + .unwrap() + .graphs + .get_mut(&graph) + .unwrap(); + current.state = GraphDeploymentState::Started; + current.recovery_executor = Some(caller.authority()?); + cas = replace(&store, &mut state, &cas).await?; + seams::fail(&DEPLOYMENT_AFTER_STARTED)?; + complete_graph_deletion(&store, &graph, delete, id, &admission).await?; + seams::fail(&DEPLOYMENT_AFTER_SCHEMA)?; + GraphDeploymentResult::Deleted { + contract: delete.contract.clone(), } - GraphDeploymentState::NotStarted => GraphDeploymentResult::NotAttempted, - GraphDeploymentState::Started if entry.create.is_some() => { - state - .outstanding - .as_mut() - .unwrap() - .graphs - .get_mut(&graph) - .unwrap() - .recovery_executor = Some(caller.authority()?); - cas = replace(&store, &mut state, &cas).await?; - match Omnigraph::settle_prepared_graph_create_after_quiescence( - entry.create.as_ref().unwrap(), - ) - .await - .map_err(|error| refusal("deployment_outcome_unknown", error.to_string()))? + } else { + match entry.state { + GraphDeploymentState::Settled { .. } => continue, + GraphDeploymentState::NotStarted + if entry.create.is_none() && entry.intent.is_none() => { - GraphCreateReconciliation::Created { - graph_manifest_version, - contract, - } => GraphDeploymentResult::Created { - graph_manifest_version, - contract, - }, - GraphCreateReconciliation::Absent => GraphDeploymentResult::Refused { - code: "graph_not_created".into(), - }, - GraphCreateReconciliation::Unknown => { + admission + .validate_graph_uri(&store.graph_root(&graph)) + .await?; + let db = open_graph(&store, &graph, &policy, true).await?; + let snapshot = db + .snapshot_of(ReadTarget::branch("main")) + .await + .map_err(|error| refusal("graph_unavailable", error.to_string()))?; + let observed = db.schema_contract_digest(); + if pending.authorization.base.schema_contracts.get(&graph) != Some(&observed) { return Err(refusal( "deployment_outcome_unknown", - "original graph creation cannot be proved from its exact genesis identity", + "graph identity changed before original catalog settlement", )); } + GraphDeploymentResult::QueryOnly { + graph_manifest_version: snapshot.graph_manifest_version(), + schema_digest: bundle.resources[&schema_address(&graph)].digest.clone(), + } } - } - GraphDeploymentState::Started => { - let original = entry.intent.as_ref().ok_or_else(|| { - refusal( - "invalid_state", - "query-only work cannot carry started graph effects", + GraphDeploymentState::NotStarted => GraphDeploymentResult::NotAttempted, + GraphDeploymentState::Started if entry.create.is_some() => { + state + .outstanding + .as_mut() + .unwrap() + .graphs + .get_mut(&graph) + .unwrap() + .recovery_executor = Some(caller.authority()?); + cas = replace(&store, &mut state, &cas).await?; + match Omnigraph::settle_prepared_graph_create_after_quiescence( + entry.create.as_ref().unwrap(), ) - })?; - if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { - policy.check_graph(identity.actor(), &graph, PolicyAction::Read)?; - policy.check_graph(identity.actor(), &graph, PolicyAction::SchemaApply)?; + .await + .map_err(|error| refusal("deployment_outcome_unknown", error.to_string()))? + { + GraphCreateReconciliation::Created { + graph_manifest_version, + contract, + } => GraphDeploymentResult::Created { + graph_manifest_version, + contract, + }, + GraphCreateReconciliation::Absent => GraphDeploymentResult::Refused { + code: "graph_not_created".into(), + }, + GraphCreateReconciliation::Unknown => { + return Err(refusal( + "deployment_outcome_unknown", + "original graph creation cannot be proved from its exact genesis identity", + )); + } + } } - admission - .validate_graph_uri(&store.graph_root(&graph)) - .await?; - let db = open_graph(&store, &graph, &policy, false).await?; - // Persist one settlement identity before its first invocation. - // Later operators adopt it, preserving its authored receipt. - let settlement = match entry.settlement { + GraphDeploymentState::Started => { + let original = entry.intent.as_ref().ok_or_else(|| { + refusal( + "invalid_state", + "query-only work cannot carry started graph effects", + ) + })?; + if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { + policy.check_graph(identity.actor(), &graph, PolicyAction::Read)?; + policy.check_graph(identity.actor(), &graph, PolicyAction::SchemaApply)?; + } + admission + .validate_graph_uri(&store.graph_root(&graph)) + .await?; + let db = open_graph(&store, &graph, &policy, false).await?; + // Persist one settlement identity before its first invocation. + // Later operators adopt it, preserving its authored receipt. + let settlement = match entry.settlement { Some(settlement) => settlement, None => db .prepare_schema_settlement_as(original, caller.actor()) @@ -2674,18 +2470,18 @@ pub async fn reconcile_deployment( ) })?, }; - let current = state - .outstanding - .as_mut() - .unwrap() - .graphs - .get_mut(&graph) - .unwrap(); - current.settlement = Some(settlement.clone()); - current.recovery_executor = Some(caller.authority()?); - cas = replace(&store, &mut state, &cas).await?; - seams::fail(&DEPLOYMENT_AFTER_SETTLEMENT_INTENT)?; - let result = db + let current = state + .outstanding + .as_mut() + .unwrap() + .graphs + .get_mut(&graph) + .unwrap(); + current.settlement = Some(settlement.clone()); + current.recovery_executor = Some(caller.authority()?); + cas = replace(&store, &mut state, &cas).await?; + seams::fail(&DEPLOYMENT_AFTER_SETTLEMENT_INTENT)?; + let result = db .settle_prepared_schema_as(original, &settlement, caller.actor()) .await .map_err(|error| { @@ -2694,13 +2490,14 @@ pub async fn reconcile_deployment( format!("original deployment remains unresolved under admission {}: {error}", admission.lock_id()), ) })?; - if matches!(result, SchemaApplySettlement::Unknown) { - return Err(refusal( - "deployment_outcome_unknown", - "protected publication evidence is unavailable", - )); + if matches!(result, SchemaApplySettlement::Unknown) { + return Err(refusal( + "deployment_outcome_unknown", + "protected publication evidence is unavailable", + )); + } + GraphDeploymentResult::Schema { result } } - GraphDeploymentResult::Schema { result } } }; state diff --git a/crates/omnigraph-cluster/src/deployment/tests.rs b/crates/omnigraph-cluster/src/deployment/tests.rs index 985b0dd43..6bed5c877 100644 --- a/crates/omnigraph-cluster/src/deployment/tests.rs +++ b/crates/omnigraph-cluster/src/deployment/tests.rs @@ -16,7 +16,7 @@ async fn unlock(root: &str) { } async fn bootstrap(dir: &Path) -> DeploymentResult { - let result = apply_deployment(dir, None, &owner(), &BTreeMap::new(), |_, _, _| {}) + let result = apply_deployment(dir, None, &owner(), |_, _, _| {}) .await .unwrap(); let DeploymentLookup::Complete { result } = result else { @@ -103,18 +103,19 @@ async fn bootstrap_and_add_graph_use_one_v2_protocol_without_reset() { .await .unwrap() .unwrap(); - let captured = capture_deployment_with_options( - dir.path(), - &DeploymentOptions { - repair_catalog: BTreeSet::from(["policy.base".into()]), - ..DeploymentOptions::default() - }, + let policy_path = dir.path().join("base.policy.yaml"); + let policy_source = fs::read_to_string(&policy_path).unwrap(); + fs::write( + &policy_path, + format!("{policy_source}\n# new policy revision\n"), ) .unwrap(); + let captured = capture_deployment(dir.path()).unwrap(); let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); - let preview = preview_deployment_serving_snapshot(&captured, &admission, &owner()) + let preview = prepare_deployment_preview(&captured, &admission, &owner()) .await - .unwrap(); + .unwrap() + .serving; assert_eq!( preview .graphs @@ -131,12 +132,12 @@ async fn bootstrap_and_add_graph_use_one_v2_protocol_without_reset() { preview .policies .iter() - .map(|policy| (&policy.source, &policy.applies_to)) + .map(|policy| &policy.applies_to) .collect::>(), snapshot .policies .iter() - .map(|policy| (&policy.source, &policy.applies_to)) + .map(|policy| &policy.applies_to) .collect::>() ); assert_eq!( @@ -158,7 +159,7 @@ async fn bootstrap_and_add_graph_use_one_v2_protocol_without_reset() { } #[tokio::test] -async fn held_server_owner_applies_twice_and_records_exact_activation() { +async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { let dir = crate::tests::identity_fixture(); let root = dir.path().to_str().unwrap(); bootstrap(dir.path()).await; @@ -180,11 +181,11 @@ async fn held_server_owner_applies_twice_and_records_exact_activation() { crate::tests::SCHEMA.replace("age: I32?", &format!("age: I32?\n {field}: String?")), ) .unwrap(); - let bundle = capture_deployment(dir.path(), &BTreeMap::new()).unwrap(); - let affected = deployment_affected_graphs(&bundle, &admission, &owner()) + let bundle = capture_deployment(dir.path()).unwrap(); + let preview = prepare_deployment_preview(&bundle, &admission, &owner()) .await .unwrap(); - assert_eq!(affected, ["knowledge"]); + assert_eq!(preview.affected_graphs, ["knowledge"]); let mut started = false; let applied = apply_captured_deployment( &bundle, @@ -213,27 +214,68 @@ async fn held_server_owner_applies_twice_and_records_exact_activation() { let contracts = applied_deployment_contracts(&admission, result.result_revision) .await .unwrap(); - let activated = record_deployment_activation( - root, - &result.id, - &admission, - DeploymentActivation { - process_incarnation: Ulid::new().to_string(), - result_revision: result.result_revision, - config_digest: result.config_digest.unwrap(), - }, - &contracts, - ) - .await - .unwrap(); - assert!(!activated.restart_required); - let status = deployment_status(root, Some(&activated.id), &owner()) + assert_eq!(contracts["knowledge"], db.schema_contract_digest()); + let before = fs::read(dir.path().join("__cluster/state.json")).unwrap(); + let status = deployment_status(root, Some(&result.id), &owner()) .await .unwrap(); - assert!( - matches!(status.lookup, Some(DeploymentLookup::Complete { result }) if result.activation.is_some()) + let Some(DeploymentLookup::Complete { result: observed }) = status.lookup else { + panic!("completed receipt missing"); + }; + assert_eq!( + serde_json::to_value(&observed).unwrap(), + serde_json::to_value(&result).unwrap() + ); + assert_eq!( + fs::read(dir.path().join("__cluster/state.json")).unwrap(), + before ); } + // Membership captured at server boot is not current deployment authority: + // create and delete a later graph under this same lifetime writer. + add_second_graph(dir.path()); + let bundle = capture_deployment(dir.path()).unwrap(); + let mut started = false; + let created = apply_captured_deployment( + &bundle, + None, + &owner(), + &admission, + &handles, + |_, _, _| {}, + &mut started, + ) + .await + .unwrap(); + let DeploymentLookup::Complete { result: created } = created else { + panic!("not complete") + }; + let GraphDeploymentResult::Created { contract, .. } = &created.graphs["second"] else { + panic!("not created") + }; + edit_config(dir.path(), |config| { + config["graphs"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("second")); + config["policies"]["base"]["applies_to"] = serde_yaml::from_str("[knowledge]").unwrap(); + }); + let bundle = capture_deployment(dir.path()).unwrap(); + let deleted = apply_captured_deployment( + &bundle, + None, + &owner(), + &admission, + &handles, + |_, _, _| {}, + &mut started, + ) + .await + .unwrap(); + assert!( + matches!(deleted, DeploymentLookup::Complete { result } if result.converged && matches!(&result.graphs["second"], GraphDeploymentResult::Deleted { contract: old } if old == contract)) + ); + assert!(!dir.path().join("graphs/second.omni").exists()); } #[tokio::test] @@ -247,7 +289,24 @@ async fn captured_input_is_revalidated_and_preflight_keeps_original_writer() { .unwrap() .unwrap(); let original_bytes = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); - let mut bundle = capture_deployment(dir.path(), &BTreeMap::new()).unwrap(); + let mut bundle = capture_deployment(dir.path()).unwrap(); + let original = serde_json::to_value(&bundle).unwrap(); + for field in [ + "options", + "adopt_graphs", + "recreate_graphs", + "schema_corrections", + "repair_catalog", + "delete_graphs", + ] { + let mut legacy = original.clone(); + legacy[field] = serde_json::json!({}); + let error = serde_json::from_value::(legacy).unwrap_err(); + assert!( + error.to_string().contains("unknown field"), + "{field}: {error}" + ); + } let mut raw: RawClusterConfig = serde_json::from_str(&bundle.config_semantics).unwrap(); raw.state.lock = Some(false); bundle.config_semantics = serde_json::to_string(&raw).unwrap(); @@ -298,10 +357,10 @@ async fn graph_creation_refuses_foreign_root_without_holding_new_admission() { Omnigraph::init(foreign.to_str().unwrap(), crate::tests::SCHEMA) .await .unwrap(); - let error = apply_deployment(dir.path(), None, &owner(), &BTreeMap::new(), |_, _, _| {}) + let error = apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) .await .unwrap_err(); - assert_eq!(error.code, "graph_adoption_required"); + assert_eq!(error.code, "graph_root_exists"); assert!( deployment_status(root, None, &owner()) .await @@ -310,47 +369,30 @@ async fn graph_creation_refuses_foreign_root_without_holding_new_admission() { .is_none() ); - // Loss of the ledger is still an explicit adoption, never an implicit - // replacement. A stale confirmation must not create an empty new ledger. - let knowledge = confirmation(dir.path(), "knowledge").await; - let second = confirmation(dir.path(), "second").await; + // A lost ledger cannot silently adopt existing graphs or initialize a + // replacement ledger over their identities. + let knowledge = graph_state(dir.path(), "knowledge").await; + let second = graph_state(dir.path(), "second").await; fs::remove_file(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); - let mut stale = second.clone(); - stale.graph_manifest_version += 1; - let mut options = DeploymentOptions { - adopt_graphs: BTreeMap::from([ - ("knowledge".into(), knowledge.clone()), - ("second".into(), stale), - ]), - ..DeploymentOptions::default() - }; - let captured = capture_deployment_with_options(dir.path(), &options).unwrap(); + let captured = capture_deployment(dir.path()).unwrap(); assert_eq!( preflight_deployment(&captured, &owner()) .await .unwrap_err() .code, - "graph_lifecycle_confirmation_mismatch" + "graph_root_exists" ); assert_eq!( - apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) + apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) .await .unwrap_err() .code, - "graph_lifecycle_confirmation_mismatch" + "graph_root_exists" ); assert!(!dir.path().join(CLUSTER_STATE_FILE).exists()); assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); - options.adopt_graphs.insert("second".into(), second.clone()); - let adopted = apply_options(dir.path(), &options).await; - assert!( - adopted - .graphs - .values() - .all(|result| matches!(result, GraphDeploymentResult::Adopted { .. })) - ); - assert_eq!(confirmation(dir.path(), "knowledge").await, knowledge); - assert_eq!(confirmation(dir.path(), "second").await, second); + assert_eq!(graph_state(dir.path(), "knowledge").await, knowledge); + assert_eq!(graph_state(dir.path(), "second").await, second); } fn identity(actor: &str) -> DeploymentCaller { @@ -383,7 +425,6 @@ async fn authenticated_deployment_checks_current_policy_before_every_effect() { dir.path(), None, &identity("principal:collaborator"), - &BTreeMap::new(), |_, _, _| {}, ) .await @@ -413,7 +454,6 @@ async fn authenticated_deployment_checks_current_policy_before_every_effect() { dir.path(), None, &identity("principal:reader"), - &BTreeMap::new(), |_, _, _| {}, ) .await @@ -423,6 +463,65 @@ async fn authenticated_deployment_checks_current_policy_before_every_effect() { fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), before ); + // Graph removal still requires the existing schema capability in addition + // to cluster management. A candidate policy cannot grant it retroactively. + edit_config(dir.path(), |config| { + config["graphs"] = serde_yaml::from_str("{}").unwrap(); + config["policies"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("base")); + }); + let graph_before = graph_state(dir.path(), "knowledge").await; + let error = apply_deployment( + dir.path(), + None, + &identity("principal:collaborator"), + |_, _, _| {}, + ) + .await + .unwrap_err(); + assert_eq!(error.code, "policy_denied"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert_eq!(graph_state(dir.path(), "knowledge").await, graph_before); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + for actor in [None, Some("principal:collaborator".to_owned())] { + let error = apply_deployment( + dir.path(), + None, + &DeploymentCaller::storage_owner(actor), + |_, _, _| {}, + ) + .await + .unwrap_err(); + assert_eq!(error.code, "policy_denied"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert_eq!(graph_state(dir.path(), "knowledge").await, graph_before); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + } + // An already absent managed root remains removable by an authorized actor, + // including through the observational planning path. + fs::remove_dir_all(dir.path().join("graphs/knowledge.omni")).unwrap(); + let identity = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let plan = plan_config_dir_authorized(dir.path(), &identity).await; + assert!(plan.plan.ok, "{plan:?}"); + let result = apply_deployment( + dir.path(), + None, + &DeploymentCaller::AuthenticatedIdentity(identity), + |_, _, _| {}, + ) + .await + .unwrap(); + assert!( + matches!(result, DeploymentLookup::Complete { result } if matches!(&result.graphs["knowledge"], GraphDeploymentResult::Deleted { contract } if contract == &graph_before.0)) + ); } #[tokio::test] @@ -436,15 +535,9 @@ async fn authenticated_deployment_records_the_authenticated_actor() { crate::tests::SCHEMA.replace("age: I32?", "age: I32?\n email: String?"), ) .unwrap(); - let result = apply_deployment( - dir.path(), - None, - &identity("principal:owner"), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + let result = apply_deployment(dir.path(), None, &identity("principal:owner"), |_, _, _| {}) + .await + .unwrap(); let DeploymentLookup::Complete { result } = result else { panic!("{result:?}"); }; @@ -462,15 +555,9 @@ async fn authenticated_deployment_records_the_authenticated_actor() { #[tokio::test] async fn authenticated_bootstrap_is_explicit_exact_and_single_use() { let dir = crate::tests::identity_fixture(); - let error = apply_deployment( - dir.path(), - None, - &identity("principal:owner"), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap_err(); + let error = apply_deployment(dir.path(), None, &identity("principal:owner"), |_, _, _| {}) + .await + .unwrap_err(); assert_eq!(error.code, "bootstrap_authority_required"); assert!(!dir.path().join(CLUSTER_STATE_FILE).exists()); let capability = DeploymentCaller::AuthenticatedIdentity( @@ -482,27 +569,49 @@ async fn authenticated_bootstrap_is_explicit_exact_and_single_use() { schema.replace("age: I32?", "age: I32?\n extra: String?"), ) .unwrap(); - let error = apply_deployment( - dir.path(), - None, - &capability, - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap_err(); + let error = apply_deployment(dir.path(), None, &capability, |_, _, _| {}) + .await + .unwrap_err(); assert_eq!(error.code, "bootstrap_authority_mismatch"); assert!(!dir.path().join(CLUSTER_STATE_FILE).exists()); fs::write(dir.path().join("people.pg"), schema).unwrap(); - let applied = apply_deployment( - dir.path(), - None, - &capability, - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + // A pristine v2 ledger may already exist after initialization lost its + // caller before acceptance. Repeated observed plans consume no authority. + let store = ClusterStore::for_config_dir(dir.path()); + let pristine = execution::empty_ledger(); + store + .write_state(&pristine, None, &mut store.observations()) + .await + .unwrap(); + let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let DeploymentCaller::AuthenticatedIdentity(bootstrap_identity) = &capability else { + unreachable!(); + }; + let mut first_authorization = None; + for _ in 0..2 { + let planned = plan_config_dir_authorized(dir.path(), bootstrap_identity).await; + assert!(planned.plan.ok, "{:?}", planned.plan.diagnostics); + assert_eq!(planned.plan.authority, LedgerAuthority::Observed); + assert!(!planned.plan.state_observations.lock_acquired); + let authorization = planned.authorization.unwrap(); + assert_eq!(authorization.state_revision, 1); + assert!(authorization.bootstrap); + if let Some(first) = &first_authorization { + assert_eq!(&authorization, first); + } else { + first_authorization = Some(authorization); + } + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + assert!(!dir.path().join("graphs/knowledge.omni").exists()); + } + + let applied = apply_deployment(dir.path(), None, &capability, |_, _, _| {}) + .await + .unwrap(); let DeploymentLookup::Complete { result } = applied else { panic!("{applied:?}"); }; @@ -511,15 +620,9 @@ async fn authenticated_bootstrap_is_explicit_exact_and_single_use() { assert_eq!(result.authority.actor.as_deref(), Some("principal:owner")); unlock(dir.path().to_str().unwrap()).await; let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); - let error = apply_deployment( - dir.path(), - None, - &capability, - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap_err(); + let error = apply_deployment(dir.path(), None, &capability, |_, _, _| {}) + .await + .unwrap_err(); assert_eq!(error.code, "bootstrap_already_initialized"); assert_eq!( fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), @@ -536,31 +639,50 @@ async fn authenticated_bootstrap_is_explicit_exact_and_single_use() { #[cfg(unix)] #[tokio::test] -async fn new_graph_cannot_escape_cluster_root_through_symlink() { - let dir = crate::tests::identity_fixture(); - let root = dir.path().to_str().unwrap(); - bootstrap(dir.path()).await; - unlock(root).await; - let external = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink(external.path(), dir.path().join("graphs/second.omni")).unwrap(); - add_second_graph(dir.path()); - let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); - let error = apply_deployment(dir.path(), None, &owner(), &BTreeMap::new(), |_, _, _| {}) - .await - .unwrap_err(); - assert_eq!(error.code, "cluster_graph_root_mismatch"); - assert_eq!( - fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), - before - ); - assert_eq!(std::fs::read_dir(external.path()).unwrap().count(), 0); - assert!( - deployment_status(root, None, &owner()) +async fn graph_lifecycle_cannot_escape_cluster_root_through_symlink() { + for deleting in [false, true] { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + if deleting { + add_second_graph(dir.path()); + } + bootstrap(dir.path()).await; + unlock(root).await; + let external = tempfile::tempdir().unwrap(); + fs::write(external.path().join("keep"), b"external").unwrap(); + let graph_root = dir.path().join("graphs/second.omni"); + if deleting { + fs::remove_dir_all(&graph_root).unwrap(); + edit_config(dir.path(), |config| { + config["graphs"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("second")); + config["policies"]["base"]["applies_to"] = + serde_yaml::from_str("[knowledge]").unwrap(); + }); + } else { + add_second_graph(dir.path()); + } + std::os::unix::fs::symlink(external.path(), &graph_root).unwrap(); + let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let error = apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) .await - .unwrap() - .lock_id - .is_none() - ); + .unwrap_err(); + assert_eq!(error.code, "cluster_graph_root_mismatch"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert_eq!(fs::read(external.path().join("keep")).unwrap(), b"external"); + assert!( + deployment_status(root, None, &owner()) + .await + .unwrap() + .lock_id + .is_none() + ); + } } #[test] @@ -572,26 +694,20 @@ fn remote_capture_does_not_open_server_storage() { let advertised = format!("file://{}", missing.display()); fs::write(&config_path, format!("storage: {advertised}\n{config}")).unwrap(); assert!(!missing.exists()); - let captured = capture_deployment_for_server(dir.path(), &BTreeMap::new(), &advertised) + let captured = capture_deployment_for_server(dir.path(), &advertised) .expect("remote capture must not canonicalize or open the server's filesystem"); assert_eq!(captured.canonical_root(), advertised); assert!(!missing.exists()); assert!(!dir.path().join(CLUSTER_STATE_FILE).exists()); // Direct capture still requires a real canonical root on its own host. assert_eq!( - capture_deployment(dir.path(), &BTreeMap::new()) - .unwrap_err() - .code, + capture_deployment(dir.path()).unwrap_err().code, "storage_root_invalid" ); assert_eq!( - capture_deployment_for_server( - dir.path(), - &BTreeMap::new(), - "file:///different-server-root" - ) - .unwrap_err() - .code, + capture_deployment_for_server(dir.path(), "file:///different-server-root") + .unwrap_err() + .code, "deployment_input_invalid" ); @@ -602,7 +718,7 @@ fn remote_capture_does_not_open_server_storage() { ) .unwrap(); assert_eq!( - capture_deployment_for_server(dir.path(), &BTreeMap::new(), &advertised) + capture_deployment_for_server(dir.path(), &advertised) .unwrap_err() .code, "storage_root_invalid" @@ -611,23 +727,22 @@ fn remote_capture_does_not_open_server_storage() { // An omitted storage declaration explicitly selects the authenticated // server. S3/Azure capture needs no local backend or cloud credentials. fs::write(&config_path, &config).unwrap(); - let captured = - capture_deployment_for_server(dir.path(), &BTreeMap::new(), &advertised).unwrap(); + let captured = capture_deployment_for_server(dir.path(), &advertised).unwrap(); assert_eq!(captured.canonical_root(), advertised); for root in [ "s3://capture-only-bucket/cluster", "az://capture-only-container/cluster", ] { fs::write(&config_path, format!("storage: {root}/\n{config}")).unwrap(); - let captured = capture_deployment_for_server(dir.path(), &BTreeMap::new(), root).unwrap(); + let captured = capture_deployment_for_server(dir.path(), root).unwrap(); assert_eq!(captured.canonical_root(), root); } // Existing local roots produce identical frozen input across executors. let local_root = format!("file://{}", fs::canonicalize(dir.path()).unwrap().display()); fs::write(&config_path, format!("storage: {local_root}\n{config}")).unwrap(); - let direct = capture_deployment(dir.path(), &BTreeMap::new()).unwrap(); - let remote = capture_deployment_for_server(dir.path(), &BTreeMap::new(), &local_root).unwrap(); + let direct = capture_deployment(dir.path()).unwrap(); + let remote = capture_deployment_for_server(dir.path(), &local_root).unwrap(); assert_eq!( serde_json::to_value(direct).unwrap(), serde_json::to_value(remote).unwrap() @@ -642,13 +757,11 @@ fn remote_capture_does_not_open_server_storage() { ) .unwrap(); assert_eq!( - capture_deployment(dir.path(), &BTreeMap::new()) - .unwrap() - .canonical_root(), + capture_deployment(dir.path()).unwrap().canonical_root(), local_root ); assert_eq!( - capture_deployment_for_server(dir.path(), &BTreeMap::new(), &local_root) + capture_deployment_for_server(dir.path(), &local_root) .unwrap_err() .code, "deployment_input_invalid" @@ -663,22 +776,15 @@ fn edit_config(dir: &Path, edit: impl FnOnce(&mut serde_yaml::Value)) { fs::write(path, serde_yaml::to_string(&value).unwrap()).unwrap(); } -async fn confirmation(dir: &Path, graph: &str) -> GraphLifecycleConfirmation { +async fn graph_state(dir: &Path, graph: &str) -> (omnigraph::db::SchemaContractDigest, u64) { let db = Omnigraph::open_read_only(dir.join(format!("graphs/{graph}.omni")).to_str().unwrap()) .await .unwrap(); - execution::graph_confirmation(&db).await.unwrap() -} - -async fn apply_options(dir: &Path, options: &DeploymentOptions) -> DeploymentResult { - let applied = apply_deployment_with_options(dir, None, &owner(), options, |_, _, _| {}) - .await - .unwrap(); - let DeploymentLookup::Complete { result } = applied else { - panic!("{applied:?}") - }; - assert!(result.converged, "{result:?}"); - result + let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); + ( + db.schema_contract_digest(), + snapshot.graph_manifest_version(), + ) } #[tokio::test] @@ -687,21 +793,15 @@ async fn current_policy_authorizes_policy_edits_rebinding_and_removal() { let root = dir.path().to_str().unwrap(); bootstrap(dir.path()).await; unlock(root).await; - let original = confirmation(dir.path(), "knowledge").await; + let original = graph_state(dir.path(), "knowledge").await; let graph_policy = dir.path().join("base.policy.yaml"); let source = fs::read_to_string(&graph_policy) .unwrap() .replace("principal:reader]", "principal:reader, principal:alice]"); fs::write(&graph_policy, &source).unwrap(); - let applied = apply_deployment( - dir.path(), - None, - &identity("principal:owner"), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + let applied = apply_deployment(dir.path(), None, &identity("principal:owner"), |_, _, _| {}) + .await + .unwrap(); assert!(matches!(applied, DeploymentLookup::Complete { result } if result.converged)); let store = ClusterStore::for_config_dir(dir.path()); let (state, _) = execution::read_existing(&store).await.unwrap(); @@ -715,7 +815,7 @@ async fn current_policy_authorizes_policy_edits_rebinding_and_removal() { omnigraph_policy::PolicyAction::Read, ) .unwrap(); - assert_eq!(confirmation(dir.path(), "knowledge").await, original); + assert_eq!(graph_state(dir.path(), "knowledge").await, original); unlock(root).await; fs::write(dir.path().join("replacement.policy.yaml"), source).unwrap(); edit_config(dir.path(), |config| { @@ -727,7 +827,7 @@ async fn current_policy_authorizes_policy_edits_rebinding_and_removal() { serde_yaml::from_str("file: ./replacement.policy.yaml\napplies_to: [knowledge]\n") .unwrap(); }); - apply_options(dir.path(), &DeploymentOptions::default()).await; + bootstrap(dir.path()).await; unlock(root).await; edit_config(dir.path(), |config| { config["policies"] @@ -735,7 +835,7 @@ async fn current_policy_authorizes_policy_edits_rebinding_and_removal() { .unwrap() .remove(serde_yaml::Value::from("replacement")); }); - apply_options(dir.path(), &DeploymentOptions::default()).await; + bootstrap(dir.path()).await; let (state, _) = execution::read_existing(&store).await.unwrap(); assert!(!state.applied_revision.resources.contains_key("policy.base")); assert!( @@ -751,35 +851,23 @@ async fn current_policy_authorizes_policy_edits_rebinding_and_removal() { .graph("knowledge") .is_none() ); - assert_eq!(confirmation(dir.path(), "knowledge").await, original); + assert_eq!(graph_state(dir.path(), "knowledge").await, original); unlock(root).await; // Current management policy authorizes revoking the author itself. A // subsequent candidate restoring access cannot authorize its own request. let management = dir.path().join("management.policy.yaml"); let source = fs::read_to_string(&management).unwrap(); fs::write(&management, source.replace("principal:owner, ", "")).unwrap(); - apply_deployment( - dir.path(), - None, - &identity("principal:owner"), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + apply_deployment(dir.path(), None, &identity("principal:owner"), |_, _, _| {}) + .await + .unwrap(); unlock(root).await; fs::write(&management, source).unwrap(); assert_eq!( - apply_deployment( - dir.path(), - None, - &identity("principal:owner"), - &BTreeMap::new(), - |_, _, _| {} - ) - .await - .unwrap_err() - .code, + apply_deployment(dir.path(), None, &identity("principal:owner"), |_, _, _| {}) + .await + .unwrap_err() + .code, "policy_denied" ); } @@ -790,7 +878,7 @@ async fn provider_crud_binding_and_blob_updates_preserve_graph_identity() { let root = dir.path().to_str().unwrap(); bootstrap(dir.path()).await; unlock(root).await; - let original = confirmation(dir.path(), "knowledge").await; + let original = graph_state(dir.path(), "knowledge").await; for model in ["mock:first", "mock:second"] { edit_config(dir.path(), |config| { config["providers"] = serde_yaml::from_str(&format!( @@ -803,7 +891,7 @@ async fn provider_crud_binding_and_blob_updates_preserve_graph_identity() { ) .unwrap(); }); - apply_options(dir.path(), &DeploymentOptions::default()).await; + bootstrap(dir.path()).await; unlock(root).await; } edit_config(dir.path(), |config| { @@ -815,7 +903,7 @@ async fn provider_crud_binding_and_blob_updates_preserve_graph_identity() { graph.remove(serde_yaml::Value::from("embedding_provider")); graph.remove(serde_yaml::Value::from("external_blobs")); }); - apply_options(dir.path(), &DeploymentOptions::default()).await; + bootstrap(dir.path()).await; let store = ClusterStore::for_config_dir(dir.path()); let (state, _) = execution::read_existing(&store).await.unwrap(); assert!( @@ -835,145 +923,152 @@ async fn provider_crud_binding_and_blob_updates_preserve_graph_identity() { .external_blob_policy .is_none() ); - assert_eq!(confirmation(dir.path(), "knowledge").await, original); + assert_eq!(graph_state(dir.path(), "knowledge").await, original); } #[tokio::test] -async fn graph_removal_requires_exact_confirmation_retains_storage_and_allows_readoption() { - let dir = crate::tests::identity_fixture(); - let root = dir.path().to_str().unwrap(); - add_second_graph(dir.path()); - bootstrap(dir.path()).await; - unlock(root).await; - let original = confirmation(dir.path(), "second").await; - let original_config = fs::read_to_string(dir.path().join(CLUSTER_CONFIG_FILE)).unwrap(); - edit_config(dir.path(), |config| { - config["graphs"] - .as_mapping_mut() - .unwrap() - .remove(serde_yaml::Value::from("second")); - config["policies"]["base"]["applies_to"] = serde_yaml::from_str("[knowledge]").unwrap(); - }); - let err = apply_deployment(dir.path(), None, &owner(), &BTreeMap::new(), |_, _, _| {}) - .await - .unwrap_err(); - assert_eq!(err.code, "graph_delete_confirmation_required"); - assert!(err.message.contains("graph_manifest_version")); - let correction = DeploymentOptions { - schema_corrections: BTreeMap::from([("second".into(), original.contract.clone())]), - ..DeploymentOptions::default() - }; - assert_eq!( - apply_deployment_with_options(dir.path(), None, &owner(), &correction, |_, _, _| {}) - .await - .unwrap_err() - .code, - "deployment_input_invalid" - ); - let mut options = DeploymentOptions::default(); - let mut stale = original.clone(); - stale.graph_manifest_version += 1; - options.delete_graphs.insert("second".into(), stale); - assert_eq!( - apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) - .await - .unwrap_err() - .code, - "graph_lifecycle_confirmation_mismatch" - ); - options - .delete_graphs - .insert("second".into(), original.clone()); - let removed = apply_options(dir.path(), &options).await; - assert!(matches!( - removed.graphs["second"], - GraphDeploymentResult::Deleted { - retained_storage: true, - .. +async fn graph_removal_deletes_exact_managed_root_and_retains_peers() { + for initial_root in ["present", "absent", "foreign"] { + let already_absent = initial_root == "absent"; + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + add_second_graph(dir.path()); + bootstrap(dir.path()).await; + unlock(root).await; + let (contract, _) = graph_state(dir.path(), "second").await; + let peer = graph_state(dir.path(), "knowledge").await; + let removed_root = dir.path().join("graphs/second.omni"); + let neighboring_prefix = dir.path().join("graphs/second.omni-other"); + fs::create_dir_all(&neighboring_prefix).unwrap(); + fs::write(neighboring_prefix.join("keep"), "peer").unwrap(); + let external_blob = dir.path().join("external-blob.bin"); + fs::write(&external_blob, b"shared external bytes").unwrap(); + if already_absent { + fs::remove_dir_all(&removed_root).unwrap(); } - )); - assert_eq!(confirmation(dir.path(), "second").await, original); - assert_eq!( - apply_deployment_with_options( - dir.path(), - Some(&removed.id), - &owner(), - &DeploymentOptions::default(), - |_, _, _| {} - ) - .await - .unwrap_err() - .code, - "deployment_input_mismatch" - ); - assert_eq!( - read_serving_snapshot_from_storage(root) - .await - .unwrap() - .graphs - .len(), - 1 - ); - unlock(root).await; - fs::write(dir.path().join(CLUSTER_CONFIG_FILE), original_config).unwrap(); - assert_eq!( - apply_deployment(dir.path(), None, &owner(), &BTreeMap::new(), |_, _, _| {}) - .await - .unwrap_err() - .code, - "graph_adoption_required" - ); - let options = DeploymentOptions { - adopt_graphs: BTreeMap::from([("second".into(), original.clone())]), - ..DeploymentOptions::default() - }; - let adopted = apply_options(dir.path(), &options).await; - assert!(matches!( - adopted.graphs["second"], - GraphDeploymentResult::Adopted { .. } - )); - assert_eq!(confirmation(dir.path(), "second").await, original); - assert_eq!( - read_serving_snapshot_from_storage(root) + let ledger = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + edit_config(dir.path(), |config| { + config["graphs"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("second")); + config["policies"]["base"]["applies_to"] = serde_yaml::from_str("[knowledge]").unwrap(); + }); + let captured = capture_deployment(dir.path()).unwrap(); + if initial_root == "foreign" { + fs::remove_dir_all(&removed_root).unwrap(); + Omnigraph::init(removed_root.to_str().unwrap(), crate::tests::SCHEMA) + .await + .unwrap(); + let foreign = graph_state(dir.path(), "second").await; + assert_ne!(foreign.0, contract); + assert_eq!( + preflight_deployment(&captured, &owner()) + .await + .unwrap_err() + .code, + "applied_schema_drift" + ); + assert_eq!( + apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) + .await + .unwrap_err() + .code, + "applied_schema_drift" + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger + ); + assert_eq!(graph_state(dir.path(), "second").await, foreign); + assert_eq!(graph_state(dir.path(), "knowledge").await, peer); + continue; + } + preflight_deployment(&captured, &owner()).await.unwrap(); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger + ); + assert_eq!(removed_root.exists(), !already_absent); + let result = bootstrap(dir.path()).await; + assert!( + matches!(&result.graphs["second"], GraphDeploymentResult::Deleted { contract: deleted } if deleted == &contract) + ); + assert!(!removed_root.exists()); + assert_eq!(graph_state(dir.path(), "knowledge").await, peer); + assert_eq!(fs::read(neighboring_prefix.join("keep")).unwrap(), b"peer"); + assert_eq!(fs::read(&external_blob).unwrap(), b"shared external bytes"); + let state: ClusterState = + serde_json::from_slice(&fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap()) + .unwrap(); + assert!( + !state + .applied_revision + .schema_contracts + .unwrap() + .contains_key("second") + ); + assert!(state.applied_revision.resources.keys().all(|address| !matches!(resource_kind(address), ResourceKind::Graph(ref id) | ResourceKind::Schema(ref id) if id == "second"))); + assert!( + state + .resource_statuses + .keys() + .all(|address| !address.ends_with(".second")) + ); + assert_eq!( + read_serving_snapshot_from_storage(root) + .await + .unwrap() + .graphs + .len(), + 1 + ); + // Terminal lookup must never issue another purge, even if a new object + // subsequently appears at the old path. + fs::create_dir(&removed_root).unwrap(); + fs::write(removed_root.join("new-owner"), b"keep").unwrap(); + let lookup = reconcile_deployment(root, &result.id, false, &owner()) .await - .unwrap() - .graphs - .len(), - 2 - ); + .unwrap(); + assert!(matches!(lookup, DeploymentLookup::Complete { .. })); + assert_eq!(fs::read(removed_root.join("new-owner")).unwrap(), b"keep"); + } } #[tokio::test] -async fn missing_graph_recreation_requires_achieved_identity_and_never_overwrites_storage() { +async fn missing_managed_graph_refuses_without_recreating_storage() { let dir = crate::tests::identity_fixture(); let root = dir.path().to_str().unwrap(); bootstrap(dir.path()).await; unlock(root).await; - let original = confirmation(dir.path(), "knowledge").await; - let options = DeploymentOptions { - recreate_graphs: BTreeMap::from([("knowledge".into(), original.contract.clone())]), - ..DeploymentOptions::default() - }; + let ledger = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + fs::remove_dir_all(dir.path().join("graphs/knowledge.omni")).unwrap(); + fs::write( + dir.path().join("people.pg"), + format!("{}\nnode Extra {{ value: String }}\n", crate::tests::SCHEMA), + ) + .unwrap(); + let captured = capture_deployment(dir.path()).unwrap(); assert_eq!( - apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) + preflight_deployment(&captured, &owner()) .await .unwrap_err() .code, - "graph_recreate_root_present" + "graph_unavailable" ); - fs::remove_dir_all(dir.path().join("graphs/knowledge.omni")).unwrap(); - let recreated = apply_options(dir.path(), &options).await; - assert!(matches!( - recreated.graphs["knowledge"], - GraphDeploymentResult::Created { .. } - )); - assert_ne!( - confirmation(dir.path(), "knowledge") + assert_eq!( + apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) .await - .contract - .schema_identity_domain, - original.contract.schema_identity_domain + .unwrap_err() + .code, + "graph_unavailable" + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger ); + assert!(!dir.path().join("graphs/knowledge.omni").exists()); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); } #[tokio::test] @@ -991,96 +1086,89 @@ async fn unrelated_unavailable_graph_does_not_block_affected_graph_preflight_or_ source.replace("return { $p.name, $p.age }", "return { $p.name }"), ) .unwrap(); - let captured = capture_deployment(dir.path(), &BTreeMap::new()).unwrap(); + let captured = capture_deployment(dir.path()).unwrap(); preflight_deployment(&captured, &owner()).await.unwrap(); - let result = apply_options(dir.path(), &DeploymentOptions::default()).await; + let result = bootstrap(dir.path()).await; assert_eq!(result.graphs.keys().collect::>(), vec!["knowledge"]); assert!(!dir.path().join("graphs/second.omni").exists()); } #[tokio::test] -async fn targeted_catalog_repair_restores_exact_bytes_without_candidate_self_authorization() { +async fn corrupt_catalog_payloads_refuse_without_repair_or_candidate_authority() { let dir = crate::tests::identity_fixture(); let root = dir.path().to_str().unwrap(); bootstrap(dir.path()).await; unlock(root).await; - let original = confirmation(dir.path(), "knowledge").await; + let original = graph_state(dir.path(), "knowledge").await; + let ledger = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); let store = ClusterStore::for_config_dir(dir.path()); let (state, _) = execution::read_existing(&store).await.unwrap(); - let digest = &state.applied_revision.resources["policy.management"].digest; - let relative = - ClusterStore::payload_relative(&ResourceKind::Policy("management".into()), digest).unwrap(); - fs::write(dir.path().join(relative), [0xff, 0xfe]).unwrap(); - let wrong_target = DeploymentOptions { - repair_catalog: BTreeSet::from(["query.knowledge.find_person".into()]), - ..DeploymentOptions::default() - }; - assert!( - apply_deployment_with_options(dir.path(), None, &owner(), &wrong_target, |_, _, _| {}) - .await - .is_err() - ); - let options = DeploymentOptions { - repair_catalog: BTreeSet::from(["policy.management".into()]), - ..DeploymentOptions::default() - }; - let management_path = dir.path().join("management.policy.yaml"); - let trusted_source = fs::read_to_string(&management_path).unwrap(); - fs::write( - &management_path, - format!("{trusted_source}\n# changed policy input\n"), - ) - .unwrap(); - assert!( - apply_deployment_with_options(dir.path(), None, &owner(), &options, |_, _, _| {}) - .await - .is_err() - ); - assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); - fs::write(&management_path, trusted_source).unwrap(); - assert!( - apply_deployment_with_options( - dir.path(), - None, - &identity("principal:owner"), - &options, - |_, _, _| {} - ) - .await - .is_err() - ); - apply_options(dir.path(), &options).await; - assert_eq!(confirmation(dir.path(), "knowledge").await, original); - let (state, _) = execution::read_existing(&store).await.unwrap(); - crate::authorization::AppliedPolicies::load(&store, &state) - .await - .unwrap() - .check_cluster("principal:owner") - .unwrap(); - unlock(root).await; - let query = "query.knowledge.find_person"; - let digest = &state.applied_revision.resources[query].digest; - let relative = ClusterStore::payload_relative(&resource_kind(query), digest).unwrap(); + let address = "policy.management"; + let digest = &state.applied_revision.resources[address].digest; + let relative = ClusterStore::payload_relative(&resource_kind(address), digest).unwrap(); + let payload = dir.path().join(relative); + let trusted = fs::read(&payload).unwrap(); + fs::write(&payload, [0xff, 0xfe]).unwrap(); + for caller in [owner(), identity("principal:owner")] { + assert!( + apply_deployment(dir.path(), None, &caller, |_, _, _| {}) + .await + .is_err() + ); + assert_eq!(fs::read(&payload).unwrap(), [0xff, 0xfe]); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger + ); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + } + // Restore authoritative bytes in the test fixture, then corrupt an applied + // query. Even replacing/deleting that query is not an implicit repair path. + fs::write(&payload, trusted).unwrap(); + let address = "query.knowledge.find_person"; + let digest = &state.applied_revision.resources[address].digest; + let relative = ClusterStore::payload_relative(&resource_kind(address), digest).unwrap(); + let payload = dir.path().join(relative); + fs::write(&payload, "corrupt").unwrap(); + let query_path = dir.path().join("people.gq"); + let source = fs::read_to_string(&query_path).unwrap(); fs::write( - dir.path().join(relative), - vec![b'x'; crate::config::MAX_CONFIG_SOURCE_BYTES + 1], + &query_path, + source.replace("return { $p.name, $p.age }", "return { $p.name }"), ) .unwrap(); - let options = DeploymentOptions { - repair_catalog: BTreeSet::from([query.into()]), - ..DeploymentOptions::default() - }; - let captured = capture_deployment_with_options(dir.path(), &options).unwrap(); - preflight_deployment(&captured, &owner()).await.unwrap(); - apply_options(dir.path(), &options).await; - assert_eq!(confirmation(dir.path(), "knowledge").await, original); - assert!( - read_serving_snapshot_from_storage(root) - .await - .unwrap() - .diagnostics - .is_empty() - ); + for remove in [false, true] { + if remove { + edit_config(dir.path(), |config| { + config["graphs"]["knowledge"] + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::from("queries")); + }); + } + let captured = capture_deployment(dir.path()).unwrap(); + assert_eq!( + preflight_deployment(&captured, &owner()) + .await + .unwrap_err() + .code, + "catalog_payload_invalid" + ); + assert_eq!( + apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) + .await + .unwrap_err() + .code, + "catalog_payload_invalid" + ); + assert_eq!(fs::read(&payload).unwrap(), b"corrupt"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger + ); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + } + assert_eq!(graph_state(dir.path(), "knowledge").await, original); } #[tokio::test] @@ -1120,7 +1208,7 @@ async fn configuration_management_does_not_require_unrelated_graph_data_read() { .unwrap() .replace("principal:reader]", "principal:reader, principal:alice]"); fs::write(path, source).unwrap(); - let result = apply_deployment(dir.path(), None, &actor, &BTreeMap::new(), |_, _, _| {}) + let result = apply_deployment(dir.path(), None, &actor, |_, _, _| {}) .await .unwrap(); let DeploymentLookup::Complete { result } = result else { diff --git a/crates/omnigraph-cluster/src/diff.rs b/crates/omnigraph-cluster/src/diff.rs index 89801132d..1bc5c0aa2 100644 --- a/crates/omnigraph-cluster/src/diff.rs +++ b/crates/omnigraph-cluster/src/diff.rs @@ -19,7 +19,6 @@ pub(crate) fn diff_resources( reason: None, binding_change: false, metadata_change: None, - lifecycle: None, migration: None, }), Some(before) if before != after => changes.push(PlanChange { @@ -31,7 +30,6 @@ pub(crate) fn diff_resources( reason: None, binding_change: false, metadata_change: None, - lifecycle: None, migration: None, }), Some(_) => {} @@ -48,7 +46,6 @@ pub(crate) fn diff_resources( reason: None, binding_change: false, metadata_change: None, - lifecycle: None, migration: None, }); } @@ -89,7 +86,6 @@ pub(crate) fn append_policy_binding_changes( reason: None, binding_change: true, metadata_change: Some(PlanMetadataChange::PolicyBindings), - lifecycle: None, migration: None, }); } @@ -130,7 +126,6 @@ pub(crate) fn append_embedding_profile_changes( reason: None, binding_change: false, metadata_change: Some(PlanMetadataChange::EmbeddingProfile), - lifecycle: None, migration: None, }); } diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index c39cbc6a1..645a3f30d 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -43,7 +43,7 @@ pub use authorization::{ }; use config::{ QueriesDecl, graph_address, load_desired, observe_declared_graphs, parse_cluster_config, - preview_schema_migration, schema_address, state_resource_digests, validate_cluster_header, + schema_address, state_resource_digests, validate_cluster_header, }; pub use deployment::*; use diff::{ @@ -135,40 +135,15 @@ pub fn validate_config_dir(config_dir: impl AsRef) -> ValidateOutput { } } +/// Observe a deployment without taking writer admission or reserving effects. pub async fn plan_config_dir(config_dir: impl AsRef) -> PlanOutput { - plan_config_dir_with_options(config_dir, PlanOptions::default()).await + plan_config_dir_as(config_dir, None).await } -/// `plan`, optionally without the cluster lock (RFC 0048). An observed plan -/// reads the ledger once, reports any lock it finds instead of refusing, and -/// labels its output `authority: observed`; it is never authority for an -/// effect. -pub async fn plan_config_dir_with_options( - config_dir: impl AsRef, - options: PlanOptions, -) -> PlanOutput { - plan_config_dir_with_deployment_options( - config_dir, - options, - &DeploymentOptions::default(), - None, - ) - .await -} - -/// Read-only execution preflight for the exact lifecycle input and actor. -/// This never acquires admission or persists a prepared intent; apply rechecks -/// the same conditions under the current writer owner. -pub async fn plan_config_dir_with_deployment_options( - config_dir: impl AsRef, - options: PlanOptions, - deployment_options: &DeploymentOptions, - actor: Option, -) -> PlanOutput { +/// Plan with the storage owner's selected actor. Apply always revalidates. +pub async fn plan_config_dir_as(config_dir: impl AsRef, actor: Option) -> PlanOutput { Box::pin(plan_config_dir_impl( config_dir.as_ref(), - options, - deployment_options, actor, None, &mut None, @@ -177,17 +152,13 @@ pub async fn plan_config_dir_with_deployment_options( } /// Plan using the current applied policy for an already authenticated actor. -/// Existing storage-holder entry points retain their explicit trust boundary. pub async fn plan_config_dir_authorized( config_dir: impl AsRef, - options: PlanOptions, identity: &IdentityAuthorization, ) -> AuthorizedPlanOutput { let mut authorization = None; let plan = Box::pin(plan_config_dir_impl( config_dir.as_ref(), - options, - &DeploymentOptions::default(), None, Some(identity), &mut authorization, @@ -201,18 +172,13 @@ pub async fn plan_config_dir_authorized( async fn plan_config_dir_impl( config_dir: &Path, - options: PlanOptions, - deployment_options: &DeploymentOptions, actor: Option, identity: Option<&IdentityAuthorization>, authorization: &mut Option, ) -> PlanOutput { - let mut authority = if options.observe { - LedgerAuthority::Observed - } else { - LedgerAuthority::Locked - }; - let outcome = load_desired(config_dir); + let authority = LedgerAuthority::Observed; + let captured = config::capture_desired(config_dir); + let outcome = captured.outcome; let mut diagnostics = outcome.diagnostics; let storage_root = outcome .desired @@ -261,40 +227,20 @@ async fn plan_config_dir_impl( }; } - if !options.observe && !desired.state_lock { - authority = LedgerAuthority::Unlocked; - } - let _lock_guard = if options.observe { - backend - .observe_lock(&mut observations, &mut diagnostics) - .await; - None - } else if desired.state_lock { - match backend.acquire_lock("plan", &mut observations).await { - Ok(guard) => Some(guard), - Err(diagnostic) => { - diagnostics.push(diagnostic); - None - } - } - } else { - diagnostics.push(Diagnostic::warning( - "state_lock_disabled", - "state.lock", - "state.lock is false; plan read state without acquiring the cluster state lock", - )); - None - }; + backend + .observe_lock(&mut observations, &mut diagnostics) + .await; - // Plan is read-only: pending sidecars are reported, never acted on - // (RFC-004 open question 3 keeps read-only commands warn-only). + // Plan reports pending recovery without executing it. warn_pending_recovery_sidecars(&backend, &mut diagnostics).await; let mut prior_resources = BTreeMap::new(); let mut prior_state: Option = None; + let mut prior_cas = None; if !has_errors(&diagnostics) { match backend.read_state(&mut observations).await { Ok(snapshot) => { + prior_cas = snapshot.state_cas; if let Some(state) = snapshot.state { prior_resources = state_resource_digests(&state); prior_state = Some(state); @@ -312,13 +258,8 @@ async fn plan_config_dir_impl( if !has_errors(&diagnostics) { append_policy_binding_changes(&mut changes, prior_state.as_ref(), &desired); append_embedding_profile_changes(&mut changes, prior_state.as_ref(), &desired); - append_lifecycle_changes( - &mut changes, - &prior_resources, - &desired.resource_digests, - deployment_options, - ); } + let bundle = capture_desired_deployment(&desired, captured.sources, None); // The same v2 scope rules govern previews and execution. A refused scope // is wholly pre-effect; no approval artifact can authorize a removed path. let scope_error = prior_state.as_ref().and_then(|state| { @@ -329,9 +270,9 @@ async fn plan_config_dir_impl( "convert the stopped cluster ledger to v2 before planning deployments", )); } - match capture_deployment_with_options(config_dir, deployment_options) { - Ok(bundle) => preview_deployment_scope(state, &bundle).err(), - Err(error) => Some(error), + match &bundle { + Ok(bundle) => preview_deployment_scope(state, bundle).err(), + Err(error) => Some(error.clone()), } }); for change in &mut changes { @@ -342,7 +283,6 @@ async fn plan_config_dir_impl( change.disposition = Some( if matches!(resource_kind(&change.resource), ResourceKind::Graph(_)) && change.operation == PlanOperation::Update - && change.lifecycle.is_none() { ApplyDisposition::Derived } else { @@ -356,16 +296,15 @@ async fn plan_config_dir_impl( diagnostics.push(error); } + let snapshot = store::StateSnapshot { + state: prior_state, + state_cas: prior_cas, + }; + if !has_errors(&diagnostics) { - if let Some(identity) = identity { + if let (Some(identity), Ok(captured)) = (identity, &bundle) { match authorization::authorize_candidate( - &backend, - &desired, - prior_state.as_ref(), - &observations, - &changes, - identity, - false, + &backend, &desired, captured, &snapshot, &changes, identity, false, ) .await { @@ -383,53 +322,33 @@ async fn plan_config_dir_impl( Some(identity) => DeploymentCaller::AuthenticatedIdentity(identity.clone()), None => DeploymentCaller::storage_owner(actor), }; - let preflight = match capture_deployment_with_options(config_dir, deployment_options) { - Ok(bundle) => preflight_deployment(&bundle, &caller).await, + let preflight = match bundle { + Ok(bundle) => preflight_deployment_at(&bundle, &caller, snapshot).await, Err(error) => Err(error), }; - if let Err(error) = preflight { - for change in &mut changes { - change.disposition = Some(ApplyDisposition::Blocked); - change.reason = Some(error.code.clone()); + match preflight { + Ok(migrations) => { + for change in &mut changes { + if let ResourceKind::Schema(graph_id) = resource_kind(&change.resource) { + change.migration = migrations.get(&graph_id).cloned(); + } + } } - diagnostics.push(error); - } - } - - // Include migration steps only after the exact read-only preflight. An - // unavailable preview is a refusal, never an executable-looking plan. - for change in &mut changes { - if has_errors(&diagnostics) || change.operation != PlanOperation::Update { - continue; - } - let ResourceKind::Schema(graph_id) = resource_kind(&change.resource) else { - continue; - }; - let graph_uri = backend.graph_root(&graph_id); - let source_path = desired - .resources - .iter() - .find(|resource| resource.address == change.resource) - .and_then(|resource| resource.path.clone()); - let preview = match source_path { - Some(path) => preview_schema_migration(&graph_uri, &path).await, - None => Err("no schema source recorded".to_string()), - }; - match preview { - Ok(migration) => change.migration = Some(migration), - Err(err) => { - change.disposition = Some(ApplyDisposition::Blocked); - change.reason = Some("schema_preview_unavailable".into()); - diagnostics.push(Diagnostic::error( - "schema_preview_unavailable", - change.resource.clone(), - format!("could not preview the schema migration: {err}"), - )); + Err(error) => { + for change in &mut changes { + change.disposition = Some(ApplyDisposition::Blocked); + change.reason = Some(error.code.clone()); + } + diagnostics.push(error); } } } + let blast_radius = compute_blast_radius(&changes, &desired.dependencies); let ok = !has_errors(&diagnostics); + if !ok { + *authorization = None; + } PlanOutput { ok, @@ -447,68 +366,6 @@ async fn plan_config_dir_impl( } } -/// Lifecycle requests can have effects without any configuration digest change. -/// Keep them in the same plan rather than returning an empty successful preview. -fn append_lifecycle_changes( - changes: &mut Vec, - prior: &BTreeMap, - desired: &BTreeMap, - options: &DeploymentOptions, -) { - let actions = options - .delete_graphs - .keys() - .map(|id| { - ( - format!("graph.{id}"), - PlanLifecycleAction::RemoveRetainingStorage, - ) - }) - .chain( - options - .adopt_graphs - .keys() - .map(|id| (format!("graph.{id}"), PlanLifecycleAction::Adopt)), - ) - .chain( - options - .recreate_graphs - .keys() - .map(|id| (format!("graph.{id}"), PlanLifecycleAction::Recreate)), - ) - .chain(options.schema_corrections.keys().map(|id| { - ( - format!("graph.{id}"), - PlanLifecycleAction::CorrectSchemaContract, - ) - })) - .chain( - options - .repair_catalog - .iter() - .map(|address| (address.clone(), PlanLifecycleAction::RepairCatalog)), - ); - for (address, action) in actions { - if let Some(change) = changes.iter_mut().find(|change| change.resource == address) { - change.lifecycle = Some(action); - } else { - changes.push(PlanChange { - resource: address.clone(), - operation: PlanOperation::Update, - before_digest: prior.get(&address).cloned(), - after_digest: desired.get(&address).cloned(), - disposition: None, - reason: None, - binding_change: false, - metadata_change: None, - lifecycle: Some(action), - migration: None, - }); - } - } - changes.sort_by(|a, b| a.resource.cmp(&b.resource)); -} - pub async fn status_config_dir(config_dir: impl AsRef) -> StatusOutput { let parsed = parse_cluster_config(config_dir.as_ref()); let mut diagnostics = parsed.diagnostics; diff --git a/crates/omnigraph-cluster/src/serve.rs b/crates/omnigraph-cluster/src/serve.rs index ea02c514d..b3c4b4b81 100644 --- a/crates/omnigraph-cluster/src/serve.rs +++ b/crates/omnigraph-cluster/src/serve.rs @@ -230,7 +230,7 @@ pub async fn read_deployment_serving_snapshot( ) -> Result> { let backend = ClusterStore::for_storage_root(storage_root).map_err(|diagnostic| vec![diagnostic])?; - read_snapshot_impl(&backend, false, None, Some(affected)).await + read_snapshot_impl(&backend, false, None, Some(affected), None).await } /// Test support: read a local cluster's serving snapshot through the @@ -426,7 +426,7 @@ fn cluster_root_of_graph_layout(graph_uri: &str) -> Option { pub(crate) async fn read_snapshot_with_store( backend: &ClusterStore, ) -> Result> { - read_snapshot_impl(backend, false, None, None).await + read_snapshot_impl(backend, false, None, None, None).await } /// Decode and validate legacy applied facts only for stopped-writer conversion. @@ -434,7 +434,7 @@ pub(crate) async fn read_snapshot_with_store( pub(crate) async fn read_snapshot_for_ledger_upgrade( backend: &ClusterStore, ) -> Result> { - read_snapshot_impl(backend, true, None, None).await + read_snapshot_impl(backend, true, None, None, None).await } /// Use the ordinary serving projector with already validated frozen candidate @@ -443,8 +443,17 @@ pub(crate) async fn preview_snapshot_with_store( backend: &ClusterStore, candidate: &crate::CapturedDeployment, affected: &[String], + state: ClusterState, + state_cas: String, ) -> Result> { - read_snapshot_impl(backend, false, Some(candidate), Some(affected)).await + read_snapshot_impl( + backend, + false, + Some(candidate), + Some(affected), + Some((state, state_cas)), + ) + .await } async fn serving_payload( @@ -471,6 +480,7 @@ async fn read_snapshot_impl( legacy_conversion: bool, candidate: Option<&crate::CapturedDeployment>, affected: Option<&[String]>, + captured: Option<(ClusterState, String)>, ) -> Result> { let selected = |graph: &str| affected.is_none_or(|graphs| graphs.iter().any(|id| id == graph)); let mut diagnostics: Vec = Vec::new(); @@ -514,21 +524,26 @@ async fn read_snapshot_impl( } let mut observations = backend.observations(); - let state = match backend.read_state(&mut observations).await { - Ok(snapshot) => match snapshot.state { - Some(state) => Some(state), - None => { - diagnostics.push(Diagnostic::error( - "cluster_state_missing", - CLUSTER_STATE_FILE, - "no cluster state ledger; run `cluster apply` first", - )); + let state = if let Some((state, cas)) = captured { + observations.state_cas = Some(cas); + Some(state) + } else { + match backend.read_state(&mut observations).await { + Ok(snapshot) => match snapshot.state { + Some(state) => Some(state), + None => { + diagnostics.push(Diagnostic::error( + "cluster_state_missing", + CLUSTER_STATE_FILE, + "no cluster state ledger; run `cluster apply` first", + )); + None + } + }, + Err(diagnostic) => { + diagnostics.push(diagnostic); None } - }, - Err(diagnostic) => { - diagnostics.push(diagnostic); - None } }; let Some(mut state) = state else { diff --git a/crates/omnigraph-cluster/src/store.rs b/crates/omnigraph-cluster/src/store.rs index 4d6d90798..bed97a038 100644 --- a/crates/omnigraph-cluster/src/store.rs +++ b/crates/omnigraph-cluster/src/store.rs @@ -48,6 +48,98 @@ pub(crate) struct StateSnapshot { pub(crate) state_cas: Option, } +/// Only explicit stopped-ledger conversion may remove obsolete runtime fields. +/// The remaining ledger is decoded by the ordinary strict types and validator. +fn decode_ledger(text: &str, upgrade: bool) -> Result<(ClusterState, bool), Diagnostic> { + let invalid = + |message: String| Diagnostic::error("invalid_state_json", CLUSTER_STATE_FILE, message); + let strict = serde_json::from_str::(text); + if !upgrade || strict.is_ok() { + return strict + .map(|state| (state, false)) + .map_err(|error| invalid(error.to_string())); + } + let mut value = + omnigraph::loader::parse_unique_json(text).map_err(|error| invalid(error.to_string()))?; + if value.get("version").and_then(serde_json::Value::as_u64) != Some(2) { + return Err(invalid(strict.unwrap_err().to_string())); + } + if value + .get("outstanding") + .is_some_and(|pending| !pending.is_null()) + { + return Err(Diagnostic::error( + "ledger_upgrade_pending", + CLUSTER_STATE_FILE, + "complete the outstanding deployment with its originating build before ledger conversion", + )); + } + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct PriorActivation { + process_incarnation: String, + result_revision: u64, + config_digest: String, + } + let mut converted = false; + let results = value + .get_mut("deployment_results") + .and_then(serde_json::Value::as_array_mut) + .ok_or_else(|| invalid("missing deployment results".into()))?; + let results_bytes = serde_json::to_vec(&results).map_err(|error| invalid(error.to_string()))?; + if results_bytes.len() > crate::deployment::MAX_RESULTS_BYTES { + return Err(invalid("obsolete results exceed the receipt bound".into())); + } + for result in results { + if serde_json::to_vec(&result) + .map_err(|error| invalid(error.to_string()))? + .len() + > crate::deployment::MAX_RESULT_BYTES + { + return Err(invalid("obsolete result exceeds the receipt bound".into())); + } + let fields = result + .as_object_mut() + .ok_or_else(|| invalid("invalid deployment result".into()))?; + let restart = fields.remove("restart_required"); + let activation = fields.remove("activation"); + if restart.is_none() && activation.is_none() { + continue; + } + let restart = restart + .and_then(|value| value.as_bool()) + .ok_or_else(|| invalid("obsolete receipt requires boolean restart_required".into()))?; + let activation = activation + .filter(|value| !value.is_null()) + .map(serde_json::from_value::) + .transpose() + .map_err(|error| invalid(error.to_string()))?; + let receipt: crate::DeploymentResult = + serde_json::from_value(result.clone()).map_err(|error| invalid(error.to_string()))?; + if let Some(active) = activation { + if !active + .process_incarnation + .parse::() + .is_ok_and(|id| id.to_string() == active.process_incarnation) + || active.result_revision != receipt.result_revision + || receipt.config_digest.as_deref() != Some(active.config_digest.as_str()) + || !receipt.converged + || restart + { + return Err(invalid("inconsistent obsolete activation witness".into())); + } + } else if !restart { + return Err(invalid( + "obsolete receipt claims activation without a witness".into(), + )); + } + converted = true; + } + let state = serde_json::from_value(value).map_err(|error| invalid(error.to_string()))?; + validate_state(&state)?; + Ok((state, converted)) +} + impl ClusterStore { /// The default layout: storage root = the config directory itself /// (`file://`), byte-compatible with every pre-existing @@ -142,6 +234,68 @@ impl ClusterStore { } } + /// Refuse symlink/alias substitution before accepting or resuming deletion. + /// The immutable intent stores this exact layout identity, never a caller's + /// arbitrary prefix or the target of a graph-directory symlink. + pub(crate) fn canonical_managed_graph_root(&self, graph: &str) -> Result { + let mut diagnostics = Vec::new(); + crate::config::validate_id("graph", "graph", graph, &mut diagnostics); + if let Some(error) = diagnostics.into_iter().next() { + return Err(error); + } + let expected = format!( + "{}/graphs/{graph}.omni", + self.canonical_root()?.trim_start_matches("file://") + ); + let actual = crate::admission::canonical_graph_uri(&self.graph_root(graph))?; + if actual != expected { + return Err(Diagnostic::error( + "cluster_graph_root_mismatch", + graph, + "graph root is outside the admitted cluster's canonical graph layout", + )); + } + Ok(expected) + } + + pub(crate) async fn delete_managed_graph_root( + &self, + graph: &str, + recorded_root: &str, + ) -> Result<(), Diagnostic> { + if self.canonical_managed_graph_root(graph)? != recorded_root { + return Err(Diagnostic::error( + "cluster_graph_root_mismatch", + graph, + "graph deletion root differs from its accepted identity", + )); + } + self.adapter + .delete_prefix(recorded_root) + .await + .map_err(|error| { + Diagnostic::error( + "deployment_outcome_unknown", + graph, + format!("graph root deletion remains outstanding: {error}"), + ) + })?; + if self + .graph_root_exists(recorded_root) + .await + .map_err(|error| { + Diagnostic::error("deployment_outcome_unknown", graph, error.to_string()) + })? + { + return Err(Diagnostic::error( + "deployment_outcome_unknown", + graph, + "graph root remains present after deletion", + )); + } + Ok(()) + } + /// Display-form storage root (plain local path for `file://`, URI for /// remote object stores). pub(crate) fn display_root(&self) -> &str { @@ -364,30 +518,6 @@ impl ClusterStore { .await } - /// Restore the one valid value for a content-addressed slot under the - /// cluster's sole writer admission. The verified digest fixes every byte; - /// reading damaged content (possibly oversized or non-UTF8) adds no proof. - /// Atomic replacement is bounded by captured source size. A concurrent - /// valid repair of this slot necessarily writes the same bytes. - pub(crate) async fn repair_payload( - &self, - kind: &ResourceKind, - digest: &str, - content: &str, - ) -> Result<(), String> { - let relative = - Self::payload_relative(kind, digest).ok_or("resource kind has no payload")?; - if content.len() > crate::config::MAX_CONFIG_SOURCE_BYTES - || sha256_hex(content.as_bytes()) != digest - { - return Err("repair source does not match its bounded digest".into()); - } - self.adapter - .write_text(&self.uri(&relative), content) - .await - .map_err(|error| error.to_string()) - } - async fn write_content_addressed( &self, relative: &str, @@ -580,14 +710,33 @@ impl ClusterStore { &self, observations: &mut StateObservations, ) -> Result { + self.read_state_inner(observations, false) + .await + .map(|(snapshot, _)| snapshot) + } + + pub(crate) async fn read_state_for_ledger_upgrade( + &self, + ) -> Result<(StateSnapshot, bool), Diagnostic> { + self.read_state_inner(&mut self.observations(), true).await + } + + async fn read_state_inner( + &self, + observations: &mut StateObservations, + upgrade: bool, + ) -> Result<(StateSnapshot, bool), Diagnostic> { let state_uri = self.uri(CLUSTER_STATE_FILE); let (text, _version) = match self.read_versioned_opt(&state_uri).await { Ok(Some(read)) => read, Ok(None) => { - return Ok(StateSnapshot { - state: None, - state_cas: None, - }); + return Ok(( + StateSnapshot { + state: None, + state_cas: None, + }, + false, + )); } Err(err) => { return Err(Diagnostic::error( @@ -602,26 +751,25 @@ impl ClusterStore { let state_cas = format!("sha256:{}", sha256_hex(text.as_bytes())); observations.state_cas = Some(state_cas.clone()); - let mut state = serde_json::from_str::(&text).map_err(|err| { - Diagnostic::error( - "invalid_state_json", - CLUSTER_STATE_FILE, - format!("could not parse state JSON: {err}"), - ) - })?; + let (mut state, converted) = decode_ledger(&text, upgrade)?; validate_state(&state)?; - canonicalize_observation_coordinates(&mut state)?; + if !converted { + canonicalize_observation_coordinates(&mut state)?; + } observations.applied_config_digest = state.applied_revision.config_digest.clone(); observations.state_revision = state.state_revision; observations.resource_count = state.applied_revision.resources.len(); - Ok(StateSnapshot { - state: Some(state), - state_cas: Some(state_cas), - }) + Ok(( + StateSnapshot { + state: Some(state), + state_cas: Some(state_cas), + }, + converted, + )) } /// CAS-guarded ledger replace. The public contract stays content-level @@ -637,6 +785,26 @@ impl ClusterStore { state: &ClusterState, expected_cas: Option<&str>, observations: &mut StateObservations, + ) -> Result<(), Diagnostic> { + self.write_state_inner(state, expected_cas, observations, false) + .await + } + + pub(crate) async fn write_state_for_ledger_upgrade( + &self, + state: &ClusterState, + expected_cas: &str, + ) -> Result<(), Diagnostic> { + self.write_state_inner(state, Some(expected_cas), &mut self.observations(), true) + .await + } + + async fn write_state_inner( + &self, + state: &ClusterState, + expected_cas: Option<&str>, + observations: &mut StateObservations, + upgrade: bool, ) -> Result<(), Diagnostic> { validate_state(state)?; if state.version != 2 { @@ -658,9 +826,7 @@ impl ClusterStore { ) })?; if let Some((text, _)) = ¤t { - let previous: ClusterState = serde_json::from_str(text).map_err(|err| { - Diagnostic::error("invalid_state_json", CLUSTER_STATE_FILE, err.to_string()) - })?; + let (previous, _) = decode_ledger(text, upgrade)?; validate_state(&previous)?; if previous.version == 2 && state.version != 2 { return Err(Diagnostic::error( diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index 92462a726..e0d5126e9 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -72,14 +72,14 @@ pub(crate) fn identity_fixture() -> tempfile::TempDir { async fn apply_identity_fixture(dir: &Path) -> DeploymentResult { let caller = DeploymentCaller::storage_owner(None); - let applied = apply_deployment(dir, None, &caller, &BTreeMap::new(), |_, _, _| {}) + let applied = apply_deployment(dir, None, &caller, |_, _, _| {}) .await .unwrap(); assert!( matches!(applied, DeploymentLookup::Complete { ref result } if result.converged), "{applied:?}" ); - let captured = capture_deployment(dir, &BTreeMap::new()).unwrap(); + let captured = capture_deployment(dir).unwrap(); if let Some(lock_id) = deployment_status(captured.canonical_root(), None, &caller) .await .unwrap() @@ -1246,7 +1246,7 @@ async fn missing_state_plans_creates() { assert!(out.ok, "{:?}", out.diagnostics); assert!(!out.state_observations.state_found); assert!(!out.state_observations.locked); - assert!(out.state_observations.lock_acquired); + assert!(!out.state_observations.lock_acquired); assert!( out.changes .iter() @@ -1606,18 +1606,14 @@ graphs: } #[tokio::test] -async fn plan_succeeds_after_force_unlock() { +async fn plan_succeeds_with_or_without_an_existing_lock() { let dir = fixture(); write_lock_file(dir.path(), "held-lock", "plan"); let locked = plan_config_dir(dir.path()).await; - assert!(!locked.ok); - assert!( - locked - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "state_lock_held") - ); + assert!(locked.ok, "{:?}", locked.diagnostics); + assert!(locked.state_observations.locked); + assert!(!locked.state_observations.lock_acquired); let unlocked = force_unlock_config_dir(dir.path(), "held-lock").await; assert!(unlocked.ok, "{:?}", unlocked.diagnostics); @@ -1627,7 +1623,7 @@ async fn plan_succeeds_after_force_unlock() { } #[tokio::test] -async fn plan_reports_state_cas_revision_and_removes_lock() { +async fn plan_reports_state_cas_revision_without_creating_a_lock() { let dir = fixture(); let state_dir = dir.path().join(CLUSTER_STATE_DIR); fs::create_dir_all(&state_dir).unwrap(); @@ -1646,16 +1642,13 @@ async fn plan_reports_state_cas_revision_and_removes_lock() { ); assert!(!out.state_observations.locked); assert!(out.state_observations.lock_id.is_none()); - assert!(out.state_observations.lock_acquired); - assert!(out.state_observations.acquired_lock_id.is_some()); - assert!( - !dir.path().join(CLUSTER_LOCK_FILE).exists(), - "plan must release lock before returning" - ); + assert!(!out.state_observations.lock_acquired); + assert!(out.state_observations.acquired_lock_id.is_none()); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); } #[tokio::test] -async fn existing_lock_makes_plan_fail() { +async fn existing_lock_is_observed_by_plan() { let dir = fixture(); let state_dir = dir.path().join(CLUSTER_STATE_DIR); fs::create_dir_all(&state_dir).unwrap(); @@ -1672,7 +1665,7 @@ async fn existing_lock_makes_plan_fail() { .unwrap(); let out = plan_config_dir(dir.path()).await; - assert!(!out.ok); + assert!(out.ok, "{:?}", out.diagnostics); assert!(out.state_observations.locked); assert_eq!(out.state_observations.lock_id.as_deref(), Some("held-lock")); assert!(!out.state_observations.lock_acquired); @@ -1681,15 +1674,8 @@ async fn existing_lock_makes_plan_fail() { out.state_observations.lock_operation.as_deref(), Some("plan") ); - assert!( - out.diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "state_lock_held") - ); - assert!(out.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "state_lock_held" - && diagnostic.message.contains("force-unlock held-lock") - })); + assert!(!out.diagnostics.iter().any(|d| d.code == "state_lock_held")); + assert!(dir.path().join(CLUSTER_LOCK_FILE).exists()); } #[tokio::test] @@ -1721,7 +1707,7 @@ graphs: assert!( out.diagnostics .iter() - .any(|diagnostic| diagnostic.code == "state_lock_disabled") + .any(|diagnostic| diagnostic.code == "deployment_requires_lock") ); assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); } @@ -1861,7 +1847,6 @@ async fn durable_store_verifies_immutable_bundle_bytes_and_encoded_bound() { let store = ClusterStore::for_config_dir(dir.path()); let source_digest = sha256_hex(SCHEMA.as_bytes()); let mut bundle = deployment::DeploymentBundle { - options: DeploymentOptions::default(), version: 2, canonical_root: store.canonical_root().unwrap(), config_digest: sha256_hex(b"configuration"), @@ -1945,6 +1930,110 @@ async fn offline_deployment_upgrade_preserves_data_history_and_applied_facts() { apply_identity_fixture(dir.path()).await; let legacy_path = dir.path().join(CLUSTER_STATE_FILE); let mut legacy = read_state_json(dir.path()); + // Earlier v2 builds persisted runtime claims in terminal receipts. Only + // explicit stopped conversion may discard those claims; exact outcomes, + // ledger identity, sequence, achieved resources and graph history survive. + let root = dir.path().to_str().unwrap(); + let caller = DeploymentCaller::storage_owner(Some("principal:owner".into())); + let graph_before = identity_manifest_version(dir.path()).await; + for activated in [false, true] { + let current = read_state_json(dir.path()); + let mut previous = current.clone(); + let receipts = previous["deployment_results"].as_array_mut().unwrap(); + assert!(!receipts.is_empty()); + for receipt in receipts { + receipt["restart_required"] = json!(!activated); + if activated { + receipt["activation"] = json!({ + "process_incarnation": "01K00000000000000000000001", + "result_revision": receipt["result_revision"], + "config_digest": receipt["config_digest"], + }); + } + } + let previous_bytes = serde_json::to_vec(&previous).unwrap(); + fs::write(&legacy_path, &previous_bytes).unwrap(); + assert_eq!( + deployment_status(root, None, &caller) + .await + .unwrap_err() + .code, + "invalid_state_json" + ); + assert_eq!( + upgrade_deployment_ledger(root, false, &caller) + .await + .unwrap_err() + .code, + "writers_stopped_required" + ); + for invalid in ["unknown", "runtime_type", "activation", "pending"] { + let mut corrupt = previous.clone(); + match invalid { + "unknown" => corrupt["deployment_results"][0]["unknown_field"] = json!(true), + "runtime_type" => { + corrupt["deployment_results"][0]["restart_required"] = json!("false") + } + "activation" => { + corrupt["deployment_results"][0]["restart_required"] = json!(false); + corrupt["deployment_results"][0]["activation"] = json!({ + "process_incarnation": "01K00000000000000000000001", + "result_revision": u64::MAX, + "config_digest": corrupt["deployment_results"][0]["config_digest"], + }); + } + "pending" => corrupt["outstanding"] = json!({"id": "originating-build-required"}), + _ => unreachable!(), + } + let corrupt_bytes = serde_json::to_vec(&corrupt).unwrap(); + fs::write(&legacy_path, &corrupt_bytes).unwrap(); + let refused = upgrade_deployment_ledger(root, true, &caller) + .await + .unwrap_err(); + assert_eq!( + refused.code, + if invalid == "pending" { + "ledger_upgrade_pending" + } else { + "invalid_state_json" + } + ); + assert_eq!(fs::read(&legacy_path).unwrap(), corrupt_bytes); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + } + for member in ["version", "input_digest", "restart_required"] { + let prefix = format!("\"{member}\":"); + let duplicate = String::from_utf8(previous_bytes.clone()).unwrap().replacen( + &prefix, + &format!("{prefix}null,{prefix}"), + 1, + ); + fs::write(&legacy_path, &duplicate).unwrap(); + assert_eq!( + upgrade_deployment_ledger(root, true, &caller) + .await + .unwrap_err() + .code, + "invalid_state_json" + ); + assert_eq!(fs::read_to_string(&legacy_path).unwrap(), duplicate); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + } + fs::write(&legacy_path, &previous_bytes).unwrap(); + let converted = upgrade_deployment_ledger(root, true, &caller) + .await + .unwrap(); + let mut expected = current; + expected["state_revision"] = json!(expected["state_revision"].as_u64().unwrap() + 1); + assert_eq!(read_state_json(dir.path()), expected); + assert!(converted.lock_id.is_none()); + assert_eq!(identity_manifest_version(dir.path()).await, graph_before); + let converted_bytes = fs::read(&legacy_path).unwrap(); + upgrade_deployment_ledger(root, true, &caller) + .await + .unwrap(); + assert_eq!(fs::read(&legacy_path).unwrap(), converted_bytes); + } legacy["version"] = json!(1); for key in [ "ledger_id", @@ -2108,24 +2197,18 @@ async fn offline_deployment_captures_schema_queries_and_retains_original_lookup( let query = QUERY.replace("$p.name, $p.age", "$p.name, $p.age, $p.email"); fs::write(dir.path().join("people.gq"), &query).unwrap(); let mut exposed = None; - let outcome = apply_deployment( - dir.path(), - None, - &caller, - &Default::default(), - |id, canonical_root, lock_id| { - assert!(read_state_json(dir.path()).get("outstanding").is_none()); - assert!(dir.path().join(CLUSTER_LOCK_FILE).exists()); - exposed = Some(( - id.to_string(), - canonical_root.to_string(), - lock_id.to_string(), - )); - // Capture preceded ID exposure; effects must use those exact bytes. - fs::write(dir.path().join("people.pg"), "invalid schema").unwrap(); - fs::write(dir.path().join("people.gq"), "invalid query").unwrap(); - }, - ) + let outcome = apply_deployment(dir.path(), None, &caller, |id, canonical_root, lock_id| { + assert!(read_state_json(dir.path()).get("outstanding").is_none()); + assert!(dir.path().join(CLUSTER_LOCK_FILE).exists()); + exposed = Some(( + id.to_string(), + canonical_root.to_string(), + lock_id.to_string(), + )); + // Capture preceded ID exposure; effects must use those exact bytes. + fs::write(dir.path().join("people.pg"), "invalid schema").unwrap(); + fs::write(dir.path().join("people.gq"), "invalid query").unwrap(); + }) .await .unwrap(); let DeploymentLookup::Complete { result } = outcome else { @@ -2133,7 +2216,7 @@ async fn offline_deployment_captures_schema_queries_and_retains_original_lookup( }; let (id, canonical_root, lock_id) = exposed.unwrap(); assert_eq!(result.id, id); - assert!(result.converged && result.restart_required); + assert!(result.converged); assert_eq!(result.result_revision, 2); assert_eq!(result.authority.actor.as_deref(), Some("principal:owner")); assert_eq!(result.authority.kind, AuthorityKind::AuthenticatedIdentity); @@ -2181,26 +2264,18 @@ async fn offline_deployment_captures_schema_queries_and_retains_original_lookup( fs::write(dir.path().join("people.pg"), &schema).unwrap(); fs::write(dir.path().join("people.gq"), &query).unwrap(); assert!(matches!( - apply_deployment( - dir.path(), - Some(&id), - &caller, - &Default::default(), - |_, _, _| panic!("lookup cannot invoke") - ) + apply_deployment(dir.path(), Some(&id), &caller, |_, _, _| panic!( + "lookup cannot invoke" + )) .await .unwrap(), DeploymentLookup::Complete { .. } )); fs::write(dir.path().join("people.gq"), format!("{query}\n")).unwrap(); assert_eq!( - apply_deployment( - dir.path(), - Some(&id), - &caller, - &Default::default(), - |_, _, _| panic!("mismatch cannot invoke") - ) + apply_deployment(dir.path(), Some(&id), &caller, |_, _, _| panic!( + "mismatch cannot invoke" + )) .await .unwrap_err() .code, @@ -2261,7 +2336,7 @@ async fn offline_deployment_query_only_preserves_branched_graph_history() { .unwrap(); let before_refused_plan = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); expand_identity_schema(dir.path()); - let refused = apply_deployment(dir.path(), None, &caller, &Default::default(), |_, _, _| {}) + let refused = apply_deployment(dir.path(), None, &caller, |_, _, _| {}) .await .unwrap_err(); assert_eq!(refused.code, "schema_preflight_failed"); @@ -2289,7 +2364,7 @@ async fn offline_deployment_query_only_preserves_branched_graph_history() { ) .unwrap(); let DeploymentLookup::Complete { result } = - apply_deployment(dir.path(), None, &caller, &Default::default(), |_, _, _| {}) + apply_deployment(dir.path(), None, &caller, |_, _, _| {}) .await .unwrap() else { @@ -2390,7 +2465,7 @@ async fn offline_deployment_query_only_preserves_branched_graph_history() { .unwrap(); fs::write(&config_path, config).unwrap(); // Re-add the stored query only. - let error = apply_deployment(dir.path(), None, &caller, &Default::default(), |_, _, _| {}) + let error = apply_deployment(dir.path(), None, &caller, |_, _, _| {}) .await .unwrap_err(); assert_eq!(error.code, "applied_schema_drift"); @@ -2412,63 +2487,11 @@ async fn offline_deployment_query_only_preserves_branched_graph_history() { // Changing desired bytes does not implicitly acknowledge a new lifetime. fs::write(dir.path().join("people.pg"), format!("{SCHEMA}\n")).unwrap(); - let error = apply_deployment(dir.path(), None, &caller, &Default::default(), |_, _, _| {}) + let error = apply_deployment(dir.path(), None, &caller, |_, _, _| {}) .await .unwrap_err(); assert_eq!(error.code, "applied_schema_drift"); - assert!( - error - .message - .contains(&replaced_contract.schema_identity_domain) - ); - assert!( - deployment_status(root, None, &caller) - .await - .unwrap() - .lock_id - .is_none() - ); - assert_eq!( - fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), - ledger_before - ); - assert_eq!( - identity_manifest_version(dir.path()).await, - replacement_version - ); - - // An explicit correction also works without changing source bytes, but - // acknowledges exactly the observed contract and still authorizes schema. - fs::write(dir.path().join("people.pg"), SCHEMA).unwrap(); - for corrections in [ - BTreeMap::from([("knowledge".to_owned(), achieved_contract.clone())]), - BTreeMap::from([("unknown".to_owned(), replaced_contract.clone())]), - ] { - let error = apply_deployment(dir.path(), None, &caller, &corrections, |_, _, _| {}) - .await - .unwrap_err(); - assert!(matches!( - error.code.as_str(), - "applied_schema_drift" | "deployment_input_invalid" - )); - assert!( - deployment_status(root, None, &caller) - .await - .unwrap() - .lock_id - .is_none() - ); - assert_eq!( - fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), - ledger_before - ); - } - let corrections = BTreeMap::from([("knowledge".to_owned(), replaced_contract.clone())]); - let denied = DeploymentCaller::storage_owner(Some("principal:reader".into())); - let error = apply_deployment(dir.path(), None, &denied, &corrections, |_, _, _| {}) - .await - .unwrap_err(); - assert_eq!(error.code, "schema_preflight_failed"); + assert!(error.message.contains("knowledge"), "{error:?}"); assert!( deployment_status(root, None, &caller) .await @@ -2480,70 +2503,10 @@ async fn offline_deployment_query_only_preserves_branched_graph_history() { fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), ledger_before ); - let DeploymentLookup::Complete { result } = - apply_deployment(dir.path(), None, &caller, &corrections, |_, _, _| {}) - .await - .unwrap() - else { - panic!("expected exact authorized correction"); - }; - assert!(result.converged); - assert!(matches!( - &result.graphs["knowledge"], - GraphDeploymentResult::Schema { result: omnigraph::db::SchemaApplySettlement::NoOp { contract, .. } } - if contract == &replaced_contract - )); assert_eq!( identity_manifest_version(dir.path()).await, replacement_version ); - assert_eq!( - read_state_json(dir.path())["applied_revision"]["schema_contracts"]["knowledge"], - serde_json::to_value(&replaced_contract).unwrap() - ); - assert!(matches!( - apply_deployment( - dir.path(), - Some(&result.id), - &caller, - &corrections, - |_, _, _| panic!("lookup only") - ) - .await - .unwrap(), - DeploymentLookup::Complete { .. } - )); - assert_eq!( - apply_deployment( - dir.path(), - Some(&result.id), - &caller, - &Default::default(), - |_, _, _| panic!("input mismatch") - ) - .await - .unwrap_err() - .code, - "deployment_input_mismatch" - ); - let status = deployment_status(root, None, &caller).await.unwrap(); - force_unlock_storage_root(root, status.lock_id.as_deref().unwrap()) - .await - .unwrap(); - assert_eq!( - apply_deployment(dir.path(), None, &caller, &corrections, |_, _, _| {}) - .await - .unwrap_err() - .code, - "schema_correction_unneeded" - ); - assert!( - deployment_status(root, None, &caller) - .await - .unwrap() - .lock_id - .is_none() - ); } #[tokio::test] async fn offline_deployment_ids_survive_result_eviction_without_aliasing_or_replay() { @@ -2563,15 +2526,13 @@ async fn offline_deployment_ids_survive_result_eviction_without_aliasing_or_repl for sequence in upgraded.next_sequence..=upgraded.next_sequence + deployment::MAX_RESULTS as u64 { let id = format!("{}:{sequence}:{}", upgraded.ledger_id, Ulid::new()); - let DeploymentLookup::Complete { result } = apply_deployment( - dir.path(), - Some(&id), - &caller, - &Default::default(), - |reported, _, _| assert_eq!(reported, id), - ) - .await - .unwrap() else { + let DeploymentLookup::Complete { result } = + apply_deployment(dir.path(), Some(&id), &caller, |reported, _, _| { + assert_eq!(reported, id) + }) + .await + .unwrap() + else { panic!("expected no-op deployment receipt"); }; assert_eq!(result.id, id); @@ -2586,13 +2547,9 @@ async fn offline_deployment_ids_survive_result_eviction_without_aliasing_or_repl Some(DeploymentLookup::IdentityMismatch) )); assert!(matches!( - apply_deployment( - dir.path(), - Some(&alias), - &caller, - &Default::default(), - |_, _, _| panic!("alias cannot execute") - ) + apply_deployment(dir.path(), Some(&alias), &caller, |_, _, _| panic!( + "alias cannot execute" + )) .await .unwrap(), DeploymentLookup::IdentityMismatch @@ -2639,13 +2596,9 @@ async fn offline_deployment_ids_survive_result_eviction_without_aliasing_or_repl }) if acceptance == "unknown" && outcome == "unknown" )); assert!(matches!( - apply_deployment( - dir.path(), - Some(id), - &caller, - &Default::default(), - |_, _, _| panic!("expired ID cannot execute") - ) + apply_deployment(dir.path(), Some(id), &caller, |_, _, _| panic!( + "expired ID cannot execute" + )) .await .unwrap(), DeploymentLookup::ResultExpired { .. } @@ -2660,13 +2613,9 @@ async fn offline_deployment_ids_survive_result_eviction_without_aliasing_or_repl Some(DeploymentLookup::NotRecorded) )); assert_eq!( - apply_deployment( - dir.path(), - Some(&future_id), - &caller, - &Default::default(), - |_, _, _| panic!("future sequence cannot execute") - ) + apply_deployment(dir.path(), Some(&future_id), &caller, |_, _, _| panic!( + "future sequence cannot execute" + )) .await .unwrap_err() .code, @@ -2747,7 +2696,7 @@ async fn offline_deployment_reserves_created_query_projection_before_schema_effe captured.outcome.diagnostics ); - let error = apply_deployment(dir.path(), None, &caller, &Default::default(), |_, _, _| {}) + let error = apply_deployment(dir.path(), None, &caller, |_, _, _| {}) .await .unwrap_err(); assert_eq!(error.code, "deployment_bounds", "{error:?}"); @@ -2847,7 +2796,6 @@ fn offline_deployment_reserves_partial_projection_resource_count() { GraphDeployment { intent: None, create: None, - adopt: None, delete: None, observed_manifest_version: 1, state: GraphDeploymentState::NotStarted, @@ -2870,7 +2818,6 @@ fn offline_deployment_reserves_partial_projection_resource_count() { } assert!(desired.len() <= deployment::MAX_RESOURCES); let bundle = DeploymentBundle { - options: DeploymentOptions::default(), version: 2, canonical_root: "file:///capacity-only".into(), config_digest: "e".repeat(64), @@ -2952,7 +2899,6 @@ fn offline_deployment_graph_completion_reserve_covers_maximum_serialized_engine_ // change. Null cancels that same term in both sides of this comparison. intent: None, create: None, - adopt: None, delete: None, observed_manifest_version: u64::MAX, state: GraphDeploymentState::NotStarted, @@ -3294,13 +3240,7 @@ async fn plan_embeds_migration_preview_for_schema_update() { ) .unwrap(); - let out = plan_config_dir_with_deployment_options( - dir.path(), - PlanOptions::default(), - &DeploymentOptions::default(), - Some("principal:owner".into()), - ) - .await; + let out = plan_config_dir_as(dir.path(), Some("principal:owner".into())).await; assert!(out.ok, "{:?}", out.diagnostics); let schema_change = out .changes @@ -3322,15 +3262,9 @@ async fn plan_embeds_migration_preview_for_schema_update() { db.branch_create("feature").await.unwrap(); let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); let version = identity_manifest_version(dir.path()).await; - let plan = plan_config_dir_with_deployment_options( - dir.path(), - PlanOptions::default(), - &DeploymentOptions::default(), - Some("principal:owner".into()), - ) - .await; + let plan = plan_config_dir_as(dir.path(), Some("principal:owner".into())).await; assert!(!plan.ok, "{plan:?}"); - let error = apply_deployment(dir.path(), None, &caller, &BTreeMap::new(), |_, _, _| {}) + let error = apply_deployment(dir.path(), None, &caller, |_, _, _| {}) .await .unwrap_err(); assert!( @@ -4243,7 +4177,7 @@ graphs: } #[tokio::test] -async fn plan_observe_takes_no_lock_and_reports_observed_authority() { +async fn plan_takes_no_lock_and_reports_observed_authority() { let dir = fixture(); let state_dir = dir.path().join(CLUSTER_STATE_DIR); fs::create_dir_all(&state_dir).unwrap(); @@ -4259,7 +4193,7 @@ async fn plan_observe_takes_no_lock_and_reports_observed_authority() { ) .unwrap(); - let out = plan_config_dir_with_options(dir.path(), PlanOptions { observe: true }).await; + let out = plan_config_dir(dir.path()).await; assert!(out.ok, "{:?}", out.diagnostics); assert_eq!(out.authority, LedgerAuthority::Observed); assert_eq!(out.state_observations.state_revision, 7); @@ -4283,11 +4217,11 @@ async fn plan_observe_takes_no_lock_and_reports_observed_authority() { .unwrap() .contains("01OTHER") ); - // The locked path still labels itself. + // A plan without an existing owner also remains observational. fs::remove_file(dir.path().join(CLUSTER_LOCK_FILE)).unwrap(); let locked = plan_config_dir(dir.path()).await; - assert_eq!(locked.authority, LedgerAuthority::Locked); - assert!(locked.state_observations.lock_acquired); + assert_eq!(locked.authority, LedgerAuthority::Observed); + assert!(!locked.state_observations.lock_acquired); } #[tokio::test] @@ -4587,7 +4521,6 @@ graphs: dir.path(), None, &DeploymentCaller::storage_owner(None), - &BTreeMap::new(), |_, _, _| {}, ) .await @@ -4739,31 +4672,35 @@ async fn serving_snapshot_reads_applied_empty_cluster() { // ---- query discovery (Terraform-style declaration) ---- #[tokio::test] -async fn plan_requires_exact_removal_confirmation_and_accepts_policy_rebinding() { - let dir = fixture(); - apply_identity_fixture(dir.path()).await; +async fn plan_observes_graph_removal_and_policy_rebinding() { + let dir = identity_fixture(); let path = dir.path().join(CLUSTER_CONFIG_FILE); - let original = fs::read_to_string(&path).unwrap(); + let original = fs::read_to_string(&path) + .unwrap() + .replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"); + fs::write(&path, &original).unwrap(); + apply_identity_fixture(dir.path()).await; let ledger = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); for source in [ "version: 1\ngraphs: {}\n".to_owned(), - original.replace("applies_to: [knowledge]", "applies_to: [cluster]"), + original.replace("applies_to: [knowledge]", "applies_to: [second]"), ] { fs::write(&path, &source).unwrap(); - let plan = plan_config_dir(dir.path()).await; - let removing = !source.contains("knowledge"); - assert_eq!(plan.ok, !removing, "{plan:?}"); - if removing { + let plan = plan_config_dir_as(dir.path(), Some("principal:owner".into())).await; + assert!(plan.ok, "{plan:?}"); + if !source.contains("knowledge") { assert!( - plan.diagnostics + plan.changes .iter() - .any(|diagnostic| diagnostic.code == "graph_delete_confirmation_required"), - "{plan:?}" + .any(|change| change.resource == "graph.knowledge" + && change.operation == PlanOperation::Delete) ); + assert!(dir.path().join("graphs/knowledge.omni").exists()); + } else { assert!( plan.changes .iter() - .all(|change| change.disposition == Some(ApplyDisposition::Blocked)) + .any(|change| change.resource == "policy.base" && change.binding_change) ); } let wire = serde_json::to_value(&plan).unwrap(); diff --git a/crates/omnigraph-cluster/src/types.rs b/crates/omnigraph-cluster/src/types.rs index e8d0188fe..0fb9e791d 100644 --- a/crates/omnigraph-cluster/src/types.rs +++ b/crates/omnigraph-cluster/src/types.rs @@ -193,9 +193,6 @@ pub struct PlanChange { /// whose applied ledger metadata needs to converge. #[serde(skip_serializing_if = "Option::is_none")] pub metadata_change: Option, - /// Explicit lifecycle work remains visible even when source digests match. - #[serde(skip_serializing_if = "Option::is_none")] - pub lifecycle: Option, /// For schema updates: the engine's migration plan against the live /// graph (RFC-004 §D7's data-aware preview). Absent when the preview is /// unavailable (error `schema_preview_unavailable`). @@ -203,16 +200,6 @@ pub struct PlanChange { pub migration: Option, } -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PlanLifecycleAction { - RemoveRetainingStorage, - Adopt, - Recreate, - RepairCatalog, - CorrectSchemaContract, -} - #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PlanMetadataChange { @@ -229,7 +216,7 @@ pub struct BlastRadius { #[derive(Debug, Clone, Serialize)] pub struct PlanOutput { pub ok: bool, - /// Whether this plan held the cluster lock or only observed the ledger. + /// Plans are observations and never reserve writer authority. pub authority: LedgerAuthority, pub config_dir: String, pub desired_revision: DesiredRevision, @@ -274,13 +261,6 @@ pub enum LedgerAuthority { Observed, } -/// Options for [`crate::plan_config_dir_with_options`]. -#[derive(Debug, Clone, Copy, Default)] -pub struct PlanOptions { - /// Plan without the cluster lock and label the output `observed`. - pub observe: bool, -} - #[derive(Debug, Clone, Serialize)] pub struct StateSyncOutput { pub ok: bool, diff --git a/crates/omnigraph-cluster/tests/failpoints.rs b/crates/omnigraph-cluster/tests/failpoints.rs index 68c18c3f0..0cfdd7136 100644 --- a/crates/omnigraph-cluster/tests/failpoints.rs +++ b/crates/omnigraph-cluster/tests/failpoints.rs @@ -8,7 +8,6 @@ #![cfg(feature = "failpoints")] -use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -82,15 +81,9 @@ async fn offline_fixture() -> tempfile::TempDir { config.split("policies:").next().unwrap(), ) .unwrap(); - let applied = apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + let applied = apply_deployment(dir.path(), None, &deployment_owner(), |_, _, _| {}) + .await + .unwrap(); assert!( matches!(applied, DeploymentLookup::Complete { ref result } if result.converged), "{applied:?}" @@ -169,7 +162,6 @@ async fn offline_deployment_failure_windows_preserve_original_identity() { dir.path(), None, &deployment_owner(), - &Default::default(), |issued, _, _| id = issued.into(), )) .await @@ -273,6 +265,134 @@ async fn offline_deployment_failure_windows_preserve_original_identity() { "lookup wrote another publication" ); } + // Deletion is completion work, unlike replaying a schema invocation. The + // original accepted root survives both lost acknowledgement and a partial + // purge whose manifest can no longer be opened. + for (seam, damage) in [ + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_ACCEPTANCE, + "none", + ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_STARTED, + "none", + ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_STARTED, + "partial", + ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_STARTED, + "foreign", + ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_SCHEMA, + "none", + ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_BEFORE_RESULT, + "none", + ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_RESULT, + "none", + ), + ] { + let dir = offline_fixture().await; + let root = dir.path().to_str().unwrap(); + let graph = dir.path().join("graphs/knowledge.omni"); + let contract = Omnigraph::open_read_only(graph.to_str().unwrap()) + .await + .unwrap() + .schema_contract_digest(); + fs::write(dir.path().join("cluster.yaml"), "version: 1\ngraphs: {}\n").unwrap(); + let mut id = String::new(); + { + let _fail = seam.fire_always(); + let error = apply_deployment(dir.path(), None, &deployment_owner(), |issued, _, _| { + id = issued.into() + }) + .await + .unwrap_err(); + assert_eq!( + error.code, + "injected_failpoint", + "{}: {error:?}", + seam.name() + ); + } + let status = omnigraph_cluster::deployment_status(root, Some(&id), &deployment_owner()) + .await + .unwrap(); + if status.outstanding_id.is_some() { + let state: serde_json::Value = + serde_json::from_slice(&fs::read(state_path(dir.path())).unwrap()).unwrap(); + assert!( + state["applied_revision"]["resources"] + .get("graph.knowledge") + .is_some() + ); + assert!( + state["outstanding"]["graphs"]["knowledge"]["delete"]["root"] + .as_str() + .unwrap() + .ends_with("/graphs/knowledge.omni") + ); + unlock_offline(dir.path()).await; + } + match damage { + "partial" => fs::remove_dir_all(graph.join("__manifest")).unwrap(), + "foreign" => { + fs::remove_dir_all(&graph).unwrap(); + Omnigraph::init(graph.to_str().unwrap(), SCHEMA) + .await + .unwrap(); + } + _ => {} + } + fs::remove_file(dir.path().join("cluster.yaml")).unwrap(); + fs::remove_file(dir.path().join("people.pg")).unwrap(); + let recovered = reconcile_deployment(root, &id, true, &deployment_owner()).await; + if damage == "foreign" { + assert_eq!(recovered.unwrap_err().code, "deployment_outcome_unknown"); + assert!(graph.exists()); + assert_eq!( + omnigraph_cluster::deployment_status(root, Some(&id), &deployment_owner()) + .await + .unwrap() + .outstanding_id + .as_deref(), + Some(id.as_str()) + ); + continue; + } + let DeploymentLookup::Complete { result } = recovered.unwrap() else { + panic!("not complete") + }; + assert!(result.converged); + assert!( + matches!(&result.graphs["knowledge"], GraphDeploymentResult::Deleted { contract: deleted } if deleted == &contract) + ); + assert!(!graph.exists(), "{} {damage}", seam.name()); + let snapshot = omnigraph_cluster::read_serving_snapshot_from_storage(root) + .await + .unwrap(); + assert!(snapshot.graphs.is_empty()); + assert!(snapshot.applied_graphs.is_empty()); + fs::create_dir(&graph).unwrap(); + fs::write(graph.join("new-object"), "keep").unwrap(); + let repeated = reconcile_deployment(root, &id, false, &deployment_owner()) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(repeated).unwrap(), + serde_json::to_value(DeploymentLookup::Complete { result }).unwrap() + ); + assert_eq!( + fs::read_to_string(graph.join("new-object")).unwrap(), + "keep" + ); + } } #[tokio::test] @@ -289,7 +409,6 @@ async fn offline_preacceptance_id_never_aliases_a_later_nonce() { dir.path(), None, &deployment_owner(), - &Default::default(), |id, _, _| unaccepted = id.into(), )) .await @@ -306,7 +425,6 @@ async fn offline_preacceptance_id_never_aliases_a_later_nonce() { dir.path(), None, &deployment_owner(), - &Default::default(), |id, _, _| accepted = id.into(), )) .await @@ -340,15 +458,9 @@ async fn offline_partial_result_allows_corrective_successor_without_replay() { fs::write(dir.path().join("cluster.yaml"), &config).unwrap(); // Reuse the existing v1 bootstrap/apply fixture; conversion starts only // after both graphs and their serving resources actually exist. - let applied = apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + let applied = apply_deployment(dir.path(), None, &deployment_owner(), |_, _, _| {}) + .await + .unwrap(); assert!( matches!(applied, DeploymentLookup::Complete { ref result } if result.converged), "{applied:?}" @@ -368,7 +480,6 @@ async fn offline_partial_result_allows_corrective_successor_without_replay() { dir.path(), None, &deployment_owner(), - &Default::default(), |id, _, _| original_id = id.into(), )) .await @@ -436,7 +547,6 @@ async fn offline_partial_result_allows_corrective_successor_without_replay() { dir.path(), None, &deployment_owner(), - &Default::default(), |_, _, _| {}, )) .await @@ -498,7 +608,6 @@ fn offline_deployment_child_process() { Path::new(&root), None, &deployment_owner(), - &Default::default(), |id, _, _| { fs::write(Path::new(&root).join("deployment-child-id"), id).unwrap(); }, @@ -517,7 +626,7 @@ async fn offline_deployment_process_death_preserves_intent_and_fence() { DeploymentLookup, apply_deployment, deployment_status, reconcile_deployment, }; let _scenario = FailScenario::setup(); - for window in [ + for (window, deleting) in [ "deployment.before_acceptance", "deployment.after_acceptance", "deployment.after_started", @@ -526,8 +635,27 @@ async fn offline_deployment_process_death_preserves_intent_and_fence() { "publish.post_merge_pre_ack", "deployment.before_result", "deployment.after_result", - ] { + ] + .into_iter() + .map(|window| (window, false)) + .chain([ + ("deployment.after_started", true), + ("deployment.before_result", true), + ]) { let dir = offline_fixture().await; + let deleted_contract = if deleting { + fs::write(dir.path().join("cluster.yaml"), "version: 1\ngraphs: {}\n").unwrap(); + Some( + Omnigraph::open_read_only( + dir.path().join("graphs/knowledge.omni").to_str().unwrap(), + ) + .await + .unwrap() + .schema_contract_digest(), + ) + } else { + None + }; let root = dir.path().to_str().unwrap(); let mut id = String::new(); let mut command = std::process::Command::new(std::env::current_exe().unwrap()); @@ -548,7 +676,6 @@ async fn offline_deployment_process_death_preserves_intent_and_fence() { dir.path(), None, &deployment_owner(), - &Default::default(), |issued, _, _| id = issued.into(), )) .await @@ -606,7 +733,6 @@ async fn offline_deployment_process_death_preserves_intent_and_fence() { dir.path(), None, &deployment_owner(), - &Default::default(), |issued, _, _| accepted = issued.into(), )) .await @@ -660,6 +786,10 @@ async fn offline_deployment_process_death_preserves_intent_and_fence() { } else { None }; + if deleting { + fs::remove_file(dir.path().join("cluster.yaml")).unwrap(); + fs::remove_file(dir.path().join("people.pg")).unwrap(); + } // This is local process-crash evidence. Killing a remote writer alone // would not establish accepted object-store I/O quiescence. let result = Box::pin(reconcile_deployment(root, &id, true, &deployment_owner())) @@ -669,6 +799,28 @@ async fn offline_deployment_process_death_preserves_intent_and_fence() { panic!("{result:?}"); }; assert_eq!(result.id, id); + if let Some(contract) = deleted_contract { + assert!(result.converged); + assert!( + matches!(&result.graphs["knowledge"], omnigraph_cluster::GraphDeploymentResult::Deleted { contract: actual } if actual == &contract) + ); + assert!(!graph_uri.exists()); + assert!( + deployment_status(root, Some(&id), &deployment_owner()) + .await + .unwrap() + .outstanding_id + .is_none() + ); + let repeated = reconcile_deployment(root, &id, false, &deployment_owner()) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(repeated).unwrap(), + serde_json::to_value(DeploymentLookup::Complete { result }).unwrap() + ); + continue; + } assert_eq!( result.converged, matches!( @@ -741,66 +893,35 @@ fn state_path(config_dir: &Path) -> PathBuf { #[serial] async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { use omnigraph_cluster::seams::catalog as seams; - use omnigraph_cluster::{ - DeploymentOptions, GraphDeploymentResult, apply_deployment_with_options, - reconcile_deployment, - }; + use omnigraph_cluster::{GraphDeploymentResult, reconcile_deployment}; let _scenario = FailScenario::setup(); - for (seam, created, replace, recreate) in [ - (&seams::DEPLOYMENT_AFTER_STARTED, false, false, false), - (&seams::DEPLOYMENT_AFTER_SCHEMA, true, false, false), - (&seams::DEPLOYMENT_AFTER_SCHEMA, true, true, false), - // An unchanged config is not convergence if explicit recreation never - // started. Same-name replacement also requires the exact new genesis. - (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, false, false, true), - (&seams::DEPLOYMENT_AFTER_STARTED, false, false, true), - (&seams::DEPLOYMENT_AFTER_SCHEMA, true, false, true), - (&seams::DEPLOYMENT_AFTER_SCHEMA, true, true, true), + for (seam, created, replace) in [ + (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, false, false), + (&seams::DEPLOYMENT_AFTER_STARTED, false, false), + (&seams::DEPLOYMENT_AFTER_SCHEMA, true, false), + (&seams::DEPLOYMENT_AFTER_SCHEMA, true, true), ] { let dir = fixture(); let root = dir.path().to_str().unwrap(); - let first = apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + let first = apply_deployment(dir.path(), None, &deployment_owner(), |_, _, _| {}) + .await + .unwrap(); assert!(matches!(first, DeploymentLookup::Complete { ref result } if result.converged)); unlock_offline(dir.path()).await; - let target = if recreate { "knowledge" } else { "second" }; - let mut options = DeploymentOptions::default(); - if recreate { - let uri = dir.path().join("graphs/knowledge.omni"); - let db = Omnigraph::open_read_only(uri.to_str().unwrap()) - .await - .unwrap(); - options - .recreate_graphs - .insert("knowledge".into(), db.schema_contract_digest()); - drop(db); - fs::remove_dir_all(uri).unwrap(); - } else { - let path = dir.path().join("cluster.yaml"); - let source = fs::read_to_string(&path).unwrap(); - fs::write( - &path, - source.replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"), - ) - .unwrap(); - } + let target = "second"; + let path = dir.path().join("cluster.yaml"); + let source = fs::read_to_string(&path).unwrap(); + fs::write( + &path, + source.replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"), + ) + .unwrap(); let mut id = String::new(); { let _failure = seam.fire_always(); - let error = apply_deployment_with_options( - dir.path(), - None, - &deployment_owner(), - &options, - |issued, _, _| id = issued.into(), - ) + let error = apply_deployment(dir.path(), None, &deployment_owner(), |issued, _, _| { + id = issued.into() + }) .await .unwrap_err(); assert_eq!(error.code, "injected_failpoint"); @@ -863,15 +984,9 @@ async fn authenticated_bootstrap_reconciles_only_its_original_identity() { let mut id = String::new(); { let _fail = omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_STARTED.fire_always(); - apply_deployment( - dir.path(), - None, - &caller, - &BTreeMap::new(), - |issued, _, _| id = issued.into(), - ) - .await - .unwrap_err(); + apply_deployment(dir.path(), None, &caller, |issued, _, _| id = issued.into()) + .await + .unwrap_err(); } let root = dir.path().to_str().unwrap(); let status = omnigraph_cluster::deployment_status(root, Some(&id), &caller) @@ -882,15 +997,9 @@ async fn authenticated_bootstrap_reconciles_only_its_original_identity() { Some(DeploymentLookup::Outstanding { .. }) )); assert!(matches!( - apply_deployment( - dir.path(), - Some(&id), - &caller, - &BTreeMap::new(), - |_, _, _| {} - ) - .await - .unwrap(), + apply_deployment(dir.path(), Some(&id), &caller, |_, _, _| {}) + .await + .unwrap(), DeploymentLookup::Outstanding { .. } )); let wrong = DeploymentCaller::AuthenticatedIdentity( @@ -924,7 +1033,7 @@ async fn authenticated_bootstrap_reconciles_only_its_original_identity() { let ordinary = DeploymentCaller::AuthenticatedIdentity( IdentityAuthorization::authenticated("creator").unwrap(), ); - let successor = apply_deployment(dir.path(), None, &ordinary, &BTreeMap::new(), |_, _, _| {}) + let successor = apply_deployment(dir.path(), None, &ordinary, |_, _, _| {}) .await .unwrap(); assert!(matches!(successor, DeploymentLookup::Complete { result } if result.converged)); @@ -935,15 +1044,9 @@ async fn authenticated_bootstrap_reconciles_only_its_original_identity() { async fn graph_creation_partial_result_keeps_only_achieved_policy_bindings() { let _scenario = FailScenario::setup(); let dir = fixture(); - apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + apply_deployment(dir.path(), None, &deployment_owner(), |_, _, _| {}) + .await + .unwrap(); unlock_offline(dir.path()).await; let config = fs::read_to_string(dir.path().join("cluster.yaml")).unwrap() .replace("graphs:\n", "providers:\n embedding:\n added:\n kind: mock\n model: test\ngraphs:\n second:\n schema: ./people.pg\n embedding_provider: added\n third:\n schema: ./people.pg\n embedding_provider: added\n") @@ -952,13 +1055,9 @@ async fn graph_creation_partial_result_keeps_only_achieved_policy_bindings() { let mut id = String::new(); { let _fail = omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_SCHEMA.fire_always(); - apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |issued, _, _| id = issued.into(), - ) + apply_deployment(dir.path(), None, &deployment_owner(), |issued, _, _| { + id = issued.into() + }) .await .unwrap_err(); } @@ -996,15 +1095,9 @@ async fn graph_creation_partial_result_keeps_only_achieved_policy_bindings() { ); assert!(!dir.path().join("graphs/third.omni").exists()); unlock_offline(dir.path()).await; - let successor = apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); + let successor = apply_deployment(dir.path(), None, &deployment_owner(), |_, _, _| {}) + .await + .unwrap(); let DeploymentLookup::Complete { result } = successor else { panic!("{successor:?}"); }; @@ -1021,243 +1114,3 @@ async fn graph_creation_partial_result_keeps_only_achieved_policy_bindings() { .any(|policy| policy.applies_to == ["graph.knowledge", "graph.second", "graph.third"]) ); } - -/// Adoption/removal publish only the recorded catalog transition. They never -/// replay graph writes or authorize a newly recreated root during recovery. -#[tokio::test] -#[serial] -async fn exact_graph_lifecycle_reconciles_without_touching_retained_data() { - use omnigraph::db::ReadTarget; - use omnigraph_cluster::seams::catalog as seams; - use omnigraph_cluster::{ - DeploymentOptions, GraphDeploymentResult, GraphLifecycleConfirmation, - apply_deployment_with_options, reconcile_deployment, - }; - let _scenario = FailScenario::setup(); - for (seam, foreign, aliased) in [ - (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, false, false), - (&seams::DEPLOYMENT_BEFORE_RESULT, false, false), - (&seams::DEPLOYMENT_AFTER_RESULT, false, false), - (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, true, false), - #[cfg(unix)] - (&seams::DEPLOYMENT_AFTER_ACCEPTANCE, false, true), - ] { - for remove in [false, true] { - let dir = fixture(); - let root = dir.path().to_str().unwrap(); - let config_path = dir.path().join("cluster.yaml"); - let one_graph = fs::read_to_string(&config_path).unwrap(); - let two_graphs = - one_graph.replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"); - if remove { - fs::write(&config_path, &two_graphs).unwrap(); - } - apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); - unlock_offline(dir.path()).await; - let uri = dir.path().join("graphs/second.omni"); - let db = if remove { - Omnigraph::open(uri.to_str().unwrap()).await.unwrap() - } else { - Omnigraph::init(uri.to_str().unwrap(), SCHEMA) - .await - .unwrap() - }; - let session = omnigraph::Session::from_defaults( - std::sync::Arc::new(db), - omnigraph::settings::SessionSettings::default(), - ); - session - .load_jsonl( - r#"{"type":"Person","data":{"name":"Kept","age":37}}"#, - omnigraph::loader::LoadMode::Merge, - ) - .await - .unwrap(); - drop(session); - let db = Omnigraph::open_read_only(uri.to_str().unwrap()) - .await - .unwrap(); - let confirmation = GraphLifecycleConfirmation { - contract: db.schema_contract_digest(), - graph_manifest_version: db - .snapshot_of(ReadTarget::branch("main")) - .await - .unwrap() - .graph_manifest_version(), - }; - let contents = db.export_jsonl("main", &[]).await.unwrap(); - drop(db); - fs::write(&config_path, if remove { &one_graph } else { &two_graphs }).unwrap(); - let mut options = DeploymentOptions::default(); - if remove { - options - .delete_graphs - .insert("second".into(), confirmation.clone()); - } else { - options - .adopt_graphs - .insert("second".into(), confirmation.clone()); - } - let mut id = String::new(); - { - let _failure = seam.fire_always(); - let error = apply_deployment_with_options( - dir.path(), - None, - &deployment_owner(), - &options, - |issued, _, _| id = issued.into(), - ) - .await - .unwrap_err(); - assert_eq!(error.code, "injected_failpoint"); - } - unlock_offline(dir.path()).await; - if foreign { - fs::remove_dir_all(&uri).unwrap(); - Omnigraph::init(uri.to_str().unwrap(), SCHEMA) - .await - .unwrap(); - } - #[cfg(unix)] - if aliased { - let moved = dir.path().join("unmanaged.omni"); - fs::rename(&uri, &moved).unwrap(); - std::os::unix::fs::symlink(moved, &uri).unwrap(); - } - fs::remove_file(config_path).unwrap(); - fs::remove_file(dir.path().join("people.pg")).unwrap(); - let recovered = reconcile_deployment(root, &id, true, &deployment_owner()).await; - if foreign || aliased { - assert_eq!( - recovered.unwrap_err().code, - if aliased { - "cluster_graph_root_mismatch" - } else { - "graph_lifecycle_confirmation_mismatch" - } - ); - assert_eq!( - omnigraph_cluster::deployment_status(root, Some(&id), &deployment_owner()) - .await - .unwrap() - .outstanding_id - .as_deref(), - Some(id.as_str()) - ); - continue; - } - let DeploymentLookup::Complete { result } = recovered.unwrap() else { - panic!("missing exact result") - }; - assert!(result.converged); - assert_eq!(result.id, id); - assert_eq!( - matches!( - result.graphs["second"], - GraphDeploymentResult::Deleted { - retained_storage: true, - .. - } - ), - remove - ); - assert_eq!( - matches!( - result.graphs["second"], - GraphDeploymentResult::Adopted { .. } - ), - !remove - ); - let db = Omnigraph::open_read_only(uri.to_str().unwrap()) - .await - .unwrap(); - assert_eq!(db.schema_contract_digest(), confirmation.contract); - assert_eq!(db.export_jsonl("main", &[]).await.unwrap(), contents); - } - } -} - -#[tokio::test] -#[serial] -async fn exact_catalog_repair_survives_acceptance_loss_without_source_checkout() { - use omnigraph_cluster::seams::catalog as seams; - use omnigraph_cluster::{ - DeploymentOptions, apply_deployment_with_options, reconcile_deployment, - }; - let _scenario = FailScenario::setup(); - let dir = fixture(); - let root = dir.path().to_str().unwrap(); - apply_deployment( - dir.path(), - None, - &deployment_owner(), - &BTreeMap::new(), - |_, _, _| {}, - ) - .await - .unwrap(); - unlock_offline(dir.path()).await; - let state: serde_json::Value = - serde_json::from_slice(&fs::read(dir.path().join("__cluster/state.json")).unwrap()) - .unwrap(); - let digest = state["applied_revision"]["resources"]["policy.base"]["digest"] - .as_str() - .unwrap(); - let payload = dir - .path() - .join(format!("__cluster/resources/policy/base/{digest}.yaml")); - let original = fs::read(&payload).unwrap(); - fs::write(&payload, [0xff]).unwrap(); - let options = DeploymentOptions { - repair_catalog: ["policy.base".into()].into_iter().collect(), - ..DeploymentOptions::default() - }; - let mut id = String::new(); - { - let _failure = seams::DEPLOYMENT_AFTER_ACCEPTANCE.fire_always(); - assert_eq!( - apply_deployment_with_options( - dir.path(), - None, - &deployment_owner(), - &options, - |issued, _, _| id = issued.into() - ) - .await - .unwrap_err() - .code, - "injected_failpoint" - ); - } - // The exact accepted repair is the only authorized fallback for the - // unreadable applied policy. A root-only lookup cannot invent that source. - let status = omnigraph_cluster::deployment_status(root, Some(&id), &deployment_owner()) - .await - .unwrap(); - omnigraph_cluster::force_unlock_storage_root(root, status.lock_id.as_deref().unwrap()) - .await - .unwrap(); - fs::remove_file(dir.path().join("cluster.yaml")).unwrap(); - fs::remove_file(dir.path().join("base.policy.yaml")).unwrap(); - fs::remove_file(dir.path().join("people.pg")).unwrap(); - fs::remove_file(dir.path().join("people.gq")).unwrap(); - let DeploymentLookup::Complete { result } = - reconcile_deployment(root, &id, true, &deployment_owner()) - .await - .unwrap() - else { - panic!("missing repair receipt") - }; - assert_eq!(result.id, id); - assert!(result.converged); - assert_eq!(fs::read(payload).unwrap(), original); -} diff --git a/crates/omnigraph-cluster/tests/identity_recovery.rs b/crates/omnigraph-cluster/tests/identity_recovery.rs index 09b081d64..88bbdf2ae 100644 --- a/crates/omnigraph-cluster/tests/identity_recovery.rs +++ b/crates/omnigraph-cluster/tests/identity_recovery.rs @@ -12,7 +12,7 @@ use omnigraph::seams::catalog; use omnigraph_cluster::seams::{FailScenario, catalog as cluster_seams}; use omnigraph_cluster::{ AuthorityKind, DeploymentCaller, DeploymentLookup, GraphDeploymentResult, - IdentityAuthorization, PlanOptions, apply_deployment, authorize_apply_plan, deployment_status, + IdentityAuthorization, apply_deployment, authorize_apply_plan, deployment_status, force_unlock_storage_root, plan_config_dir_authorized, reconcile_deployment, upgrade_deployment_ledger, }; @@ -62,7 +62,7 @@ async fn identity_schema_apply_refuses_real_pending_data_recovery_without_effect .unwrap(); fs::write( dir.path().join("cluster.policy.yaml"), - "version: 1\ngroups:\n owners: [principal:operator]\nrules:\n - id: config\n allow: { actors: { group: owners }, actions: [config_manage] }\n", + "version: 1\ngroups:\n owners: [principal:operator, principal:schema]\nrules:\n - id: config\n allow: { actors: { group: owners }, actions: [config_manage] }\n", ) .unwrap(); fs::write( @@ -78,12 +78,7 @@ async fn identity_schema_apply_refuses_real_pending_data_recovery_without_effect ) .unwrap(); let caller = IdentityAuthorization::authenticated("principal:schema").unwrap(); - let planned = Box::pin(plan_config_dir_authorized( - dir.path(), - PlanOptions { observe: true }, - &caller, - )) - .await; + let planned = Box::pin(plan_config_dir_authorized(dir.path(), &caller)).await; assert!(planned.plan.ok, "{:?}", planned.plan.diagnostics); let expected = planned.authorization.unwrap(); @@ -112,12 +107,7 @@ async fn identity_schema_apply_refuses_real_pending_data_recovery_without_effect !graph.join("__recovery").exists(), "an interrupted mutation leaves no recovery record (RFC 0067)" ); - let unblocked = Box::pin(plan_config_dir_authorized( - dir.path(), - PlanOptions { observe: true }, - &caller, - )) - .await; + let unblocked = Box::pin(plan_config_dir_authorized(dir.path(), &caller)).await; assert!( unblocked.plan.ok, "an interrupted mutation must not block planning: {:?}", @@ -129,12 +119,7 @@ async fn identity_schema_apply_refuses_real_pending_data_recovery_without_effect let ledger = dir.path().join("__cluster/state.json"); let before_ledger = fs::read(&ledger).unwrap(); - let preview = Box::pin(plan_config_dir_authorized( - dir.path(), - PlanOptions { observe: true }, - &caller, - )) - .await; + let preview = Box::pin(plan_config_dir_authorized(dir.path(), &caller)).await; assert!( !preview.plan.ok, "pending data recovery must block a new plan" @@ -149,7 +134,6 @@ async fn identity_schema_apply_refuses_real_pending_data_recovery_without_effect dir.path(), None, &DeploymentCaller::AuthenticatedIdentity(caller.clone()), - &BTreeMap::new(), |_, _, _| {}, ) .await; @@ -171,12 +155,12 @@ async fn identity_schema_apply_refuses_real_pending_data_recovery_without_effect // cannot interpret one, and only the build that wrote it may resolve it. let owner = DeploymentCaller::storage_owner(Some("principal:schema".into())); assert!( - apply_deployment(dir.path(), None, &owner, &BTreeMap::new(), |_, _, _| {}) + apply_deployment(dir.path(), None, &owner, |_, _, _| {}) .await .is_err() ); fs::remove_file(graph.join("__recovery/01LEGACYSIDECAR.json")).unwrap(); - let resolved = apply_deployment(dir.path(), None, &owner, &BTreeMap::new(), |_, _, _| {}) + let resolved = apply_deployment(dir.path(), None, &owner, |_, _, _| {}) .await .unwrap(); assert!( @@ -282,7 +266,6 @@ async fn offline_recovery_reauthorizes_executor_and_preserves_original_and_fence dir.path(), None, &original, - &Default::default(), |issued, _, _| id = issued.to_string(), )) .await @@ -486,7 +469,7 @@ async fn offline_recovery_reauthorizes_executor_and_preserves_original_and_fence async fn bootstrap(dir: &Path) { let owner = DeploymentCaller::storage_owner(None); - let result = apply_deployment(dir, None, &owner, &BTreeMap::new(), |_, _, _| {}) + let result = apply_deployment(dir, None, &owner, |_, _, _| {}) .await .unwrap(); assert!( diff --git a/crates/omnigraph-cluster/tests/s3_cluster.rs b/crates/omnigraph-cluster/tests/s3_cluster.rs index 8779dedaf..72e7cf44e 100644 --- a/crates/omnigraph-cluster/tests/s3_cluster.rs +++ b/crates/omnigraph-cluster/tests/s3_cluster.rs @@ -1,6 +1,6 @@ //! Cluster ledger v2 on object storage: bootstrap, graph creation, catalog //! publication, serving snapshots from config and bare storage roots, schema -//! evolution, exact admission handoff, and unsupported graph-deletion refusal. +//! evolution, exact admission handoff, and exact managed-root graph deletion. //! //! Each provider is independently gated. S3 skips unless //! `OMNIGRAPH_S3_TEST_BUCKET` is set; Azure skips unless @@ -86,7 +86,7 @@ policies: async fn deploy_fixture(dir: &std::path::Path, root: &str) -> omnigraph_cluster::DeploymentResult { let caller = DeploymentCaller::storage_owner(Some("act-admin".into())); - let applied = apply_deployment(dir, None, &caller, &Default::default(), |_, _, _| {}) + let applied = apply_deployment(dir, None, &caller, |_, _, _| {}) .await .unwrap(); let DeploymentLookup::Complete { result } = applied else { @@ -284,35 +284,50 @@ async fn object_storage_cluster_full_lifecycle(root: &str, expected_scheme: &str serde_json::from_str(&adapter.read_text(&ledger_path).await.unwrap()).unwrap(); let evolved_revision = evolved["state_revision"].as_u64().unwrap(); - // Inventory deletion requires exact lifecycle confirmation and never - // recursively erases graph storage. - let before_delete = adapter.read_text(&ledger_path).await.unwrap(); + // An omitted declaration authorizes the whole exact managed root, while + // neighboring prefixes and shared external objects remain untouched. + let contract = Omnigraph::open_read_only(&graph_root) + .await + .unwrap() + .schema_contract_digest(); + let neighbor = format!("{graph_root}-neighbor/keep"); + let external_blob = format!("{root}/shared-blobs/keep"); + adapter.write_text(&neighbor, "neighbor").await.unwrap(); + adapter + .write_text(&external_blob, "external") + .await + .unwrap(); fs::write( dir.path().join("cluster.yaml"), format!("version: 1\nstorage: {root}\ngraphs: {{}}\n"), ) .unwrap(); - let refused = apply_deployment( - dir.path(), - None, + let deleted = deploy_fixture(dir.path(), root).await; + assert!( + matches!(&deleted.graphs["knowledge"], omnigraph_cluster::GraphDeploymentResult::Deleted { contract: old } if old == &contract) + ); + assert!(!adapter.exists(&graph_root).await.unwrap()); + assert_eq!(adapter.read_text(&neighbor).await.unwrap(), "neighbor"); + assert_eq!(adapter.read_text(&external_blob).await.unwrap(), "external"); + let snapshot = read_serving_snapshot_from_storage(root).await.unwrap(); + assert!(snapshot.graphs.is_empty()); + assert!(snapshot.applied_graphs.is_empty()); + assert!(snapshot.state_revision > evolved_revision); + let ledger = adapter.read_text(&ledger_path).await.unwrap(); + let lookup = omnigraph_cluster::reconcile_deployment( + root, + &deleted.id, + false, &DeploymentCaller::storage_owner(Some("act-admin".into())), - &Default::default(), - |_, _, _| {}, ) .await - .unwrap_err(); - assert_eq!(refused.code, "graph_delete_confirmation_required"); - assert_eq!( - adapter.read_text(&ledger_path).await.unwrap(), - before_delete - ); - let snapshot = read_serving_snapshot_from_storage(root).await.unwrap(); - assert_eq!(snapshot.graphs.len(), 1); - assert_eq!(snapshot.state_revision, evolved_revision); + .unwrap(); + assert!(matches!(lookup, DeploymentLookup::Complete { result } if result.id == deleted.id)); + assert_eq!(adapter.read_text(&ledger_path).await.unwrap(), ledger); adapter.delete_prefix(root).await.unwrap(); } -/// Same fixture and backend matrix as the v1 lifecycle above. This checks the +/// Same fixture and backend matrix as the lifecycle above. This checks the /// actual object-store CAS and retained-lock contract, not live-Azure fencing. async fn object_storage_offline_deployment(root: &str) { let dir = tempfile::tempdir().unwrap(); @@ -357,17 +372,13 @@ async fn object_storage_offline_deployment(root: &str) { let query = FIND_PERSON_GQ.replace("return { $p.name }", "return { $p.name, $p.title }"); fs::write(dir.path().join("queries/people.gq"), &query).unwrap(); let mut reported = None; - let DeploymentLookup::Complete { result } = apply_deployment( - dir.path(), - None, - &caller, - &Default::default(), - |id, _, lock| { + let DeploymentLookup::Complete { result } = + apply_deployment(dir.path(), None, &caller, |id, _, lock| { reported = Some((id.to_string(), lock.to_string())); - }, - ) - .await - .unwrap() else { + }) + .await + .unwrap() + else { panic!("expected durable deployment receipt"); }; let (id, lock) = reported.unwrap(); @@ -395,13 +406,9 @@ async fn object_storage_offline_deployment(root: &str) { Some(lock.as_str()) ); assert!(matches!( - apply_deployment( - dir.path(), - Some(&id), - &caller, - &Default::default(), - |_, _, _| panic!("same ID cannot execute twice") - ) + apply_deployment(dir.path(), Some(&id), &caller, |_, _, _| panic!( + "same ID cannot execute twice" + )) .await .unwrap(), DeploymentLookup::Complete { .. } @@ -440,7 +447,7 @@ async fn object_storage_offline_deployment(root: &str) { let ledger = adapter.read_text(&ledger_uri).await.unwrap(); adapter.write_text(&bundle_uri, "{}").await.unwrap(); assert_eq!( - apply_deployment(dir.path(), None, &caller, &Default::default(), |_, _, _| {}) + apply_deployment(dir.path(), None, &caller, |_, _, _| {}) .await .unwrap_err() .code, diff --git a/crates/omnigraph-server/examples/bench_actor_isolation.rs b/crates/omnigraph-server/examples/bench_actor_isolation.rs index 0b4a7cf7c..eb0a94abd 100644 --- a/crates/omnigraph-server/examples/bench_actor_isolation.rs +++ b/crates/omnigraph-server/examples/bench_actor_isolation.rs @@ -2,13 +2,13 @@ //! //! The handoff calls this out as the empirical proof of MR-686's central //! design promise: per-actor admission control isolates noisy actors so a -//! heavy `/ingest` user does not starve light `/change` traffic. The +//! heavy `/load` user does not starve light `/mutate` traffic. The //! per-`(table, branch)` queue pins the same-key serialization story; this //! bench pins actor isolation under load. //! //! Setup: -//! - One "heavy" actor flooding `/ingest` with multi-row NDJSON bodies. -//! - N "light" actors each running short bursts of `/change` inserts. +//! - One "heavy" actor flooding `/load` with multi-row NDJSON bodies. +//! - N "light" actors each running short bursts of `/mutate` inserts. //! - Each actor authenticates with its own bearer token so the //! `WorkloadController` accounts them as distinct identities. //! @@ -48,20 +48,20 @@ const HEAVY_ACTOR: &str = "act-heavy"; #[derive(Parser, Debug)] #[command(about = "Actor-isolation HTTP bench for MR-686 WorkloadController")] struct Args { - /// Number of light actors driving /change traffic concurrently with the - /// heavy /ingest flood. Each gets its own bearer token. + /// Number of light actors driving /mutate traffic concurrently with the + /// heavy /load flood. Each gets its own bearer token. #[arg(long, default_value_t = 4)] light_actors: usize, - /// Number of /change ops per light actor. + /// Number of /mutate ops per light actor. #[arg(long, default_value_t = 50)] light_ops_per_actor: usize, - /// Number of /ingest batches the heavy actor sends. + /// Number of /load batches the heavy actor sends. #[arg(long, default_value_t = 200)] heavy_batches: usize, - /// NDJSON rows per heavy /ingest batch. + /// NDJSON rows per heavy /load batch. #[arg(long, default_value_t = 200)] heavy_rows_per_batch: usize, - /// Concurrent in-flight /ingest tasks the heavy actor maintains. With + /// Concurrent in-flight /load tasks the heavy actor maintains. With /// `inflight_cap` smaller than this, the heavy actor exercises its own /// admission cap (and the bench reports `heavy_too_many_requests > 0`), /// proving the gate fires without affecting light actors. Default 4 @@ -133,7 +133,7 @@ async fn send_heavy_batch(app: Router, batch_idx: usize, rows: usize) -> StatusC let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) .method(Method::POST) - .uri("/graphs/default/ingest") + .uri("/graphs/default/load") .header("authorization", format!("Bearer {HEAVY_TOKEN}")) .header("content-type", "application/json") .body(Body::from(body)) @@ -144,7 +144,7 @@ async fn send_heavy_batch(app: Router, batch_idx: usize, rows: usize) -> StatusC } } -/// Drive `batches` /ingest calls from the heavy actor with up to +/// Drive `batches` /load calls from the heavy actor with up to /// `concurrency` in flight at a time. With `concurrency > inflight_cap`, /// the heavy actor's own admission permits are exhausted at peak, and /// some batches return 429. Returns (ok, 429, other) counts. @@ -214,7 +214,7 @@ async fn drive_light_actor( let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) .method(Method::POST) - .uri("/graphs/default/change") + .uri("/graphs/default/mutate") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from(body)) @@ -374,7 +374,7 @@ async fn main() { light_p99_ms: pct(0.99), light_p999_ms: pct(0.999), light_max_ms: max_ms, - notes: "MR-686 actor-isolation bench. Heavy /ingest + light /change concurrent.", + notes: "MR-686 actor-isolation bench. Heavy /load + light /mutate concurrent.", }; let json = serde_json::to_string_pretty(&results).unwrap(); diff --git a/crates/omnigraph-server/examples/bench_concurrent_http.rs b/crates/omnigraph-server/examples/bench_concurrent_http.rs index 71e358465..07a1fb40e 100644 --- a/crates/omnigraph-server/examples/bench_concurrent_http.rs +++ b/crates/omnigraph-server/examples/bench_concurrent_http.rs @@ -1,6 +1,6 @@ //! Server-level concurrent HTTP benchmark for MR-686 (PR 0 baseline). //! -//! Drives concurrent `/change` requests against an in-process Omnigraph HTTP +//! Drives concurrent `/mutate` requests against an in-process Omnigraph HTTP //! server. Originally written to measure the global `Arc>` //! lock penalty as an MR-686 baseline; that lock has since been removed //! (engine write APIs are `&self`, the server holds a lockless @@ -136,7 +136,7 @@ async fn drive_actor( let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) .method(Method::POST) - .uri("/graphs/default/change") + .uri("/graphs/default/mutate") .header("content-type", "application/json") .body(Body::from(body)) .unwrap(); @@ -250,7 +250,7 @@ async fn main() { p99_ms: pct(0.99), p999_ms: pct(0.999), max_ms, - notes: "MR-686 PR 0 baseline. Drives /change via Tower oneshot.", + notes: "MR-686 PR 0 baseline. Drives /mutate via Tower oneshot.", }; let json = serde_json::to_string_pretty(&results).unwrap(); diff --git a/crates/omnigraph-server/src/data_tokens.rs b/crates/omnigraph-server/src/data_tokens.rs index 1bd640be3..b11a24bdf 100644 --- a/crates/omnigraph-server/src/data_tokens.rs +++ b/crates/omnigraph-server/src/data_tokens.rs @@ -9,26 +9,17 @@ use std::path::Path; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use color_eyre::eyre::{Result, bail, eyre}; -use omnigraph_policy::PolicyAction; use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; use p256::pkcs8::{DecodePublicKey, EncodePublicKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use url::Url; -use crate::graph_id::GraphId; use crate::identity::{AuthenticatedActor, ResolvedActor}; pub const MAX_TOKEN_BYTES: usize = 8_192; pub const MAX_TRUST_BYTES: usize = 65_536; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DataGrant { - pub graph_id: GraphId, - pub actions: Vec, -} - /// Only authenticated, non-development principal classes enter this profile. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -44,25 +35,6 @@ pub enum DataAssurance { VerifiedWorkload, } -/// Parsed claims are authenticated only after `DataTokenTrust::verify_at`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DataTokenClaims { - pub version: u8, - pub iss: String, - pub aud: String, - pub sub: String, - pub account_id: String, - pub cluster_id: String, - pub cluster_incarnation: String, - pub principal_kind: PrincipalKind, - pub assurance: DataAssurance, - pub iat: u64, - pub exp: u64, - pub jti: String, - pub grants: Vec, -} - /// Version 2 authenticates identity only. All graph permissions come from /// applied policy; strict parsing refuses permission or membership claims. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -218,7 +190,7 @@ impl DataTokenTrust { }) } - /// Verify and return the legacy identity projection. For authenticated + /// Verify and return the identity projection. For authenticated /// claims and request authorization, use [`Self::verify_authenticated_at`]. #[must_use] pub fn verify_at(&self, token: &str, now: u64) -> Option { @@ -227,7 +199,7 @@ impl DataTokenTrust { } /// Verify using an explicit admission time and retain the exact signed - /// profile and any legacy ceiling. Failure is deliberately opaque: callers + /// identity. Failure is deliberately opaque: callers /// must not log a credential or expose its unverified claims. #[must_use] pub fn verify_authenticated_at(&self, token: &str, now: u64) -> Option { @@ -255,22 +227,9 @@ impl DataTokenTrust { ) .ok()?; let claims_bytes = URL_SAFE_NO_PAD.decode(claims_part).ok()?; - // The discriminator only chooses a parser. Reparse the original bytes - // into the strict profile so duplicate and unknown fields still fail. - let discriminator: serde_json::Value = serde_json::from_slice(&claims_bytes).ok()?; - match discriminator.get("version")?.as_u64()? { - 1 => { - let claims: DataTokenClaims = serde_json::from_slice(&claims_bytes).ok()?; - self.valid_claims(&claims, now) - .then(|| AuthenticatedActor::signed(claims)) - } - 2 => { - let claims: IdentityTokenClaims = serde_json::from_slice(&claims_bytes).ok()?; - self.valid_identity_claims(&claims, now) - .then(|| AuthenticatedActor::signed_identity(claims)) - } - _ => None, - } + let claims: IdentityTokenClaims = serde_json::from_slice(&claims_bytes).ok()?; + self.valid_identity_claims(&claims, now) + .then(|| AuthenticatedActor::signed_identity(claims)) } fn valid_identity_claims(&self, claims: &IdentityTokenClaims, now: u64) -> bool { @@ -294,51 +253,6 @@ impl DataTokenTrust { | (PrincipalKind::Automation, DataAssurance::VerifiedWorkload) ) } - - fn valid_claims(&self, claims: &DataTokenClaims, now: u64) -> bool { - let Some(ttl) = claims.exp.checked_sub(claims.iat) else { - return false; - }; - if claims.version != 1 - || claims.iss != self.issuer - || claims.aud != self.audience - || claims.account_id != self.account_id - || claims.cluster_id != self.cluster_id - || claims.cluster_incarnation != self.cluster_incarnation - || !valid_id(&claims.sub) - || !valid_id(&claims.jti) - || !(60..=86_400).contains(&ttl) - || claims.exp <= now - || claims.iat > now.saturating_add(30) - || !(1..=64).contains(&claims.grants.len()) - || !matches!( - (claims.principal_kind, claims.assurance), - (PrincipalKind::Human, DataAssurance::VerifiedHuman) - | (PrincipalKind::Automation, DataAssurance::VerifiedWorkload) - ) - { - return false; - } - let mut graphs = HashSet::new(); - claims.grants.iter().all(|grant| { - let mut actions = HashSet::new(); - graphs.insert(grant.graph_id.as_str()) - && (1..=8).contains(&grant.actions.len()) - && grant.actions.iter().all(|action| { - matches!( - action, - PolicyAction::Read - | PolicyAction::Export - | PolicyAction::Change - | PolicyAction::BranchCreate - | PolicyAction::BranchDelete - | PolicyAction::BranchMerge - | PolicyAction::InvokeQuery - | PolicyAction::GraphList - ) && actions.insert(action) - }) - }) - } } #[cfg(test)] diff --git a/crates/omnigraph-server/src/data_tokens/tests.rs b/crates/omnigraph-server/src/data_tokens/tests.rs index 2ab95776b..17cd351dd 100644 --- a/crates/omnigraph-server/src/data_tokens/tests.rs +++ b/crates/omnigraph-server/src/data_tokens/tests.rs @@ -4,7 +4,15 @@ use p256::ecdsa::{SigningKey, signature::Signer}; use serde_json::{Value, json}; fn golden() -> Value { - serde_json::from_str(include_str!("../../tests/fixtures/data-token-v1.json")).unwrap() + let mut fixture: Value = + serde_json::from_str(include_str!("../../tests/fixtures/data-token-v1.json")).unwrap(); + fixture["claims"]["version"] = json!(2); + fixture["claims"].as_object_mut().unwrap().remove("grants"); + fixture["token"] = json!(sign_raw( + &fixture["header"].to_string(), + &fixture["claims"].to_string() + )); + fixture } fn document() -> Value { @@ -39,11 +47,11 @@ fn sign(claims: &Value) -> String { } #[test] -fn issuer_golden_signature_and_per_graph_ceiling() { +fn signed_identity_is_reusable_and_retired_profile_is_refused() { let fixture = golden(); let trust = trust(); let now = fixture["verification_time"].as_u64().unwrap(); - let mut actor = trust + let actor = trust .verify_authenticated_at(fixture["token"].as_str().unwrap(), now) .unwrap(); assert_eq!( @@ -56,21 +64,12 @@ fn issuer_golden_signature_and_per_graph_ceiling() { .unwrap(); assert_eq!(projected.actor_id, actor.actor().actor_id); assert_eq!(projected.source, actor.source); - assert_eq!( - serde_json::to_value(actor.data_claims().unwrap()).unwrap(), - fixture["claims"] - ); - assert!(actor.select_graph(&GraphId::try_from("graph-a").unwrap())); - assert!(actor.permits_action(PolicyAction::Change)); - assert!(actor.select_graph(&GraphId::try_from("reports").unwrap())); - assert!(actor.permits_action(PolicyAction::Read)); - assert!(!actor.permits_action(PolicyAction::Change)); - assert!(!actor.select_graph(&GraphId::try_from("other").unwrap())); - // Tokens are reusable capabilities, not an invented one-use token ledger. + let retired: Value = + serde_json::from_str(include_str!("../../tests/fixtures/data-token-v1.json")).unwrap(); assert!( trust - .verify_at(fixture["token"].as_str().unwrap(), now) - .is_some() + .verify_authenticated_at(retired["token"].as_str().unwrap(), now) + .is_none() ); } @@ -82,16 +81,13 @@ fn identity_profile_preserves_bindings_and_rejects_permissions() { let mut claims = fixture["claims"].clone(); claims["version"] = json!(2); claims.as_object_mut().unwrap().remove("grants"); - let mut actor = trust.verify_authenticated_at(&sign(&claims), now).unwrap(); - assert!(actor.data_claims().is_none()); + let actor = trust.verify_authenticated_at(&sign(&claims), now).unwrap(); assert_eq!( serde_json::to_value(actor.identity_claims().unwrap()).unwrap(), claims ); - assert!(actor.select_graph(&GraphId::try_from("other").unwrap())); - assert!(actor.permits_action(PolicyAction::SchemaApply)); for (field, value) in [ - ("grants", fixture["claims"]["grants"].clone()), + ("grants", json!([{"graph_id":"graph-a","actions":["read"]}])), ("grants", json!([])), ("roles", json!(["admin"])), ("actions", json!(["read"])), @@ -127,50 +123,16 @@ fn identity_profile_preserves_bindings_and_rejects_permissions() { } #[test] -fn signed_profile_rejects_invalid_authority_and_unsupported_claims() { +fn signed_profile_rejects_unsupported_claims_and_malformed_identity() { let fixture = golden(); let now = fixture["verification_time"].as_u64().unwrap(); let trust = trust(); for (field, value) in [ - ("version", json!(2)), - ("iss", json!("https://other.example")), ("aud", json!([fixture["claims"]["aud"]])), - ("account_id", json!("other")), - ("cluster_id", json!("other")), - ("cluster_incarnation", json!("other")), - ("sub", json!("email@example.com")), ("sub", json!("a".repeat(129))), - ("jti", json!("")), - ("principal_kind", json!("development")), - ("assurance", json!("verified_workload")), - ("iat", json!(now + 31)), ("iat", json!(-1)), - ("exp", json!(now)), ("nbf", json!(now + 1)), ("actor", json!("admin")), - ("grants", json!([])), - ( - "grants", - json!([{"graph_id":"graph-a","actions":["admin"]}]), - ), - ( - "grants", - json!([{"graph_id":"graph-a","actions":["schema_apply"]}]), - ), - ( - "grants", - json!([{"graph_id":"graph-a","actions":["config_manage"]}]), - ), - ( - "grants", - json!([{"graph_id":"graph-a","actions":["read","read"]}]), - ), - ("grants", json!([{"graph_id":"graph-a","actions":["*"]}])), - ("grants", json!([{"graph_id":"graph_a","actions":["read"]}])), - ( - "grants", - json!([{"graph_id":"policies","actions":["read"]}]), - ), ] { let mut claims = fixture["claims"].clone(); claims[field] = value; @@ -179,24 +141,6 @@ fn signed_profile_rejects_invalid_authority_and_unsupported_claims() { "accepted {field}: {claims}" ); } - let mut claims = fixture["claims"].clone(); - let duplicate = claims["grants"][0].clone(); - claims["grants"].as_array_mut().unwrap().push(duplicate); - assert!(trust.verify_at(&sign(&claims), now).is_none()); - claims["grants"] = Value::Array( - (0..65) - .map(|i| json!({"graph_id":format!("g{i}"),"actions":["read"]})) - .collect(), - ); - assert!(trust.verify_at(&sign(&claims), now).is_none()); - let duplicate = fixture["claims"] - .to_string() - .replacen('{', "{\"version\":1,", 1); - assert!( - trust - .verify_at(&sign_raw(&fixture["header"].to_string(), &duplicate), now) - .is_none() - ); } #[test] @@ -369,38 +313,6 @@ fn rotation_accepts_each_installed_key_without_online_discovery() { ); } -#[test] -fn action_ceiling_has_no_implicit_permissions() { - let fixture = golden(); - let trust = trust(); - let now = fixture["verification_time"].as_u64().unwrap(); - let actions = [ - PolicyAction::Read, - PolicyAction::Change, - PolicyAction::Export, - PolicyAction::BranchCreate, - PolicyAction::BranchDelete, - PolicyAction::BranchMerge, - PolicyAction::InvokeQuery, - PolicyAction::GraphList, - ]; - for allowed in actions { - let mut claims = fixture["claims"].clone(); - claims["grants"] = json!([{"graph_id":"graph-a","actions":[allowed]}]); - let mut actor = trust.verify_authenticated_at(&sign(&claims), now).unwrap(); - assert!(actor.select_graph(&GraphId::try_from("graph-a").unwrap())); - for checked in actions { - assert_eq!( - actor.permits_action(checked), - allowed == checked, - "{allowed} vs {checked}" - ); - } - assert!(!actor.permits_action(PolicyAction::SchemaApply)); - assert!(!actor.permits_action(PolicyAction::Admin)); - } -} - #[test] fn trust_file_reads_are_bounded_and_allow_projected_regular_files() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/omnigraph-server/src/deployment.rs b/crates/omnigraph-server/src/deployment.rs index 0457575fb..67af96e41 100644 --- a/crates/omnigraph-server/src/deployment.rs +++ b/crates/omnigraph-server/src/deployment.rs @@ -2,7 +2,7 @@ //! Input and graph outcomes belong to the cluster ledger; this controller owns //! only request lifetime, graph admission and activation of serving bindings. -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::sync::Arc; use axum::{ @@ -10,8 +10,8 @@ use axum::{ extract::{Path, State}, }; use omnigraph_cluster::{ - CapturedDeployment, DeploymentActivation, DeploymentCaller, DeploymentLookup, DeploymentStatus, - IdentityAuthorization, + CapturedDeployment, DeploymentCaller, DeploymentLookup, DeploymentStatus, + GraphDeploymentResult, IdentityAuthorization, }; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; @@ -58,11 +58,6 @@ pub(crate) struct DeploymentStatusResponse { } fn caller(state: &AppState, actor: &AuthenticatedActor) -> Result { - if actor.data_claims().is_some() { - return Err(ApiError::forbidden( - "graph-scoped credentials cannot deploy cluster configuration", - )); - } crate::handlers::authorize_request( Some(actor), state @@ -106,6 +101,47 @@ fn uncertain(message: impl Into) -> ApiError { ApiError::internal(message) } +/// Installed together with graph bindings; durable completion stays in the +/// cluster ledger, while activation belongs only to this process incarnation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ActiveDeployment { + canonical_root: String, + process_incarnation: String, + id: String, + input_digest: String, + result_revision: u64, + config_digest: String, +} + +impl ActiveDeployment { + pub(crate) fn new( + owner: &omnigraph_cluster::ClusterAdmission, + result: &omnigraph_cluster::DeploymentResult, + ) -> Option { + result.converged.then_some(())?; + Some(Self { + canonical_root: owner.canonical_root().to_owned(), + process_incarnation: owner.lock_id().to_owned(), + id: result.id.clone(), + input_digest: result.input_digest.clone(), + result_revision: result.result_revision, + config_digest: result.config_digest.clone()?, + }) + } +} + +/// Seed only the admission's exact boot input. Loading/blocked entries cannot +/// report it active; every installed view must first pass startup verification. +pub(crate) fn initialize_boot_activation(state: &AppState) { + let activation = state.cluster_admission.as_ref().and_then(|owner| { + owner + .serving_deployment() + .filter(|result| result.config_digest == state.witness.booted_serving_digest) + .and_then(|result| ActiveDeployment::new(owner, result)) + }); + state.routing.registry.initialize_deployment(activation); +} + fn active_result( state: &AppState, lookup: Option<&DeploymentLookup>, @@ -114,22 +150,24 @@ fn active_result( let Some(owner) = state.cluster_admission.as_ref() else { return false; }; - if state.operations.snapshot().closed { + let Some(DeploymentLookup::Complete { result }) = lookup else { + return false; + }; + if result.result_revision != current_revision { return false; } - matches!(lookup, Some(DeploymentLookup::Complete { result }) - if result.result_revision == current_revision - && result.activation.as_ref().is_some_and(|active| - active.process_incarnation == owner.lock_id() - && active.result_revision == current_revision) - && result.graphs.iter().all(|(id, outcome)| GraphId::try_from(id.as_str()).is_ok_and(|id| { - let current = state.routing.registry.get(&GraphKey::cluster(id)); - if matches!(outcome, omnigraph_cluster::GraphDeploymentResult::Deleted { .. }) { - matches!(current, RegistryLookup::Gone) - } else { - matches!(current, RegistryLookup::Ready(_)) - } - }))) + state + .operations + .while_open(|| { + let snapshot = state.routing.registry.snapshot_ref(); + let expected = ActiveDeployment::new(owner, result); + Ok::<_, ApiError>(expected.is_some() + && snapshot.deployment == expected + && snapshot.graphs.values().all(|entry| { + matches!(entry, crate::GraphEntry::Ready(view) if view.contract_is_current()) + })) + }) + .unwrap_or(false) } #[utoipa::path( @@ -258,25 +296,12 @@ async fn execute( } // Validate and authorize before pausing healthy graphs. No client-supplied // path is opened for writes; the server's root admission is authoritative. - let affected = - omnigraph_cluster::deployment_affected_graphs(&request.deployment, &owner, &caller) + let preview = + omnigraph_cluster::prepare_deployment_preview(&request.deployment, &owner, &caller) .await .map_err(refusal)?; - for id in request.deployment.options().recreate_graphs.keys() { - let key = GraphKey::cluster( - GraphId::try_from(id.as_str()) - .map_err(|error| ApiError::bad_request(error.to_string()))?, - ); - if matches!( - state.routing.registry.get(&key), - RegistryLookup::Ready(_) | RegistryLookup::Transitioning(_) - ) { - return Err(ApiError::conflict(format!( - "graph {id} retains a runtime owner; restart after correcting the missing root before recreation" - ))); - } - } - let retained = validate_serving_candidate(&state, &request.deployment, &owner, &caller).await?; + let affected = preview.affected_graphs; + validate_serving_candidate(&state, &owner, preview.serving)?; let mut keys = Vec::new(); for id in &affected { let key = GraphKey::cluster( @@ -335,7 +360,6 @@ async fn execute( .into_iter() .map(|(key, engine)| (key.graph_id.to_string(), engine)), ); - live.extend(retained); let mut effects_started = false; let applied = omnigraph_cluster::apply_captured_deployment( &request.deployment, @@ -400,11 +424,6 @@ async fn execute( prepare_management_policy(server_policy).map_err(|error| uncertain(error.message))?; let mut handles = Vec::new(); let mut unavailable = Vec::new(); - let mut removals: HashSet<_> = keys - .iter() - .filter(|key| !contracts.contains_key(key.graph_id.as_str())) - .cloned() - .collect(); for graph in graphs { let id = graph.graph_id.clone(); let contract = contracts @@ -414,7 +433,6 @@ async fn execute( let key = GraphKey::cluster( GraphId::try_from(id.as_str()).map_err(|error| uncertain(error.to_string()))?, ); - removals.remove(&key); let uri = omnigraph::storage::normalize_root_uri(&graph.uri) .map_err(|error| uncertain(format!("achieved graph URI is invalid: {error}")))?; let prepared = match crate::prepare_single_graph(graph, Some(contract.clone())) { @@ -479,59 +497,44 @@ async fn execute( } } } - let fully_ready = unavailable.is_empty(); + let activation = if unavailable.is_empty() { + ActiveDeployment::new(&owner, result) + } else { + None + }; + let deleted = result + .graphs + .iter() + .filter_map(|(id, result)| match result { + GraphDeploymentResult::Deleted { contract } => Some((id, contract.clone())), + _ => None, + }) + .map(|(id, contract)| { + Ok(( + GraphKey::cluster( + GraphId::try_from(id.as_str()).map_err(|error| uncertain(error.to_string()))?, + ), + contract, + )) + }) + .collect::, ApiError>>()?; transition - .activate_deployment(handles, removals, unavailable, server_policy) + .activate_deployment(handles, unavailable, deleted, server_policy, activation) .map_err(|error| uncertain(format!("deployment applied; activation refused: {error}")))?; - if result.config_digest.is_none() || !fully_ready { - // Serving follows the exact achieved schema/query/runtime projection. - // Partial convergence never acknowledges the desired revision active. - return Ok(DeploymentResponse { - deployment: applied, - active: false, - }); - } - let activated = omnigraph_cluster::record_deployment_activation( - owner.canonical_root(), - &request.deployment_id, - &owner, - DeploymentActivation { - process_incarnation: owner.lock_id().to_owned(), - result_revision: result.result_revision, - config_digest: result - .config_digest - .clone() - .ok_or_else(|| uncertain("applied deployment did not converge"))?, - }, - &contracts, - ) - .await - .map_err(|error| { - uncertain(format!( - "deployment activated; activation record is uncertain: {}", - error.message - )) - })?; - let deployment = DeploymentLookup::Complete { result: activated }; Ok(DeploymentResponse { - active: active_result(&state, Some(&deployment), result.result_revision), - deployment, + active: active_result(&state, Some(&applied), result.result_revision), + deployment: applied, }) } /// Resolve runtime-only settings using the same serving projection and graph /// preparation as startup, before accepting any deployment or graph effect. /// Unaffected bindings remain installed and are not reconfigured by this apply. -async fn validate_serving_candidate( +fn validate_serving_candidate( state: &AppState, - deployment: &CapturedDeployment, owner: &omnigraph_cluster::ClusterAdmission, - caller: &DeploymentCaller, -) -> Result>, ApiError> { - let snapshot = - omnigraph_cluster::preview_deployment_serving_snapshot(deployment, owner, caller) - .await - .map_err(refusal)?; + snapshot: omnigraph_cluster::ServingSnapshot, +) -> Result<(), ApiError> { let settings = crate::settings::settings_from_snapshot( std::path::Path::new(owner.canonical_root()), None, @@ -550,49 +553,15 @@ async fn validate_serving_candidate( .. } = settings.mode; prepare_management_policy(server_policy)?; - state - .routing - .registry - .validate_owner_capacity( - &graphs - .iter() - .map(|graph| graph.uri.clone()) - .collect::>(), - ) - .map_err(|error| ApiError::conflict(error.to_string()))?; - let mut retained = BTreeMap::new(); for graph in graphs { - let id = graph.graph_id.clone(); let prepared = crate::prepare_single_graph(graph, None).map_err(|error| { ApiError::conflict(format!("deployment serving preparation failed: {error}")) })?; - let retired = state - .routing - .registry - .retained_engine(&prepared.pending.uri) - .map_err(|error| ApiError::conflict(error.to_string()))?; - if let Some(engine) = &retired { - let confirmation = deployment.options().adopt_graphs.get(&id).ok_or_else(|| { - ApiError::conflict(format!("graph {id} retains a deleted runtime owner; exact adoption or restart is required")) - })?; - let snapshot = engine - .snapshot_of(omnigraph::db::ReadTarget::branch("main")) - .await - .map_err(|error| ApiError::from_omni(error.before_effect()))?; - if engine.schema_contract_digest() != confirmation.contract - || snapshot.graph_manifest_version() != confirmation.graph_manifest_version - { - return Err(ApiError::conflict(format!( - "graph {id} retained owner does not match the adoption confirmation" - ))); - } - retained.insert(id, Arc::clone(engine)); - } let engine = match state.routing.registry.get(&prepared.pending.key) { RegistryLookup::Ready(view) | RegistryLookup::Transitioning(view) => { Some(Arc::clone(&view.engine)) } - _ => retired, + _ => None, }; if let Some(engine) = engine { let policy = prepared @@ -612,7 +581,7 @@ async fn validate_serving_candidate( drop(engine); } } - Ok(retained) + Ok(()) } fn prepare_management_policy( diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 4489ab2e2..1a9c80462 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -41,8 +41,8 @@ mod dispatch; use dispatch::{ Door, ReadDispatch, classify, control_write_at_read_door, explain_at_write_door, read_at_write_door, refuse_empty_file, refuse_explain, refuse_process_settings, - refuse_settings_at_deprecated_route, refuse_statement_envelope, refuse_wrong_door, - run_branch_statement, session_with_prefix, show_at_write_door, + refuse_statement_envelope, refuse_wrong_door, run_branch_statement, session_with_prefix, + show_at_write_door, }; /// Liveness probe. @@ -180,17 +180,11 @@ pub(crate) async fn server_graphs_list( }, )?; - let may_list = |id: &str| { - actor - .as_ref() - .is_none_or(|actor| actor.0.permits_graph_listing(id)) - }; let stopping = state.draining.load(std::sync::atomic::Ordering::SeqCst) || state.operations.snapshot().closed; let mut graphs: Vec = snapshot .graphs .values() - .filter(|entry| may_list(entry.key().graph_id.as_str())) .map(|entry| { let failure = match &entry { GraphEntry::Loading(_) | GraphEntry::Ready(_) | GraphEntry::Transitioning(_) => { @@ -483,11 +477,6 @@ pub(crate) async fn resolve_graph_handle( })?; let graph_id = GraphId::try_from(graph_id_str.to_string()) .map_err(|err| ApiError::bad_request(err.to_string()))?; - if let Some(actor) = request.extensions_mut().get_mut::() { - if !actor.select_graph(&graph_id) { - return Err(ApiError::forbidden("credential does not permit this graph")); - } - } let key = GraphKey::cluster(graph_id.clone()); let handle = resolve_registered_graph( &state, @@ -505,8 +494,7 @@ pub(crate) async fn resolve_graph_handle( ingress::admit(&state, request, next).await } -/// HTTP and MCP share identity and unavailable-graph disclosure. Callers must -/// first bind the credential to this graph with `select_graph`. +/// HTTP and MCP share identity and unavailable-graph disclosure. pub(crate) fn resolve_registered_graph( state: &AppState, key: &GraphKey, @@ -611,7 +599,7 @@ pub(crate) enum Authz { /// and reserving `Err` for operational failures (401 missing bearer, 500 /// policy-evaluation error). Two sources of the policy engine: /// * Per-graph handler — passes `handle.policy.as_deref()` so the -/// graph's Cedar rules govern read/change/branch_*/schema_apply. +/// graph's Cedar rules govern read/change/branch_*. /// * Management handler — captures the current registry snapshot policy so /// server-level Cedar rules govern management access coherently with its /// graph inventory. @@ -628,11 +616,6 @@ pub(crate) fn authorize( request: PolicyRequest, ) -> std::result::Result { if let Some(actor) = actor { - if !actor.permits_action(request.action) { - return Ok(Authz::Denied( - "credential does not permit this action".to_string(), - )); - } if actor.source == AuthSource::SignedData && policy.is_none() { return Ok(Authz::Denied( "signed data credentials require an applied Cedar policy permit".to_string(), @@ -663,12 +646,11 @@ pub(crate) fn authorize( // `server_graphs_list` ("don't leak the registry until the // operator explicitly authorizes it") holds uniformly; the // cluster must be bootstrapped with an explicit cluster-scoped - // policy bundle. This deployment class keeps that binding fixed. + // policy bundle. if request.action.resource_kind() == PolicyResourceKind::Server { return Ok(Authz::Denied( "server-scoped actions require an applied cluster policy permit; \ - declare the cluster policy when bootstrapping. Existing policy bindings \ - cannot be changed by this deployment class. The management surface \ + declare the cluster policy when bootstrapping. The management surface \ is closed by default, including with --unauthenticated." .to_string(), )); @@ -677,8 +659,7 @@ pub(crate) fn authorize( return Ok(Authz::Denied( "server runs in default-deny mode (bearer tokens configured but no \ applied policy bundle). Only `read` actions are permitted. Other \ - actions require an applied graph policy; changing existing policy \ - bindings is outside the supported deployment class." + actions require an applied graph policy." .to_string(), )); } @@ -779,81 +760,6 @@ pub(crate) async fn server_snapshot( Ok(Json(output)) } -/// Header values that flag a response as coming from a deprecated route -/// (RFC 9745 / RFC 8288) and point at the canonical successor. -pub(crate) fn deprecation_headers(successor_link: &'static str) -> [(HeaderName, HeaderValue); 2] { - [ - ( - HeaderName::from_static("deprecation"), - HeaderValue::from_static("true"), - ), - ( - HeaderName::from_static("link"), - HeaderValue::from_static(successor_link), - ), - ] -} - -#[utoipa::path( - post, - path = "/read", - tag = "queries", - operation_id = "read", - request_body = ReadRequest, - responses( - (status = 200, description = "Legacy token-free query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = LegacyReadOutput), - (status = 400, description = "Bad request", body = ErrorOutput), - (status = 401, description = "Unauthorized", body = ErrorOutput), - (status = 403, description = "Forbidden", body = ErrorOutput), - (status = 409, description = "Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying", body = ErrorOutput), - ), - security(("bearer_token" = [])), -)] -#[deprecated( - note = "use POST /query instead; /read is kept indefinitely with a byte-stable envelope" -)] -/// **Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead. -/// -/// Execute a GQ read query. The route is kept indefinitely with a byte-stable -/// envelope; cell spelling follows the JSON writer. A `settings` field, and -/// a `set` or `reset` prefix in the source, are refused: the route runs under -/// the process defaults alone. 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"` -/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the -/// signal. -pub(crate) async fn server_read( - State(state): State, - Extension(handle): Extension, - actor: Option>, - Json(request): Json, -) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { - if request.settings.is_some() { - return Err(ApiError::bad_request( - crate::api::query_file_refusals::SETTINGS_AT_DEPRECATED_ROUTE, - )); - } - let session = state.session(&handle, None)?; - let output = run_query( - handle, - session, - actor.as_ref().map(|Extension(actor)| actor), - Door::Read, - &request.query_source, - request.query_name.as_deref(), - request.params.as_ref(), - request.branch, - request.snapshot, - ) - .await? - .into_read_output()?; - Ok(( - deprecation_headers("; rel=\"successor-version\""), - Json(output.into()), - )) -} - #[utoipa::path( post, path = "/query", @@ -862,22 +768,20 @@ pub(crate) async fn server_read( request_body = QueryRequest, responses( (status = 200, description = "Query results", body = ReadOutput), - (status = 400, description = "Bad request - also returned when the query body contains mutations (use POST /mutate, or its deprecated alias POST /change, for write queries), when a control write statement (`branch create`, `branch delete`, `branch merge`) arrives here instead of POST /mutate, when a request target accompanies a branch statement, and when a name or parameters accompany a branch statement", body = ErrorOutput), + (status = 400, description = "Bad request - also returned when the query body contains mutations (use POST /mutate, for write queries), when a control write statement (`branch create`, `branch delete`, `branch merge`) arrives here instead of POST /mutate, when a request target accompanies a branch statement, and when a name or parameters accompany a branch statement", body = ErrorOutput), (status = 401, description = "Unauthorized", body = ErrorOutput), (status = 403, description = "Forbidden", body = ErrorOutput), (status = 409, description = "Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying", body = ErrorOutput), ), security(("bearer_token" = [])), )] -/// Execute an inline read query (friendlier-named alternative to `POST /read`). +/// Execute an inline read query. /// /// Designed for ad-hoc exploration and AI-agent tool-use: short field /// names (`query`, `name`) match the CLI `-e` flag and the GQ `query` /// keyword. Mutations (`insert`/`update`/`delete`) are rejected with 400 -/// -- use `POST /mutate` (or its deprecated alias `POST /change`) for -/// write queries. It shares `POST /read` target semantics (branch xor -/// snapshot) and the same Cedar action (Read), while its canonical response -/// additionally carries the pinned graph-commit token. +/// -- use `POST /mutate` for writes. Select a branch or snapshot and obtain +/// the pinned graph-commit token with the response. Cedar authorizes Read. /// /// The GQ statement `branch list` is also served here, with no `branch`, /// `snapshot`, `name`, or `params`: it answers one result per branch (field @@ -1318,9 +1222,9 @@ fn reject_graph_commit_expected_head( } /// Shared backend for `/mutate` (canonical), `/mutate/if-graph-commit`, -/// `/change` (deprecated alias), and the stored-mutation arm of +/// and the stored-mutation arm of /// `/queries/{name}`. Returns the bare `ChangeOutput`; each route handler -/// wraps it (the alias also attaches Deprecation headers). +/// wraps it. /// /// Order: parse and classify first; a branch statement then passes /// [`refuse_wrong_door`] and [`refuse_statement_envelope`], and otherwise @@ -1342,7 +1246,6 @@ pub(crate) async fn run_mutate( expected_head: Option<&str>, ) -> std::result::Result { let file = classify(query)?; - refuse_settings_at_deprecated_route(door, &file.settings)?; refuse_process_settings(&file.settings)?; refuse_empty_file(&file)?; let queries = match file.body { @@ -1425,19 +1328,14 @@ pub(crate) async fn run_mutate( .await } -/// Shared backend for `/query` (canonical), `/read` (deprecated alias), and -/// the stored-read arm of `/queries/{name}`. +/// Shared backend for `/query` and the stored-read arm of `/queries/{name}`. /// /// Order: parse and classify first; `branch list` then passes /// [`refuse_wrong_door`] and [`refuse_statement_envelope`], and otherwise runs /// the handler body of `GET /branches` (a scope-free `read` check); `show` /// passes the same refusals and the same scope-free `read` check before it -/// reads the session's effective settings. At the deprecated `Read` door a -/// `set` or `reset` prefix is refused by -/// [`refuse_settings_at_deprecated_route`] before any of that, as the -/// `settings` field of `/read` is. A -/// declared query resolves and authorizes its read target, is refused at -/// every door but `Read` when it contains mutations, and runs. +/// reads the session's effective settings. A declared query resolves and +/// authorizes its read target, refuses mutations, and runs. /// /// Intentionally does **not** take [`AppState`] (unlike [`run_mutate`]): /// reads use the bounded server observer lane, so there is no `state.workload` consumer. @@ -1453,7 +1351,6 @@ pub(crate) async fn run_query( snapshot: Option, ) -> std::result::Result { let file = classify(query)?; - refuse_settings_at_deprecated_route(door, &file.settings)?; refuse_process_settings(&file.settings)?; refuse_empty_file(&file)?; let queries = match file.body { @@ -1473,11 +1370,6 @@ pub(crate) async fn run_query( }; } FileBody::Show(id) => { - if matches!(door, Door::Read | Door::Change) { - return Err(ApiError::bad_request( - crate::api::branch_statement_refusals::DEPRECATED_ROUTE, - )); - } refuse_statement_envelope( branch.is_some() || snapshot.is_some(), name.is_some() || params_json.is_some(), @@ -1496,7 +1388,7 @@ pub(crate) async fn run_query( let target = resolve_authorized_read_target(&handle, actor, branch, snapshot).await?; let query_decl = select_named_query_decl(queries, name) .map_err(|err| ApiError::bad_request(err.to_string()))?; - if door != Door::Read && !query_decl.mutations.is_empty() { + if !query_decl.mutations.is_empty() { return Err(ApiError::bad_request(format!( "query '{}' contains mutations (insert/update/delete); use POST /mutate for write queries", query_decl.name @@ -1578,74 +1470,6 @@ fn engine_error_with_cause(error: OmniError) -> (ApiError, Option; rel="successor-version"` headers per RFC 9745 / RFC 8288 -/// so SDKs and proxies can surface the signal. -pub(crate) async fn server_change( - State(state): State, - Extension(handle): Extension, - Extension(ingress): Extension, - actor: Option>, - headers: axum::http::HeaderMap, - request: std::result::Result, JsonRejection>, -) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { - let Json(request) = request - .map_err(|rejection| ApiError::json_rejection("invalid mutation request", rejection))?; - reject_graph_commit_expected_head(&headers, "/mutate/if-graph-commit")?; - if request.settings.is_some() { - return Err(ApiError::bad_request( - crate::api::query_file_refusals::SETTINGS_AT_DEPRECATED_ROUTE, - )); - } - let session = state.session(&handle, None)?; - let output = run_mutate( - state, - handle, - ingress, - session, - actor.as_ref().map(|Extension(actor)| actor), - Door::Change, - &request.query, - request.name.as_deref(), - request.params.as_ref(), - request.branch, - None, - ) - .await?; - Ok(( - deprecation_headers("; rel=\"successor-version\""), - Json(output), - )) -} - #[utoipa::path( post, path = "/mutate", @@ -1677,8 +1501,7 @@ pub(crate) async fn server_change( /// Conditional callers use `POST /mutate/if-graph-commit`, which requires and /// validates `Omnigraph-If-Graph-Commit`. This endpoint rejects that header. /// -/// Pairs with `POST /query` (read-only). The legacy `POST /change` route -/// has identical semantics and is kept as a deprecated alias. +/// Pairs with `POST /query` (read-only). /// /// The GQ statements `branch create`, `branch delete`, and `branch merge` /// (grammar: `BranchStmt` in `omnigraph-compiler`) are also served @@ -2101,107 +1924,6 @@ pub(crate) async fn server_schema_get( })) } -#[utoipa::path( - post, - path = "/schema/apply", - tag = "mutations", - operation_id = "applySchema", - request_body = SchemaApplyRequest, - responses( - (status = 200, description = "Schema apply results", body = SchemaApplyOutput), - (status = 400, description = "Bad request", body = ErrorOutput), - (status = 401, description = "Unauthorized", body = ErrorOutput), - (status = 403, description = "Forbidden", body = ErrorOutput), - (status = 409, description = "Use `omnigraph cluster apply --server --config ` for live schema deployment", 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 -/// submit schema changes with `omnigraph cluster apply --server --config `. -/// -/// Diffs `schema_source` against the current schema and applies the resulting -/// migration steps (add/drop type, add/drop property, etc.). **Destructive**: -/// a drop removes data from the branch head; older commits keep reading it -/// until `omnigraph cleanup` stops retaining them. Returns the list of steps -/// applied; if `applied` is false the diff was unsupported and no changes -/// were made. -pub(crate) async fn server_schema_apply( - State(state): State, - Extension(handle): Extension, - Extension(ingress): Extension, - actor: Option>, - Json(request): Json, -) -> std::result::Result, ApiError> { - let actor_arc = actor - .as_ref() - .map(|Extension(actor)| Arc::clone(&actor.actor_id)) - .unwrap_or_else(|| Arc::::from("anonymous")); - let actor_id = actor - .as_ref() - .map(|Extension(actor)| actor.actor_id.as_ref()); - authorize_request( - actor.as_ref().map(|Extension(actor)| actor), - handle.policy.as_deref(), - PolicyRequest { - action: PolicyAction::SchemaApply, - branch: None, - 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 and run \ - `omnigraph cluster apply --server --config `.", - )); - } - let est_bytes = request.schema_source.len() as u64; - let admission = state - .workload - .try_admit(&actor_arc, est_bytes) - .map_err(ApiError::from_workload_reject)?; - let actor_id = actor_id.map(str::to_owned); - owned_write(&state, admission, ingress, handle.clone(), async move { - let result = { - let db = &handle.engine; - let registry = handle.queries.as_deref(); - let label = handle.key.graph_id.as_str().to_string(); - // Engine-layer policy enforcement (MR-722): pass the resolved - // actor through so apply_schema_as can call enforce() with the - // authoritative identity. With a policy installed in AppState, - // engine-side enforcement re-checks the same decision the - // HTTP-layer authorize_request just made above. PR #3 collapses - // the redundancy. - db.apply_schema_as_with_catalog_check( - &request.schema_source, - actor_id.as_deref(), - |catalog| { - if let Some(registry) = registry { - validate_registry_against_catalog(registry, catalog, &label)?; - } - Ok(()) - }, - ) - .await - .map_err(ApiError::from_omni)? - }; - // Physical indexes are derived state. Schema apply records intent only; - // explicit `ensure_indices` / `optimize` maintenance owns convergence on - // every surface, including a long-lived server. Keeping the handler free - // of detached physical writes also makes a successful response describe - // the complete effect envelope of this request. - Ok(Json(schema_apply_output(handle.uri.as_str(), result))) - }) - .await -} - /// Authorize one load target without touching request data. async fn authorize_load_scope( handle: &GraphHandle, @@ -2249,10 +1971,10 @@ async fn authorize_load_scope( ) } -/// Shared body for JSON `POST /load` and `POST /ingest` (deprecated): +/// JSON `POST /load`: /// branch-exists / fork-if-`from` check, Cedar authorization, admission, the /// bulk `load_as`, and the `IngestOutput` mapping. -async fn run_ingest( +async fn run_json_load( state: AppState, handle: GraphRequest, ingress: IngressLease, @@ -2326,9 +2048,6 @@ async fn run_ingest( /// it; without `from`, `branch` must already exist — a missing branch is a /// 404, never an implicit fork. **Destructive** when `mode` is `overwrite` /// or when the load produces conflicting writes. -/// -/// The legacy `POST /ingest` route has identical semantics and is kept as a -/// deprecated alias. pub(crate) async fn server_load( State(state): State, Extension(handle): Extension, @@ -2337,7 +2056,7 @@ pub(crate) async fn server_load( Json(request): Json, ) -> std::result::Result, ApiError> { Ok(Json( - run_ingest( + run_json_load( state, handle, ingress, @@ -2503,54 +2222,6 @@ pub(crate) async fn server_load_ndjson( .await } -#[utoipa::path( - post, - path = "/ingest", - tag = "mutations", - operation_id = "ingest", - request_body = IngestRequest, - responses( - (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), - (status = 409, description = "Prepared load authority changed before effects", body = ErrorOutput), - (status = 413, description = "Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling", body = ErrorOutput), - (status = 424, description = "An allowed external Blob source could not be probed or read", body = ErrorOutput), - (status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput), - (status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput), - ), - security(("bearer_token" = [])), -)] -#[deprecated(note = "use POST /load instead; /ingest retains its parser and branch defaults")] -/// **Deprecated** — use [`POST /load`](#tag/mutations/operation/load) instead. -/// -/// Bulk-load NDJSON data into a branch. The deprecated route retains its -/// parser and branch defaults, but its response uses the current canonical -/// vocabulary. New integrations should target `POST /load`. Responses -/// 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, - Extension(handle): Extension, - Extension(ingress): Extension, - actor: Option>, - Json(request): Json, -) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { - let output = run_ingest( - state, - handle, - ingress, - actor.as_ref().map(|Extension(actor)| actor), - request, - ) - .await?; - Ok(( - deprecation_headers("; rel=\"successor-version\""), - Json(output), - )) -} - #[utoipa::path( get, path = "/branches", diff --git a/crates/omnigraph-server/src/handlers/dispatch.rs b/crates/omnigraph-server/src/handlers/dispatch.rs index 128f0cb5c..fae4333ef 100644 --- a/crates/omnigraph-server/src/handlers/dispatch.rs +++ b/crates/omnigraph-server/src/handlers/dispatch.rs @@ -12,15 +12,11 @@ use omnigraph_compiler::settings::{SettingId, SettingRow}; /// The HTTP entry a GQ source arrived through. A statement is served only at /// its own door: `Query` serves `branch list` and `explain`, `Mutate` serves -/// `branch create`, `branch delete`, and `branch merge`, and the deprecated -/// `Read` and `Change` serve none. `Read` is also the one door that runs a -/// mutation body. +/// `branch create`, `branch delete`, and `branch merge`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Door { Query, - Read, Mutate, - Change, } /// What [`run_query`] answered: a declared query's rows, the branch names @@ -69,21 +65,6 @@ pub(super) fn refuse_process_settings( Ok(()) } -/// A `set` or `reset` prefix at a deprecated door: `/read` and `/change` -/// serve their legacy bodies under the process defaults alone, so the prefix -/// is refused exactly as their `settings` field is. -pub(super) fn refuse_settings_at_deprecated_route( - door: Door, - settings: &[SettingStmt], -) -> std::result::Result<(), ApiError> { - if matches!(door, Door::Read | Door::Change) && !settings.is_empty() { - return Err(ApiError::bad_request( - crate::api::query_file_refusals::SETTINGS_AT_DEPRECATED_ROUTE, - )); - } - Ok(()) -} - /// A source no door can pick a statement from: settings-only, or holding /// nothing at all. pub(super) fn refuse_empty_file(file: &QueryFile) -> std::result::Result<(), ApiError> { @@ -139,15 +120,12 @@ pub(super) fn show_at_write_door(id: Option) -> ApiError { )) } -/// A branch statement is refused at every door that does not serve it: the two -/// deprecated routes serve none, `Query` serves only the read, `Mutate` only -/// the control writes. +/// `Query` serves only branch reads; `Mutate` serves only control writes. pub(super) fn refuse_wrong_door( door: Door, stmt: &BranchStmt, ) -> std::result::Result<(), ApiError> { match (door, stmt) { - (Door::Read | Door::Change, _) => Err(ApiError::bad_request(refusals::DEPRECATED_ROUTE)), (Door::Query, BranchStmt::Write(write)) => Err(control_write_at_read_door(write)), (Door::Mutate, BranchStmt::List) => Err(read_at_write_door()), (Door::Query, BranchStmt::List) | (Door::Mutate, BranchStmt::Write(_)) => Ok(()), @@ -155,12 +133,10 @@ pub(super) fn refuse_wrong_door( } /// An `explain` statement is served at `Query` alone: a read refused at -/// `Mutate` as `branch list` is, and at the deprecated routes as every -/// statement is. +/// `Mutate` as `branch list` is. pub(super) fn refuse_explain(door: Door) -> std::result::Result<(), ApiError> { match door { Door::Query => Ok(()), - Door::Read | Door::Change => Err(ApiError::bad_request(refusals::EXPLAIN_DEPRECATED_ROUTE)), Door::Mutate => Err(explain_at_write_door()), } } diff --git a/crates/omnigraph-server/src/identity.rs b/crates/omnigraph-server/src/identity.rs index f07409f8c..436a0a8e0 100644 --- a/crates/omnigraph-server/src/identity.rs +++ b/crates/omnigraph-server/src/identity.rs @@ -1,7 +1,7 @@ //! Server-resolved identities for static credentials and RFC 0053 offline //! signed data credentials. Both use the cluster registry (`tenant_id: None`). -//! Signed identities retain exact graph/action grants; the selected graph -//! narrows those grants before the existing Cedar policy gate. +//! Signed identities carry no permission grants; current Cedar policy +//! authorizes each operation. use std::fmt; use std::sync::Arc; @@ -137,15 +137,13 @@ impl fmt::Display for GraphKey { } /// Authorization shape. Static credentials use Cedar without an additional -/// grant ceiling; version 1 signed credentials retain exact grants, while -/// version 2 credentials authenticate identity for applied policy evaluation. +/// grant ceiling; signed credentials authenticate identity for applied policy +/// evaluation. #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] #[non_exhaustive] pub enum Scope { /// Static credential authority, subject to Cedar policy. Full, - /// Exact graph/action ceilings are retained in the authenticated claims. - DataToken, /// Cluster-bound identity; graph permissions come only from applied policy. IdentityToken, } @@ -200,12 +198,10 @@ impl ResolvedActor { pub struct AuthenticatedActor { actor: ResolvedActor, data_token: Option, - selected_graph: Option, } #[derive(Debug, Clone)] enum VerifiedDataToken { - Restricted(Arc), Identity(Arc), Oidc, } @@ -228,7 +224,6 @@ impl AuthenticatedActor { source: AuthSource::SignedData, }, data_token: Some(VerifiedDataToken::Oidc), - selected_graph: None, } } @@ -244,20 +239,6 @@ impl AuthenticatedActor { Self { actor: ResolvedActor::cluster_static(actor_id), data_token: None, - selected_graph: None, - } - } - - pub(crate) fn signed(claims: crate::data_tokens::DataTokenClaims) -> Self { - Self { - actor: ResolvedActor { - actor_id: Arc::from(format!("principal:{}", claims.sub)), - tenant_id: None, - scopes: vec![Scope::DataToken], - source: AuthSource::SignedData, - }, - data_token: Some(VerifiedDataToken::Restricted(Arc::new(claims))), - selected_graph: None, } } @@ -270,7 +251,6 @@ impl AuthenticatedActor { source: AuthSource::SignedData, }, data_token: Some(VerifiedDataToken::Identity(Arc::new(claims))), - selected_graph: None, } } @@ -279,14 +259,6 @@ impl AuthenticatedActor { &self.actor } - /// Authenticated signed claims, excluding the original bearer plaintext. - pub fn data_claims(&self) -> Option<&crate::data_tokens::DataTokenClaims> { - match &self.data_token { - Some(VerifiedDataToken::Restricted(claims)) => Some(claims), - _ => None, - } - } - /// Authenticated version 2 identity metadata, never graph permission grants. pub fn identity_claims(&self) -> Option<&crate::data_tokens::IdentityTokenClaims> { match &self.data_token { @@ -294,49 +266,6 @@ impl AuthenticatedActor { _ => None, } } - - pub(crate) fn select_graph(&mut self, graph_id: &GraphId) -> bool { - if self.source == AuthSource::Static || self.is_identity() { - return true; - } - if self.data_claims().is_some_and(|claims| { - claims - .grants - .iter() - .any(|grant| grant.graph_id == *graph_id) - }) { - self.selected_graph = Some(graph_id.clone()); - return true; - } - false - } - - pub(crate) fn permits_action(&self, action: omnigraph_policy::PolicyAction) -> bool { - if self.source == AuthSource::Static || self.is_identity() { - return true; - } - self.data_claims().is_some_and(|claims| { - claims.grants.iter().any(|grant| { - (self.selected_graph.as_ref() == Some(&grant.graph_id) - || (self.selected_graph.is_none() - && action == omnigraph_policy::PolicyAction::GraphList)) - && grant.actions.contains(&action) - }) - }) - } - - pub(crate) fn permits_graph_listing(&self, graph_id: &str) -> bool { - self.source == AuthSource::Static - || self.is_identity() - || self.data_claims().is_some_and(|claims| { - claims.grants.iter().any(|grant| { - grant.graph_id.as_str() == graph_id - && grant - .actions - .contains(&omnigraph_policy::PolicyAction::GraphList) - }) - }) - } } #[cfg(test)] diff --git a/crates/omnigraph-server/src/ingress.rs b/crates/omnigraph-server/src/ingress.rs index 07f6ecacf..bf144d8b1 100644 --- a/crates/omnigraph-server/src/ingress.rs +++ b/crates/omnigraph-server/src/ingress.rs @@ -90,7 +90,7 @@ async fn classify(parts: &mut Parts, route: &str) -> Result BearerTokenHash { handlers::server_snapshot, handlers::server_blob_get, handlers::server_blob_head, - // deprecated; the #[deprecated] attribute on the handler - // surfaces as `deprecated: true` on the OpenAPI operation. - #[allow(deprecated)] handlers::server_read, handlers::server_query, handlers::server_export, - #[allow(deprecated)] handlers::server_change, handlers::server_mutate, handlers::server_mutate_if_graph_commit, handlers::server_list_queries, handlers::server_invoke_query, handlers::server_invoke_query_if_graph_commit, - handlers::server_schema_apply, handlers::server_schema_get, handlers::server_load, handlers::server_load_ndjson, - // deprecated; the #[deprecated] attribute on the handler surfaces as - // `deprecated: true` on the OpenAPI operation. - #[allow(deprecated)] handlers::server_ingest, handlers::server_branch_list, handlers::server_branch_create, handlers::server_branch_delete, @@ -2397,26 +2388,7 @@ pub fn build_app(state: AppState) -> Router { // dedicated handler makes the zero-payload-read contract structural. .route("/blob", get(server_blob_get).head(server_blob_head)) .route("/export", post(server_export)) - // /read and /change retain their deprecated route/request semantics; - // their handlers carry #[deprecated] so the OpenAPI operation is - // flagged and their responses include RFC 9745 Deprecation + - // RFC 8288 Link headers. Suppress the call-site warning for the - // route registration itself. - .route( - "/read", - post({ - #[allow(deprecated)] - server_read - }), - ) .route("/query", post(server_query)) - .route( - "/change", - post({ - #[allow(deprecated)] - server_change - }), - ) .route("/mutate", post(server_mutate)) .route( "/mutate/if-graph-commit", @@ -2429,23 +2401,11 @@ pub fn build_app(state: AppState) -> Router { post(server_invoke_query_if_graph_commit), ) .route("/schema", get(server_schema_get)) - .route("/schema/apply", post(server_schema_apply)) .route( "/load", post(server_load).layer(DefaultBodyLimit::max(INGEST_REQUEST_BODY_LIMIT_BYTES)), ) .route("/load/ndjson", post(server_load_ndjson)) - // /ingest is the deprecated alias of /load; its handler carries - // #[deprecated] (OpenAPI operation flagged) and emits RFC 9745 - // Deprecation + RFC 8288 Link headers. Suppress the call-site warning. - .route( - "/ingest", - post({ - #[allow(deprecated)] - server_ingest - }) - .layer(DefaultBodyLimit::max(INGEST_REQUEST_BODY_LIMIT_BYTES)), - ) .route( "/branches", get(server_branch_list).post(server_branch_create), @@ -2654,6 +2614,7 @@ async fn serve_config( shutdown_grace, ) .with_process_defaults(process_defaults); + deployment::initialize_boot_activation(&state); let retained_admission = state.cluster_admission.clone(); let startup_owner = operations .own_startup() diff --git a/crates/omnigraph-server/src/mcp.rs b/crates/omnigraph-server/src/mcp.rs index cedadbdcf..b6d303d86 100644 --- a/crates/omnigraph-server/src/mcp.rs +++ b/crates/omnigraph-server/src/mcp.rs @@ -319,14 +319,11 @@ impl GraphTools { fn graph( &self, name: &str, - mut actor: AuthenticatedActor, + actor: AuthenticatedActor, graph: &McpGraphRequest, ) -> Result<(GraphRequest, AuthenticatedActor), ApiError> { let id = GraphId::try_from(name.to_string()) .map_err(|error| ApiError::bad_request(error.to_string()))?; - if !actor.select_graph(&id) { - return Err(ApiError::forbidden("credential does not permit this graph")); - } let handle = handlers::resolve_registered_graph(&self.state, &GraphKey::cluster(id), Some(&actor))?; if graph.set(handle.clone()).is_err() { diff --git a/crates/omnigraph-server/src/oidc_identity_tests.rs b/crates/omnigraph-server/src/oidc_identity_tests.rs index 68db6d020..948d43e0b 100644 --- a/crates/omnigraph-server/src/oidc_identity_tests.rs +++ b/crates/omnigraph-server/src/oidc_identity_tests.rs @@ -55,7 +55,6 @@ fn identity_is_local_stable_admission_without_token_permissions() { let actor = trust.verify_at(&sign(&c), NOW).unwrap(); assert_eq!(actor.actor_id_str(), "principal:stable_1"); assert!(actor.is_identity()); - assert!(actor.data_claims().is_none()); assert!( actor.identity_claims().is_none(), "OIDC is a distinct authenticated profile" diff --git a/crates/omnigraph-server/src/registry.rs b/crates/omnigraph-server/src/registry.rs index 9f1bc8b53..102182802 100644 --- a/crates/omnigraph-server/src/registry.rs +++ b/crates/omnigraph-server/src/registry.rs @@ -11,6 +11,7 @@ use omnigraph::storage::normalize_root_uri; use tokio::time::Instant; use crate::ApiError; +use crate::deployment::ActiveDeployment; use crate::identity::GraphKey; use crate::operations::OperationRuntime; use crate::policy::PolicyEngine; @@ -113,6 +114,7 @@ pub struct RegistrySnapshot { pub any_per_graph_policy: bool, /// Management policy published in the same snapshot as graph bindings. pub server_policy: Option>, + pub(crate) deployment: Option, } impl RegistrySnapshot { @@ -122,6 +124,7 @@ impl RegistrySnapshot { graphs, any_per_graph_policy, server_policy: None, + deployment: None, } } } @@ -130,6 +133,7 @@ impl RegistrySnapshot { fn with_graphs(&self, graphs: HashMap) -> Self { let mut snapshot = Self::new(graphs); snapshot.server_policy = self.server_policy.clone(); + snapshot.deployment = self.deployment.clone(); snapshot } } @@ -174,9 +178,6 @@ pub enum InsertError { struct RegistryState { snapshot: Arc, - // Logical deletion removes serving admission, not native owner lifetime. - // Retain drained owners so a later adoption cannot open a second writer. - retired: HashMap>, // One active scheduling record per process registry. This record adds no // engine instance or request capacity: the graph entry retains the predecessor // after closure, including when the record expires or is abandoned. @@ -206,7 +207,6 @@ impl GraphRegistry { Self { state: Mutex::new(RegistryState { snapshot: Arc::new(RegistrySnapshot::default()), - retired: HashMap::new(), candidate: None, }), } @@ -233,7 +233,6 @@ impl GraphRegistry { Ok(Self { state: Mutex::new(RegistryState { snapshot: Arc::new(RegistrySnapshot::new(graphs)), - retired: HashMap::new(), candidate: None, }), }) @@ -250,37 +249,11 @@ impl GraphRegistry { Arc::clone(&locked(&self.state).snapshot) } - pub(crate) fn retained_engine( - &self, - uri: &str, - ) -> Result>, ServingTransitionError> { - Ok(locked(&self.state) - .retired - .get(&root_identity(uri)?) - .cloned()) - } - - /// Include retired owners in the bounded process inventory. No owner is - /// evicted without native settlement evidence; restart releases this cache. - pub(crate) fn validate_owner_capacity( - &self, - candidate_uris: &[String], - ) -> Result<(), ServingTransitionError> { - const MAX_RUNTIME_OWNERS: usize = 2048; - let state = locked(&self.state); - let mut roots: HashSet<_> = state.retired.keys().cloned().collect(); - for entry in state.snapshot.graphs.values() { - roots.insert(root_identity(entry.uri())?); - } - for uri in candidate_uris { - roots.insert(root_identity(uri)?); - } - if roots.len() > MAX_RUNTIME_OWNERS { - return Err(ServingTransitionError::InvalidGraph(format!( - "runtime owner limit of {MAX_RUNTIME_OWNERS} reached; restart to release retained deleted owners" - ))); - } - Ok(()) + pub(crate) fn initialize_deployment(&self, deployment: Option) { + let mut state = locked(&self.state); + let mut snapshot = state.snapshot.with_graphs(state.snapshot.graphs.clone()); + snapshot.deployment = deployment; + state.snapshot = Arc::new(snapshot); } pub fn get(&self, key: &GraphKey) -> RegistryLookup { @@ -628,73 +601,18 @@ impl GraphRegistry { .iter() .map(|view| Ok(Arc::new(view.successor(view.epoch().successor()?)))) .collect::, ServingTransitionError>>()?; - self.activate(record, views) - } - - /// Compile the complete replacement set before entering the short final - /// publication boundary. The exact engine contract is checked both before - /// compilation and again by activate, so no query registry crosses schemas. - #[cfg(test)] - pub(crate) fn validate_activation( - &self, - record: &Arc, - mut bindings: HashMap, - additions: Vec<(Arc, SchemaContractDigest)>, - ) -> Result>, ServingTransitionError> { - self.check_drained(record)?; - if bindings.len() != record.predecessors.len() { - return Err(ServingTransitionError::InvalidBindings); - } - let mut views = Vec::with_capacity(bindings.len() + additions.len()); - for predecessor in &record.predecessors { - let (contract, queries) = bindings - .remove(&predecessor.key) - .ok_or(ServingTransitionError::InvalidBindings)?; - if contract != predecessor.engine.schema_contract_digest() { - return Err(ServingTransitionError::SchemaChanged); - } - crate::validate_registry_against_catalog( - &queries, - &predecessor.engine.catalog(), - predecessor.key.graph_id.as_str(), - ) - .map_err(|error| ServingTransitionError::InvalidQueries(error.to_string()))?; - let handle = Arc::new(GraphHandle { - key: predecessor.key.clone(), - uri: predecessor.uri.clone(), - engine: Arc::clone(&predecessor.engine), - policy: predecessor.policy.clone(), - queries: (!queries.is_empty()).then(|| Arc::new(queries)), - }); - let view = Arc::new(ServingView::new(handle, predecessor.epoch().successor()?)); - if view.schema_contract() != &contract { - return Err(ServingTransitionError::SchemaChanged); - } - views.push(view); - } - for (handle, contract) in additions { - let (_, handle) = canonicalize_handle_uri(handle) - .map_err(|error| ServingTransitionError::InvalidGraph(error.to_string()))?; - if contract != handle.engine.schema_contract_digest() { - return Err(ServingTransitionError::SchemaChanged); - } - if let Some(queries) = &handle.queries { - crate::validate_registry_against_catalog( - queries, - &handle.engine.catalog(), - handle.key.graph_id.as_str(), - ) - .map_err(|error| ServingTransitionError::InvalidQueries(error.to_string()))?; - } - let view = Arc::new(ServingView::new(handle, ServingEpoch::INITIAL)); - if view.schema_contract() != &contract { - return Err(ServingTransitionError::SchemaChanged); - } - views.push(view); - } - Ok(views) + let snapshot = self.snapshot_ref(); + self.activate_deployment( + record, + views, + Vec::new(), + Vec::new(), + snapshot.server_policy.clone(), + snapshot.deployment.clone(), + ) } + /// Validate every replacement before the short final publication boundary. pub(crate) fn validate_deployment_activation( &self, record: &Arc, @@ -721,51 +639,49 @@ impl GraphRegistry { Some(old) => old.epoch().successor()?, None => ServingEpoch::INITIAL, }; - Ok(Arc::new(ServingView::new(handle, epoch))) + let view = Arc::new(ServingView::new(handle, epoch)); + if view.schema_contract() != &contract { + return Err(ServingTransitionError::SchemaChanged); + } + Ok(view) }) .collect() } - pub(crate) fn activate( - &self, - record: &Arc, - views: Vec>, - ) -> Result, ServingTransitionError> { - self.activate_deployment( - record, - views, - HashSet::new(), - Vec::new(), - self.snapshot_ref().server_policy.clone(), - ) - } - pub(crate) fn activate_deployment( &self, record: &Arc, views: Vec>, - removals: HashSet, unavailable: Vec>, + deleted: Vec<(GraphKey, SchemaContractDigest)>, server_policy: Option>, + deployment: Option, ) -> Result, ServingTransitionError> { let mut state = locked(&self.state); validate_closed(&state, record)?; let mut graphs = state.snapshot.graphs.clone(); - let mut retired = state.retired.clone(); - let mut epochs = HashMap::with_capacity(views.len()); - for key in &removals { - if !record.predecessors.iter().any(|old| &old.key == key) - && !record.unavailable.iter().any(|old| &old.key == key) - { + let mut deleted_keys = HashSet::new(); + for (key, contract) in deleted { + if !deleted_keys.insert(key.clone()) { return Err(ServingTransitionError::InvalidBindings); } - if let Some(GraphEntry::Transitioning(view)) = graphs.remove(key) { - retired.insert(root_identity(&view.uri)?, Arc::clone(&view.engine)); + if let Some(predecessor) = record.predecessors.iter().find(|old| old.key == key) { + if predecessor.schema_contract() != &contract + || predecessor.engine.schema_contract_digest() != contract + { + return Err(ServingTransitionError::SchemaChanged); + } + } else if !record.unavailable.iter().any(|old| old.key == key) { + return Err(ServingTransitionError::InvalidBindings); } + // Only a completed deletion receipt can retire an exact closed + // predecessor. No retired-engine or re-adoption cache is created. + graphs.remove(&key); } + let mut epochs = HashMap::with_capacity(views.len()); for view in &views { let root = root_identity(&view.uri)?; - if removals.contains(&view.key) + if deleted_keys.contains(&view.key) || epochs.insert(view.key.clone(), view.epoch()).is_some() { return Err(ServingTransitionError::InvalidBindings); @@ -787,11 +703,6 @@ impl GraphRegistry { InsertError::DuplicateKey(view.key.clone()).to_string(), )); } - if let Some(retained) = retired.remove(&root) { - if !retained.shares_runtime_owner(&view.engine) { - return Err(ServingTransitionError::InvalidBindings); - } - } for entry in graphs.values() { if entry.key() != &view.key && root_identity(entry.uri())? == root { return Err(ServingTransitionError::InvalidGraph( @@ -804,9 +715,9 @@ impl GraphRegistry { let mut unavailable_keys = HashSet::new(); for graph in unavailable { let root = root_identity(&graph.uri)?; - if removals.contains(&graph.key) - || !unavailable_keys.insert(graph.key.clone()) + if !unavailable_keys.insert(graph.key.clone()) || epochs.contains_key(&graph.key) + || deleted_keys.contains(&graph.key) || record.predecessors.iter().any(|old| old.key == graph.key) { return Err(ServingTransitionError::InvalidBindings); @@ -830,12 +741,13 @@ impl GraphRegistry { if record .predecessors .iter() - .any(|view| !epochs.contains_key(&view.key) && !removals.contains(&view.key)) + .any(|view| !epochs.contains_key(&view.key) && !deleted_keys.contains(&view.key)) { return Err(ServingTransitionError::InvalidBindings); } let mut snapshot = state.snapshot.with_graphs(graphs); snapshot.server_policy = server_policy; + snapshot.deployment = deployment; let snapshot = Arc::new(snapshot); if views.iter().any(|view| !view.contract_is_current()) { return Err(ServingTransitionError::SchemaChanged); @@ -844,7 +756,6 @@ impl GraphRegistry { return Err(ServingTransitionError::DeadlineElapsed); } state.snapshot = snapshot; - state.retired = retired; state.candidate = None; Ok(epochs) } @@ -1514,6 +1425,22 @@ mod tests { .unwrap() } + fn activation_binding( + handle: &Arc, + property: &str, + ) -> (Arc, SchemaContractDigest) { + ( + Arc::new(GraphHandle { + key: handle.key.clone(), + uri: handle.uri.clone(), + engine: Arc::clone(&handle.engine), + policy: handle.policy.clone(), + queries: Some(Arc::new(activation_queries(property))), + }), + handle.engine.schema_contract_digest(), + ) + } + #[tokio::test] async fn batch_activation_drains_all_affected_graphs_and_keeps_the_same_engines() { let dir = TempDir::new().unwrap(); @@ -1571,22 +1498,17 @@ mod tests { .unwrap(); } let added = build_handle("added", dir.path()).await; - let bindings = [&alpha, &beta] - .into_iter() - .map(|handle| { - ( - handle.key.clone(), - ( - handle.engine.schema_contract_digest(), - activation_queries("nickname"), - ), - ) - }) - .collect(); let epochs = transition - .activate( - bindings, - vec![(Arc::clone(&added), added.engine.schema_contract_digest())], + .activate_deployment( + vec![ + activation_binding(&alpha, "nickname"), + activation_binding(&beta, "nickname"), + (Arc::clone(&added), added.engine.schema_contract_digest()), + ], + Vec::new(), + Vec::new(), + None, + None, ) .unwrap(); assert_eq!(epochs.len(), 3); @@ -1708,15 +1630,17 @@ mod tests { transition .activate_deployment( vec![ + activation_binding(&alpha, "nickname"), (rebound, beta.engine.schema_contract_digest()), ( Arc::clone(&blocked_handle), blocked_handle.engine.schema_contract_digest(), ), ], - HashSet::from([alpha.key.clone()]), + Vec::new(), Vec::new(), Some(Arc::clone(&management)), + None, ) .unwrap(); let activated = registry.snapshot_ref(); @@ -1726,7 +1650,7 @@ mod tests { )); assert!(previous.server_policy.is_none()); assert!(previous.graphs.contains_key(&alpha.key)); - assert!(!activated.graphs.contains_key(&alpha.key)); + assert!(activated.graphs.contains_key(&alpha.key)); assert!(matches!( registry.get(&blocked_handle.key), RegistryLookup::Ready(_) @@ -1737,33 +1661,6 @@ mod tests { beta_current.schema_contract(), &beta.engine.schema_contract_digest() ); - assert!( - registry - .retained_engine(&alpha.uri) - .unwrap() - .unwrap() - .shares_runtime_owner(&alpha.engine) - ); - // Four active roots plus the deleted alpha owner still consume five slots. - let mut candidates: Vec<_> = (0..2043) - .map(|index| { - dir.path() - .join(format!("future-{index}")) - .to_string_lossy() - .into_owned() - }) - .collect(); - registry.validate_owner_capacity(&candidates).unwrap(); - candidates.push( - dir.path() - .join("one-too-many") - .to_string_lossy() - .into_owned(), - ); - assert!(matches!( - registry.validate_owner_capacity(&candidates), - Err(ServingTransitionError::InvalidGraph(_)) - )); } #[tokio::test] @@ -1771,7 +1668,16 @@ mod tests { let dir = TempDir::new().unwrap(); let alpha = build_handle("alpha", dir.path()).await; let beta = build_handle("beta", dir.path()).await; - for refusal in ["missing", "queries", "contract", "duplicate"] { + for refusal in [ + "missing", + "queries", + "contract", + "duplicate", + "foreign_delete", + "duplicate_delete", + "wrong_delete_contract", + "delete_and_replace", + ] { let registry = Arc::new( GraphRegistry::from_handles(vec![Arc::clone(&alpha), Arc::clone(&beta)]).unwrap(), ); @@ -1786,41 +1692,50 @@ mod tests { .close() .unwrap(); transition.wait_requests().await.unwrap(); - let mut bindings: HashMap<_, _> = [&alpha, &beta] - .into_iter() - .map(|handle| { - ( - handle.key.clone(), - ( - handle.engine.schema_contract_digest(), - activation_queries("name"), - ), - ) - }) - .collect(); - let additions = match refusal { + let mut bindings = vec![ + activation_binding(&alpha, "name"), + activation_binding(&beta, "name"), + ]; + let mut deleted = Vec::new(); + match refusal { "missing" => { - bindings.remove(&beta.key); - Vec::new() + bindings.pop(); } "queries" => { - bindings.get_mut(&beta.key).unwrap().1 = activation_queries("nonexistent"); - Vec::new() + bindings[1] = activation_binding(&beta, "nonexistent"); } "contract" => { beta.engine .apply_schema(&format!("// changed\n{TEST_SCHEMA}")) .await .unwrap(); - Vec::new() } - "duplicate" => vec![(Arc::clone(&alpha), alpha.engine.schema_contract_digest())], + "duplicate" => bindings.push(activation_binding(&alpha, "name")), + "foreign_delete" => deleted.push(( + GraphKey::cluster(GraphId::try_from("unrelated").unwrap()), + beta.engine.schema_contract_digest(), + )), + "duplicate_delete" => { + bindings.pop(); + deleted = vec![(beta.key.clone(), beta.engine.schema_contract_digest()); 2]; + } + "wrong_delete_contract" => { + bindings.pop(); + deleted.push((beta.key.clone(), alpha.engine.schema_contract_digest())); + } + "delete_and_replace" => { + deleted.push((beta.key.clone(), beta.engine.schema_contract_digest())); + } _ => unreachable!(), - }; - let error = transition.activate(bindings, additions).unwrap_err(); + } + let error = transition + .activate_deployment(bindings, Vec::new(), deleted, None, None) + .unwrap_err(); match refusal { "queries" => assert!(matches!(error, ServingTransitionError::InvalidQueries(_))), - "contract" => assert_eq!(error, ServingTransitionError::SchemaChanged), + "contract" | "wrong_delete_contract" => { + assert_eq!(error, ServingTransitionError::SchemaChanged) + } _ => assert_eq!(error, ServingTransitionError::InvalidBindings), } assert!(registry.list().is_empty()); @@ -1835,7 +1750,7 @@ mod tests { } #[tokio::test] - async fn create_only_activation_and_unchanged_batch_resumption_share_the_candidate_boundary() { + async fn create_delete_and_unchanged_batch_resumption_share_the_candidate_boundary() { let dir = TempDir::new().unwrap(); let alpha = build_handle("alpha", dir.path()).await; let beta = build_handle("beta", dir.path()).await; @@ -1854,9 +1769,12 @@ mod tests { assert!(transition.engines().unwrap().is_empty()); assert_eq!( transition - .activate( - HashMap::new(), - vec![(Arc::clone(&beta), beta.engine.schema_contract_digest())] + .activate_deployment( + vec![(Arc::clone(&beta), beta.engine.schema_contract_digest())], + Vec::new(), + Vec::new(), + None, + None, ) .unwrap() .len(), @@ -1879,19 +1797,67 @@ mod tests { assert_ne!(resumed[&beta.key], prior_beta); assert_eq!(registry.list().len(), 2); + let peer = captured(®istry, &operations, &beta.key); + let predecessor = captured(®istry, &operations, &alpha.key); + let descendant = predecessor.clone(); + let transition = registry + .prepare_transition( + &operations, + std::slice::from_ref(&alpha.key), + transition_deadline(), + ) + .unwrap() + .close() + .unwrap(); + let stale = Arc::clone(&locked(®istry.state).candidate.as_ref().unwrap().record); + drop(predecessor); + { + let waiting = transition.wait_requests(); + tokio::pin!(waiting); + assert!(futures::poll!(waiting.as_mut()).is_pending()); + drop(descendant); + waiting.await.unwrap(); + } + transition + .activate_deployment( + Vec::new(), + Vec::new(), + vec![(alpha.key.clone(), alpha.engine.schema_contract_digest())], + None, + None, + ) + .unwrap(); + assert!(matches!(registry.get(&alpha.key), RegistryLookup::Gone)); + assert!(matches!( + registry.capture(&operations, &alpha.key).unwrap(), + RegistryCapture::Gone + )); + assert_eq!( + registry.resume_same_views(&stale), + Err(ServingTransitionError::StaleAttempt) + ); + assert_eq!( + captured(®istry, &operations, &beta.key).epoch(), + peer.epoch() + ); + assert_eq!(registry.len(), 1); + let transition = registry .prepare_transition(&operations, &[], transition_deadline()) .unwrap() .close() .unwrap(); assert!(matches!( - transition.activate( - HashMap::new(), - vec![(Arc::clone(&alpha), alpha.engine.schema_contract_digest())] + transition.activate_deployment( + vec![(Arc::clone(&beta), beta.engine.schema_contract_digest())], + Vec::new(), + Vec::new(), + None, + None, ), Err(ServingTransitionError::InvalidGraph(_)) )); - assert_eq!(registry.list().len(), 2); + assert_eq!(registry.list().len(), 1); } #[tokio::test] @@ -1913,17 +1879,7 @@ mod tests { transition.wait_requests().await.unwrap(); let record = Arc::clone(&locked(®istry.state).candidate.as_ref().unwrap().record); let replacements = registry - .validate_activation( - &record, - HashMap::from([( - alpha.key.clone(), - ( - alpha.engine.schema_contract_digest(), - activation_queries("name"), - ), - )]), - vec![], - ) + .validate_deployment_activation(&record, vec![activation_binding(&alpha, "name")]) .unwrap(); let expected = if refusal == "contract" { alpha @@ -1937,7 +1893,14 @@ mod tests { ServingTransitionError::ProcessClosed }; assert_eq!( - operations.while_open(|| registry.activate(&record, replacements)), + operations.while_open(|| registry.activate_deployment( + &record, + replacements, + Vec::new(), + Vec::new(), + None, + None + )), Err(expected) ); assert!(matches!( diff --git a/crates/omnigraph-server/src/serving.rs b/crates/omnigraph-server/src/serving.rs index 77d9c760f..8ae729c3c 100644 --- a/crates/omnigraph-server/src/serving.rs +++ b/crates/omnigraph-server/src/serving.rs @@ -17,8 +17,6 @@ use tokio::time::Instant; use crate::ApiError; use crate::identity::GraphKey; use crate::operations::OperationRuntime; -#[cfg(test)] -use crate::queries::QueryRegistry; use crate::registry::{BlockedGraph, GraphHandle, GraphRegistry}; /// A non-reusable epoch within one registered graph's lifetime. @@ -341,29 +339,14 @@ impl GraphTransition { }) } - /// Publish complete query/schema bindings only after the deployment - /// controller has recorded their durable achieved state. All replacement - /// engines and policies stay identical; additions are already durable graphs. - #[cfg(test)] - pub(crate) fn activate( - self, - bindings: HashMap, - additions: Vec<(Arc, SchemaContractDigest)>, - ) -> Result, ServingTransitionError> { - let replacements = self - .registry - .validate_activation(&self.record, bindings, additions)?; - self.operations - .while_open(|| self.registry.activate(&self.record, replacements)) - } - /// Publish graph lifecycle and authorization changes in one registry snapshot. pub(crate) fn activate_deployment( self, handles: Vec<(Arc, SchemaContractDigest)>, - removals: std::collections::HashSet, unavailable: Vec>, + deleted: Vec<(GraphKey, SchemaContractDigest)>, server_policy: Option>, + deployment: Option, ) -> Result, ServingTransitionError> { let views = self .registry @@ -372,9 +355,10 @@ impl GraphTransition { self.registry.activate_deployment( &self.record, views, - removals, unavailable, + deleted, server_policy, + deployment, ) }) } diff --git a/crates/omnigraph-server/src/settings.rs b/crates/omnigraph-server/src/settings.rs index 7ca8332b0..fbe3015c2 100644 --- a/crates/omnigraph-server/src/settings.rs +++ b/crates/omnigraph-server/src/settings.rs @@ -1099,15 +1099,9 @@ graphs: ) .unwrap(); let caller = omnigraph_cluster::DeploymentCaller::storage_owner(None); - let apply = omnigraph_cluster::apply_deployment( - dir.path(), - None, - &caller, - &Default::default(), - |_, _, _| {}, - ) - .await - .unwrap(); + let apply = omnigraph_cluster::apply_deployment(dir.path(), None, &caller, |_, _, _| {}) + .await + .unwrap(); assert!( matches!(apply, omnigraph_cluster::DeploymentLookup::Complete { ref result } if result.converged), "{apply:?}" diff --git a/crates/omnigraph-server/tests/auth_policy.rs b/crates/omnigraph-server/tests/auth_policy.rs index 75d723631..3374ad2cb 100644 --- a/crates/omnigraph-server/tests/auth_policy.rs +++ b/crates/omnigraph-server/tests/auth_policy.rs @@ -14,7 +14,7 @@ use omnigraph::error::OmniError; use omnigraph::loader::LoadMode; use omnigraph_server::api::{ BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorOutput, ExportRequest, - ReadRequest, SchemaApplyRequest, + QueryRequest, }; use omnigraph_server::{AppState, build_app}; use serde_json::{Value, json}; @@ -90,12 +90,14 @@ async fn identity_discovery_exposes_only_existence_and_policy_controls_schema() {"graph_id":"unavailable","display_name":"unavailable"} ]}) ); - for token in [&restricted, "static-token"] { + for (token, expected) in [ + (&restricted[..], StatusCode::UNAUTHORIZED), + ("static-token", StatusCode::FORBIDDEN), + ] { let (status, _) = json_response(&app, get_request("/graphs/discovery", token)).await; assert_eq!( - status, - StatusCode::FORBIDDEN, - "legacy credentials cannot escape their catalog contract" + status, expected, + "retired signed credentials are rejected; static credentials cannot discover" ); } for path in [ @@ -143,38 +145,23 @@ async fn identity_discovery_exposes_only_existence_and_policy_controls_schema() let (status, _) = json_response(&app, get_request("/graphs/default/schema", &identity)).await; assert_eq!(status, expected, "the same token follows activated policy"); - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .method(Method::POST) - .uri(g("/schema/apply")) - .header("authorization", format!("Bearer {identity}")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::to_vec(&SchemaApplyRequest { - schema_source: fs::read_to_string(fixture("test.pg")).unwrap(), - }) - .unwrap(), - )) - .unwrap(); - let (status, _) = json_response(&app, request).await; - assert_eq!( - status, expected, - "identity credentials neither grant nor ban schema apply" - ); } } #[tokio::test(flavor = "multi_thread")] -async fn signed_data_tokens_narrow_policy_and_attribute_writes() { +async fn signed_identities_obey_current_policy_and_attribute_writes() { let tokens = data_tokens::DataTokens::new(); let temp = init_loaded_graph().await; let graph = graph_path(temp.path()); let policy_path = temp.path().join("policy.yaml"); - fs::write( - &policy_path, - permit_all_policy_yaml(&[&tokens.actor, "breakglass"]), - ) - .unwrap(); + let writer = "01M00000000000000000000005"; + let creator = "01M00000000000000000000006"; + let loader = "01M00000000000000000000007"; + let policy = format!( + "version: 1\ngroups:\n readers: [{}, principal:{writer}, principal:{creator}, principal:{loader}, breakglass]\n writers: [principal:{writer}, principal:{loader}, breakglass]\n creators: [principal:{creator}, principal:{loader}, breakglass]\nrules:\n - id: read\n allow: {{actors: {{group: readers}}, actions: [read], branch_scope: any}}\n - id: write\n allow: {{actors: {{group: writers}}, actions: [change], branch_scope: any}}\n - id: create\n allow: {{actors: {{group: creators}}, actions: [branch_create], target_branch_scope: any}}\n", + tokens.actor, + ); + fs::write(&policy_path, policy).unwrap(); let state = AppState::open_with_bearer_tokens_and_policy( graph.to_string_lossy().to_string(), vec![("breakglass".into(), "explicit.static.credential".into())], @@ -184,11 +171,8 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { .unwrap() .with_data_token_trust(tokens.trust.clone()); let app = build_app(state); - let read = tokens.token(json!([ - {"graph_id":"default","actions":["read"]}, - {"graph_id":"reports","actions":["change"]} - ])); - let wider = tokens.token(json!([{"graph_id":"default","actions":["change"]}])); + let read = tokens.identity_token(); + let wider = tokens.identity_token_for(writer); let unrelated_authentication = tokens .trust .verify_authenticated_at( @@ -221,7 +205,7 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { assert_eq!( status, StatusCode::FORBIDDEN, - "neither another graph's grant nor a forged public actor may widen signed authority" + "neither another authenticated extension nor a forged public actor may bypass current policy" ); let (_, after) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; assert_eq!(after, before, "denial must not publish a commit"); @@ -232,8 +216,8 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { .await; assert_eq!( status, - StatusCode::FORBIDDEN, - "no graph existence probe outside the token grant" + StatusCode::NOT_FOUND, + "an authenticated unknown graph is still absent" ); let (status, _) = json_response( &app, @@ -245,23 +229,22 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { StatusCode::OK, "explicit static dot credentials still work" ); - let write = tokens.token(json!([{"graph_id":"default","actions":["read","change"]}])); + let write = tokens.identity_token_for(writer); let mut allowed = request(); allowed .headers_mut() .insert("authorization", format!("Bearer {write}").parse().unwrap()); let (status, body) = json_response(&app, allowed).await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["actor_id"], tokens.actor); + assert_eq!(body["actor_id"], format!("principal:{writer}")); let (_, commits) = json_response(&app, get_request(&g("/commits?branch=main"), &write)).await; - assert_eq!(commits["commits"][0]["actor_id"], tokens.actor); + assert_eq!( + commits["commits"][0]["actor_id"], + format!("principal:{writer}") + ); assert_ne!(commits, before); let (_, before_branches) = json_response(&app, get_request(&g("/branches"), &read)).await; - let other_graph_create = tokens.token(json!([ - {"graph_id":"default","actions":["read"]}, - {"graph_id":"reports","actions":["branch_create"]} - ])); - for token in [&read, &wider, &other_graph_create] { + for token in [&read, &wider] { let (status, body) = json_response( &app, statement_request("/mutate", token, "branch create signed_branch"), @@ -270,7 +253,7 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { assert_forbidden( status, body, - "branch statements retain the selected graph's action ceiling", + "branch statements require current branch_create permission", ); } let (_, after_branches) = json_response(&app, get_request(&g("/branches"), &read)).await; @@ -279,14 +262,14 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { "denied branch statements have no effect" ); - let create = tokens.token(json!([{"graph_id":"default","actions":["branch_create"]}])); + let create = tokens.identity_token_for(creator); let (status, body) = json_response( &app, statement_request("/mutate", &create, "branch create signed_branch"), ) .await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["actor_id"], tokens.actor); + assert_eq!(body["actor_id"], format!("principal:{creator}")); let (status, body) = json_response( &app, statement_request("/mutate", &create, "branch delete signed_branch"), @@ -308,22 +291,14 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { let mut hidden = statement_request("/query", &read, "branch list"); *hidden.uri_mut() = "/graphs/hidden/query".parse().unwrap(); let (status, body) = json_response(&app, hidden).await; - assert_forbidden( - status, - body, - "branch list cannot probe a graph outside the signed grant", - ); + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); - // Version-1 signed-grant compatibility on the existing load route. Failed - // fork+load admission must leave the branch registry and graph head alone. + // Current policy must grant both change and branch creation for fork+load. + // Failed admission leaves the branch registry and graph head alone. let (_, before_load_branches) = json_response(&app, get_request(&g("/branches"), &read)).await; let (_, before_load_commits) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; - let split_load_grant = tokens.token(json!([ - {"graph_id":"default","actions":["change"]}, - {"graph_id":"reports","actions":["branch_create"]} - ])); - for token in [&read, &write, &create, &split_load_grant] { + for token in [&read, &write, &create] { let (status, body) = json_response(&app, signed_load_request(token, true)).await; assert_forbidden( status, @@ -336,13 +311,12 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; assert_eq!(before_load_branches, after_load_branches); assert_eq!(before_load_commits, after_load_commits); - let load_token = - tokens.token(json!([{"graph_id":"default","actions":["read","change","branch_create"]}])); + let load_token = tokens.identity_token_for(loader); let (status, loaded) = json_response(&app, signed_load_request(&load_token, true)).await; assert_eq!(status, StatusCode::OK, "{loaded}"); assert_eq!(loaded["branch_created"], true); - assert_eq!(loaded["actor_id"], tokens.actor); - assert_eq!(loaded["commit"]["actor_id"], tokens.actor); + assert_eq!(loaded["actor_id"], format!("principal:{loader}")); + assert_eq!(loaded["commit"]["actor_id"], format!("principal:{loader}")); let (_, branch_commits) = json_response(&app, get_request(&g("/commits?branch=load_review"), &read)).await; assert_eq!(loaded["commit"], branch_commits["commits"][0]); @@ -389,7 +363,7 @@ async fn signed_data_requires_cedar_and_rejects_forgery_on_every_protected_route .unwrap() .with_data_token_trust(tokens.trust.clone()); let app = build_app(state); - let token = tokens.token(json!([{"graph_id":"default","actions":["read"]}])); + let token = tokens.identity_token(); let (status, _) = json_response(&app, get_request(&g("/snapshot?branch=main"), &token)).await; assert_eq!( status, @@ -424,8 +398,7 @@ async fn signed_data_requires_cedar_and_rejects_forgery_on_every_protected_route StatusCode::FORBIDDEN, "a valid token must not enroll its actor in Cedar" ); - let load_token = - tokens.token(json!([{"graph_id":"default","actions":["change","branch_create"]}])); + let load_token = tokens.identity_token(); let (_, before_load) = json_response(&app, get_request(&g("/branches"), "static-token")).await; for denied_app in [&app, &unknown_actor_app] { let (status, body) = @@ -441,6 +414,8 @@ async fn signed_data_requires_cedar_and_rejects_forgery_on_every_protected_route before_load, after_load, "Cedar denial cannot create the load branch" ); + let retired = + tokens.token(json!([{"graph_id":"default","actions":["read","change","branch_create"]}])); let state = AppState::open(graph.to_string_lossy().to_string()) .await .unwrap() @@ -451,19 +426,15 @@ async fn signed_data_requires_cedar_and_rejects_forgery_on_every_protected_route (Method::GET, "/blob"), (Method::HEAD, "/blob"), (Method::POST, "/export"), - (Method::POST, "/read"), (Method::POST, "/query"), - (Method::POST, "/change"), (Method::POST, "/mutate"), (Method::POST, "/mutate/if-graph-commit"), (Method::GET, "/queries"), (Method::POST, "/queries/find_person"), (Method::POST, "/queries/find_person/if-graph-commit"), (Method::GET, "/schema"), - (Method::POST, "/schema/apply"), (Method::POST, "/load"), (Method::POST, "/load/ndjson"), - (Method::POST, "/ingest"), (Method::GET, "/branches"), (Method::POST, "/branches"), (Method::DELETE, "/branches/feature"), @@ -474,20 +445,22 @@ async fn signed_data_requires_cedar_and_rejects_forgery_on_every_protected_route (Method::GET, "/changes"), (Method::POST, "/changes/baseline"), ] { - let response = app - .clone() - .oneshot( - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .method(method) - .uri(g(path)) - .header("authorization", "Bearer invalid.jwt.signature") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}"); + for rejected in ["invalid.jwt.signature", &retired] { + let response = app + .clone() + .oneshot( + Request::builder() + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .method(method.clone()) + .uri(g(path)) + .header("authorization", format!("Bearer {rejected}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}"); + } } for path in ["/graphs", "/graphs/default/snapshot"] { // No contract header either: authentication must reject first. @@ -507,7 +480,7 @@ async fn signed_data_requires_cedar_and_rejects_forgery_on_every_protected_route } #[tokio::test(flavor = "multi_thread")] -async fn signed_merge_cannot_smuggle_source_deletion_through_merge_grant() { +async fn signed_merge_requires_separate_policy_permission_to_delete_source() { let tokens = data_tokens::DataTokens::new(); let temp = init_loaded_graph().await; let graph = graph_path(temp.path()); @@ -517,7 +490,14 @@ async fn signed_merge_cannot_smuggle_source_deletion_through_merge_grant() { .unwrap(); drop(db); let policy_path = temp.path().join("policy.yaml"); - fs::write(&policy_path, permit_all_policy_yaml(&[&tokens.actor])).unwrap(); + fs::write( + &policy_path, + permit_all_policy_yaml(&[&tokens.actor]).replace( + "schema_apply, branch_create, branch_delete, branch_merge", + "schema_apply, branch_create, branch_merge", + ), + ) + .unwrap(); let app = build_app( AppState::open_with_bearer_tokens_and_policy( graph.to_string_lossy().to_string(), @@ -528,7 +508,7 @@ async fn signed_merge_cannot_smuggle_source_deletion_through_merge_grant() { .unwrap() .with_data_token_trust(tokens.trust.clone()), ); - let token = tokens.token(json!([{"graph_id":"default","actions":["read","branch_merge"]}])); + let token = tokens.identity_token(); let (status, body) = json_response( &app, Request::builder() @@ -549,12 +529,6 @@ async fn signed_merge_cannot_smuggle_source_deletion_through_merge_grant() { assert_eq!(body["commit"], Value::Null); assert!(body.get("branch_delete_error").is_none()); assert_eq!(body["branch_delete_error_details"]["code"], "forbidden"); - assert!( - body["branch_delete_error_details"]["error"] - .as_str() - .unwrap() - .contains("credential does not permit") - ); let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); assert!( db.branch_list() @@ -1179,9 +1153,9 @@ async fn policy_uses_resolved_branch_for_snapshot_reads() { .unwrap(); let app = build_app(state); - let read = ReadRequest { - query_source: fs::read_to_string(fixture("test.gq")).unwrap(), - query_name: Some("get_person".to_string()), + let read = QueryRequest { + query: fs::read_to_string(fixture("test.gq")).unwrap(), + name: Some("get_person".to_string()), params: Some(json!({ "name": "Alice" })), branch: None, snapshot: Some(snapshot_id.clone()), @@ -1191,7 +1165,7 @@ async fn policy_uses_resolved_branch_for_snapshot_reads() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("authorization", "Bearer team-token") .header("content-type", "application/json") @@ -1424,7 +1398,7 @@ async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("authorization", "Bearer team-token") .header("content-type", "application/json") @@ -1450,7 +1424,7 @@ async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("authorization", "Bearer team-token") .header("content-type", "application/json") @@ -1743,12 +1717,17 @@ async fn show_takes_the_scope_free_read_decision_as_branch_list_does() { } #[tokio::test(flavor = "multi_thread")] -async fn signed_change_only_credential_cannot_show_settings() { +async fn signed_identity_with_change_only_policy_cannot_show_settings() { let tokens = data_tokens::DataTokens::new(); let temp = init_loaded_graph().await; let graph = graph_path(temp.path()); let policy_path = temp.path().join("policy.yaml"); - fs::write(&policy_path, permit_all_policy_yaml(&[&tokens.actor])).unwrap(); + let reader = "01M00000000000000000000005"; + let policy = format!( + "version: 1\ngroups:\n readers: [principal:{reader}]\n changers: [{}]\nrules:\n - id: read\n allow: {{actors: {{group: readers}}, actions: [read], branch_scope: any}}\n - id: change\n allow: {{actors: {{group: changers}}, actions: [change], branch_scope: any}}\n", + tokens.actor + ); + fs::write(&policy_path, policy).unwrap(); let state = AppState::open_with_bearer_tokens_and_policy( graph.to_string_lossy().to_string(), vec![("breakglass".into(), "explicit.static.credential".into())], @@ -1758,8 +1737,8 @@ async fn signed_change_only_credential_cannot_show_settings() { .unwrap() .with_data_token_trust(tokens.trust.clone()); let app = build_app(state); - let change_only = tokens.token(json!([{"graph_id":"default","actions":["change"]}])); - let read = tokens.token(json!([{"graph_id":"default","actions":["read"]}])); + let change_only = tokens.identity_token(); + let read = tokens.identity_token_for(reader); for source in SHOW_SOURCES { let (status, body) = @@ -1788,7 +1767,7 @@ async fn authenticated_change_stamps_actor_on_commits() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("authorization", "Bearer token-one") .header("content-type", "application/json") @@ -1856,7 +1835,7 @@ async fn authenticated_branch_merge_stamps_merge_actor_on_head_commit() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("authorization", "Bearer token-one") .header("content-type", "application/json") @@ -2069,7 +2048,7 @@ async fn oversized_request_body_returns_payload_too_large() { .oneshot( Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(oversized)) @@ -2122,7 +2101,7 @@ async fn default_deny_mode_rejects_change_with_forbidden() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header(AUTHORIZATION, "Bearer demo-token") .header("content-type", "application/json") @@ -2139,38 +2118,6 @@ async fn default_deny_mode_rejects_change_with_forbidden() { ); } -#[tokio::test(flavor = "multi_thread")] -async fn default_deny_mode_rejects_schema_apply_with_forbidden() { - let (_temp, app) = app_for_graph_with_auth_tokens_only( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-andrew", "demo-token")], - ) - .await; - - let req = SchemaApplyRequest { - schema_source: additive_schema_with_nickname(), - }; - let (status, body) = json_response( - &app, - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/schema/apply")) - .method(Method::POST) - .header(AUTHORIZATION, "Bearer demo-token") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&req).unwrap())) - .unwrap(), - ) - .await; - assert_eq!(status, StatusCode::FORBIDDEN); - let error: ErrorOutput = serde_json::from_value(body).unwrap(); - assert!( - error.error.contains("default-deny"), - "expected default-deny in error message, got: {}", - error.error - ); -} - #[tokio::test(flavor = "multi_thread")] async fn policy_decision_parity_change_admin_on_main_allowed() { // (act-ragnor, change, main) — admins-change-anywhere rule applies. diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index 850b8c427..3621e7aea 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -549,7 +549,7 @@ async fn applied_empty_cluster_still_requires_exact_data_trust_root() { assert_eq!( settings.config().witness.booted_serving_digest, Some( - omnigraph_cluster::capture_deployment(temp.path(), &Default::default()) + omnigraph_cluster::capture_deployment(temp.path()) .unwrap() .config_digest() .to_owned() @@ -615,7 +615,7 @@ mod multi_graph_startup { } #[tokio::test(flavor = "multi_thread")] - async fn signed_registry_lists_only_granted_ready_and_blocked_graphs() { + async fn identity_registry_lists_policy_authorized_ready_and_blocked_graphs() { let tokens = data_tokens::DataTokens::new(); let policy = omnigraph_policy::PolicyEngine::load_server_from_source(&format!( "version: 1\ngroups:\n viewers: [\"{}\"]\nrules:\n - id: list\n allow:\n actors: {{group: viewers}}\n actions: [graph_list]\n",tokens.actor @@ -628,30 +628,33 @@ mod multi_graph_startup { .await; let state = state.with_data_token_trust(tokens.trust.clone()); let app = build_app(state); - let token = tokens.token(serde_json::json!([ - {"graph_id":"alpha","actions":["graph_list"]}, - {"graph_id":"beta","actions":["read"]}, - {"graph_id":"ghost-allowed","actions":["graph_list"]} - ])); + let token = tokens.identity_token(); let (status, body) = json_response(&app, get_request("/graphs", &token)).await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["graphs"].as_array().unwrap().len(), 2); + assert_eq!(body["graphs"].as_array().unwrap().len(), 4); assert_eq!(body["graphs"][0]["graph_id"], "alpha"); assert_eq!(body["graphs"][0]["state"], "ready"); - assert_eq!(body["graphs"][1]["graph_id"], "ghost-allowed"); - assert_eq!(body["graphs"][1]["state"], "blocked"); + assert_eq!(body["graphs"][2]["graph_id"], "ghost-allowed"); + assert_eq!(body["graphs"][2]["state"], "blocked"); assert!(body.get("quarantined").is_none()); - for (id, expected) in [ - ("ghost-allowed", StatusCode::SERVICE_UNAVAILABLE), - ("ghost-hidden", StatusCode::FORBIDDEN), - ] { + // Listing permission reveals availability but grants no data access. + let (status, _) = json_response(&app, get_request("/graphs/alpha/snapshot", &token)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + let hidden = tokens.identity_token_for("unlisted"); + for id in ["ghost-allowed", "ghost-hidden"] { let (status, _) = json_response(&app, get_request(&format!("/graphs/{id}/snapshot"), &token)).await; - assert_eq!(status, expected); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + let (status, _) = json_response( + &app, + get_request(&format!("/graphs/{id}/snapshot"), &hidden), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); } - let read = tokens.token(serde_json::json!([{"graph_id":"alpha","actions":["read"]}])); - let (status, _) = json_response(&app, get_request("/graphs", &read)).await; - assert_eq!(status, StatusCode::FORBIDDEN); + let retired = tokens.token(serde_json::json!([{"graph_id":"alpha","actions":["read"]}])); + let (status, _) = json_response(&app, get_request("/graphs", &retired)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); } /// Cluster route `/graphs/{graph_id}/snapshot` resolves to the right @@ -729,25 +732,20 @@ mod multi_graph_startup { (Method::GET, "/graphs/alpha/commits", None), ( Method::POST, - "/graphs/alpha/read", - Some(r#"{"query_source":"query q() { return {} }"}"#), + "/graphs/alpha/query", + Some(r#"{"query":"query q() { return {} }"}"#), ), ( Method::POST, - "/graphs/alpha/change", - Some(r#"{"query_source":"query q() { return {} }"}"#), + "/graphs/alpha/mutate", + Some(r#"{"query":"query q() { return {} }"}"#), ), ( Method::POST, "/graphs/alpha/export", Some(r#"{"branch":"main"}"#), ), - ( - Method::POST, - "/graphs/alpha/schema/apply", - Some(r#"{"schema_source":""}"#), - ), - (Method::POST, "/graphs/alpha/ingest", Some(r#"{"data":""}"#)), + (Method::POST, "/graphs/alpha/load", Some(r#"{"data":""}"#)), ( Method::POST, "/graphs/alpha/branches/merge", @@ -2108,7 +2106,6 @@ mod owned_shutdown { async fn live_deployment_retains_disconnected_owner_and_never_replays_original_id() { use omnigraph_cluster::{CapturedDeployment, DeploymentStatus}; use omnigraph_server::{GraphId, GraphKey, RegistryLookup, ServerConfigMode}; - use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; @@ -2186,6 +2183,24 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i let original_engine = Arc::clone(&original.engine); let original_contract = original_engine.schema_contract_digest(); let before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let boot_ledger: serde_json::Value = serde_json::from_slice(&before).unwrap(); + let boot_id = boot_ledger["deployment_results"] + .as_array() + .unwrap() + .last() + .unwrap()["id"] + .as_str() + .unwrap() + .to_owned(); + assert!( + !status(&app, Some(&boot_id)).await.1, + "generic construction does not attest deployment bindings" + ); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + before, + "boot observation does not rewrite the durable receipt" + ); let (code, _) = json_response(&app, get_request("/cluster/deployments", "reader-token")).await; assert_eq!(code, StatusCode::FORBIDDEN); let (initial_status, _) = status(&app, None).await; @@ -2196,7 +2211,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i ) .unwrap(); fs::write(temp.path().join("people.gq"), "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.bio } }\n").unwrap(); - let deployment = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let deployment = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (code, _) = json_response(&app, submit(&id, &deployment, "reader-token")).await; assert_eq!(code, StatusCode::FORBIDDEN); assert_eq!( @@ -2220,7 +2235,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i ) .replace("[knowledge, peer]", "[knowledge, peer, blocked]"); fs::write(temp.path().join("cluster.yaml"), format!("{blocked_config}providers:\n embedding:\n missing:\n kind: openai-compatible\n api_key: ${{{missing_key}}}\n")).unwrap(); - let blocked = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let blocked = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (code, refusal) = json_response(&app, submit(&id, &blocked, "operator-token")).await; assert_eq!(code, StatusCode::CONFLICT, "{refusal}"); assert!(!state.operation_runtime().snapshot().closed); @@ -2288,6 +2303,10 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i }) .await .expect("deployment must close affected admission before draining"); + assert!( + !status(&app, Some(&boot_id)).await.1, + "transitioning bindings are not active" + ); let (code, _) = json_response(&app, get_request("/graphs/peer/snapshot", "operator-token")).await; assert_eq!(code, StatusCode::OK, "unaffected graph keeps serving"); @@ -2328,6 +2347,18 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i let (code, duplicate) = json_response(&app, submit(&id, &deployment, "operator-token")).await; assert_eq!(code, StatusCode::OK, "{duplicate}"); assert_eq!(duplicate["active"], true); + let persisted: serde_json::Value = + serde_json::from_slice(&fs::read(temp.path().join("__cluster/state.json")).unwrap()) + .unwrap(); + let receipt = persisted["deployment_results"] + .as_array() + .unwrap() + .iter() + .find(|receipt| receipt["id"] == id) + .unwrap(); + assert_eq!(receipt, &duplicate["deployment"]["result"]); + assert!(receipt.get("activation").is_none()); + assert!(receipt.get("restart_required").is_none()); assert_eq!( original_engine.list_commits(None).await.unwrap()[0].graph_commit_id, committed_head @@ -2340,7 +2371,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i "node Person { name: String @key bio: String? note: String? }\n", ) .unwrap(); - let successor = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let successor = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (code, mismatch) = json_response(&app, submit(&id, &successor, "operator-token")).await; assert_eq!(code, StatusCode::CONFLICT, "{mismatch}"); let successor_id = completed.next_deployment_id(); @@ -2375,7 +2406,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i "node Peer { name: String @key note: String? }\n", ) .unwrap(); - let independent = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let independent = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; let (code, changed) = json_response( &app, @@ -2391,39 +2422,6 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i assert!(!state.operation_runtime().snapshot().closed); fs::write(catalog_query, source).unwrap(); - // Recreation cannot dispose a retained native owner, even after graph - // admission drains. Refuse before recording an operation or touching data. - let recreate = omnigraph_cluster::capture_deployment_with_options( - temp.path(), - &omnigraph_cluster::DeploymentOptions { - recreate_graphs: BTreeMap::from([("knowledge".to_owned(), latest_contract.clone())]), - ..Default::default() - }, - ) - .unwrap(); - let (next, _) = status(&app, None).await; - let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); - let (code, refused) = json_response( - &app, - submit(&next.next_deployment_id(), &recreate, "operator-token"), - ) - .await; - assert_eq!(code, StatusCode::CONFLICT, "{refused}"); - assert!( - refused["error"] - .as_str() - .unwrap() - .contains("retains a runtime owner") - ); - assert_eq!( - fs::read(temp.path().join("__cluster/state.json")).unwrap(), - ledger_before - ); - assert!(matches!( - state.routing().registry.get(&key), - RegistryLookup::Ready(_) - )); - // Existing policies can grant and revoke writes on the same runtime. // Candidate authorization cannot grant its submitter permission to apply. let old_policy = fs::read_to_string(temp.path().join("policy.yaml")).unwrap(); @@ -2435,8 +2433,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i old_management.replace("[operator]", "[reader]"), ) .unwrap(); - let denied_candidate = - omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let denied_candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); let (code, _) = json_response( @@ -2454,17 +2451,14 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i ledger_before ); fs::write(temp.path().join("cluster-policy.yaml"), &old_management).unwrap(); - let grant = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); - let (code, body) = json_response( - &app, - submit(&next.next_deployment_id(), &grant, "operator-token"), - ) - .await; + let grant = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); + let grant_id = next.next_deployment_id(); + let (code, body) = json_response(&app, submit(&grant_id, &grant, "operator-token")).await; assert_eq!(code, StatusCode::OK, "{body}"); assert_eq!(body["active"], true); fn change(body: Body) -> Request { - Request::post("/graphs/knowledge/change") + Request::post("/graphs/knowledge/mutate") .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) .header("authorization", "Bearer reader-token") .header("content-type", "application/json") @@ -2474,6 +2468,10 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i let mutation = serde_json::json!({"query":"query add() { insert Person { name: \"Granted\" } }", "name":"add"}); let (code, body) = json_response(&app, change(Body::from(mutation.to_string()))).await; assert_eq!(code, StatusCode::OK, "{body}"); + assert!( + status(&app, Some(&grant_id)).await.1, + "row commits keep the installed deployment active" + ); // A revocation waits for a request admitted under the granted policy. New // captures close immediately; after cutover neither HTTP nor engine bypass @@ -2494,7 +2492,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i .unwrap() .unwrap(); fs::write(temp.path().join("policy.yaml"), &old_policy).unwrap(); - let revoke = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let revoke = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; let revoke_request = submit(&next.next_deployment_id(), &revoke, "operator-token"); let request_app = app.clone(); @@ -2546,8 +2544,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i for model in ["first-model", "second-model"] { let bound = base_config.replace(" schema: ./people.pg\n", " schema: ./people.pg\n embedding_provider: selected\n external_blobs:\n allow:\n - base: s3://lifecycle-assets/allowed\n scope: server_safe\n"); fs::write(temp.path().join("cluster.yaml"), format!("{bound}providers:\n embedding:\n selected:\n kind: mock\n model: {model}\n")).unwrap(); - let candidate = - omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; let (code, body) = json_response( &app, @@ -2568,7 +2565,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i &format!("kind: openai-compatible\n api_key: ${{{missing_key}}}"), ); fs::write(temp.path().join("cluster.yaml"), broken).unwrap(); - let candidate = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); let RegistryLookup::Ready(before) = state.routing().registry.get(&key) else { @@ -2589,8 +2586,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i ledger_before ); fs::write(temp.path().join("cluster.yaml"), &base_config).unwrap(); - let removed_bindings = - omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let removed_bindings = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (code, body) = json_response( &app, submit( @@ -2602,74 +2598,141 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i .await; assert_eq!(code, StatusCode::OK, "{body}"); - // Explicit removal hides the graph; exact adoption restores its data and - // history rather than recreating it. Both activate without a restart. + // Removal waits for admitted request descendants and belongs to the + // server after its caller disconnects. The receipt proves physical deletion. let peer_key = GraphKey::cluster(GraphId::try_from("peer").unwrap()); let RegistryLookup::Ready(peer) = state.routing().registry.get(&peer_key) else { panic!("ready") }; - let confirmation = omnigraph_cluster::GraphLifecycleConfirmation { - contract: peer.engine.schema_contract_digest(), - graph_manifest_version: peer - .engine - .snapshot_of(omnigraph::db::ReadTarget::branch("main")) - .await - .unwrap() - .graph_manifest_version(), - }; - let peer_owner = Arc::clone(&peer.engine); + let deleted_contract = peer.engine.schema_contract_digest(); + drop(peer); let removed = base_config .replace(" peer:\n schema: ./peer.pg\n", "") .replace("[knowledge, peer]", "[knowledge]"); fs::write(temp.path().join("cluster.yaml"), &removed).unwrap(); - let options = omnigraph_cluster::DeploymentOptions { - delete_graphs: BTreeMap::from([("peer".into(), confirmation.clone())]), - ..Default::default() - }; - let candidate = - omnigraph_cluster::capture_deployment_with_options(temp.path(), &options).unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; - let (code, body) = json_response( - &app, - submit(&next.next_deployment_id(), &candidate, "operator-token"), - ) - .await; + let delete_id = next.next_deployment_id(); + let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let (code, _) = json_response(&app, submit(&delete_id, &candidate, "reader-token")).await; + assert_eq!(code, StatusCode::FORBIDDEN); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + ledger_before + ); + + let (polled, body_polled) = tokio::sync::oneshot::channel(); + let (release, body_released) = tokio::sync::oneshot::channel(); + let held_body = Body::from_stream(futures::stream::once(async move { + polled.send(()).unwrap(); + body_released.await.unwrap(); + Ok::<_, std::io::Error>(axum::body::Bytes::from_static( + br#"{"query":"query peers() { match { $p: Peer } return { $p.name } }"}"#, + )) + })); + let request = Request::post("/graphs/peer/query") + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .header("authorization", "Bearer operator-token") + .header("content-type", "application/json") + .body(held_body) + .unwrap(); + let request_app = app.clone(); + let invocation = tokio::spawn(async move { json_response(&request_app, request).await }); + tokio::time::timeout(Duration::from_secs(10), body_polled) + .await + .unwrap() + .unwrap(); + let request = submit(&delete_id, &candidate, "operator-token"); + let deletion_app = app.clone(); + let observer = tokio::spawn(async move { json_response(&deletion_app, request).await }); + tokio::time::timeout(Duration::from_secs(10), async { + while !matches!( + state.routing().registry.get(&peer_key), + RegistryLookup::Transitioning(_) + ) { + tokio::task::yield_now().await; + } + }) + .await + .expect("deletion must close admission before waiting for the held request"); + assert!(temp.path().join("graphs/peer.omni").exists()); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + ledger_before + ); + assert_eq!( + json_response(&app, get_request("/graphs/peer/snapshot", "operator-token")) + .await + .0, + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!( + json_response( + &app, + get_request("/graphs/knowledge/snapshot", "operator-token") + ) + .await + .0, + StatusCode::OK + ); + observer.abort(); + let _ = observer.await; + release.send(()).unwrap(); + let (code, body) = invocation.await.unwrap(); assert_eq!(code, StatusCode::OK, "{body}"); - assert_eq!(body["active"], true); + let deleted = tokio::time::timeout(Duration::from_secs(20), async { + loop { + let (observed, active) = status(&app, Some(&delete_id)).await; + if active { + break observed; + } + assert!(!state.operation_runtime().snapshot().closed); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("owned deletion must finish after its caller disconnects"); + let deleted = serde_json::to_value(deleted.lookup.unwrap()).unwrap(); + assert_eq!(deleted["result"]["graphs"]["peer"]["outcome"], "deleted"); + assert_eq!( + deleted["result"]["graphs"]["peer"]["contract"], + serde_json::to_value(deleted_contract).unwrap() + ); + assert!(!temp.path().join("graphs/peer.omni").exists()); assert!(matches!( state.routing().registry.get(&peer_key), RegistryLookup::Gone )); - assert!(temp.path().join("graphs/peer.omni").exists()); - fs::write(temp.path().join("cluster.yaml"), &base_config).unwrap(); - let options = omnigraph_cluster::DeploymentOptions { - adopt_graphs: BTreeMap::from([("peer".into(), confirmation.clone())]), - ..Default::default() - }; - let candidate = - omnigraph_cluster::capture_deployment_with_options(temp.path(), &options).unwrap(); - let (next, _) = status(&app, None).await; - // A read-only retained-owner check can fail before deployment acceptance. - // Temporarily hide its manifest to exercise the storage failure boundary. - let manifest = temp.path().join("graphs/peer.omni/__manifest"); - let held_manifest = temp.path().join("held-peer-manifest"); - fs::rename(&manifest, &held_manifest).unwrap(); - let ledger_before = fs::read(temp.path().join("__cluster/state.json")).unwrap(); - let (code, refused) = json_response( - &app, - submit(&next.next_deployment_id(), &candidate, "operator-token"), - ) - .await; - fs::rename(held_manifest, manifest).unwrap(); - assert!(!code.is_success(), "{refused}"); - assert!( - !state.operation_runtime().snapshot().closed, - "preflight read failure must not contain the process: {refused}" + assert_eq!( + json_response(&app, get_request("/graphs/peer/snapshot", "operator-token")) + .await + .0, + StatusCode::NOT_FOUND ); + let after = fs::read(temp.path().join("__cluster/state.json")).unwrap(); + let (code, repeated) = + json_response(&app, submit(&delete_id, &candidate, "operator-token")).await; + assert_eq!(code, StatusCode::OK, "{repeated}"); + assert_eq!(repeated["deployment"], deleted); + assert_eq!(repeated["active"], true); assert_eq!( fs::read(temp.path().join("__cluster/state.json")).unwrap(), - ledger_before + after ); + assert!(!temp.path().join("graphs/peer.omni").exists()); + + // Removing the final graph leaves an applied, ready empty cluster. The + // management policy remains usable for future deployments. + fs::write( + temp.path().join("cluster.yaml"), + "version: 1\ngraphs: {}\npolicies:\n management:\n file: ./cluster-policy.yaml\n applies_to: [cluster]\n", + ) + .unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); + let (code, body) = + json_response(&app, get_request("/cluster/deployments", "operator-token")).await; + assert_eq!(code, StatusCode::OK, "{body}"); + let next: DeploymentStatus = serde_json::from_value(body["status"].clone()).unwrap(); let (code, body) = json_response( &app, submit(&next.next_deployment_id(), &candidate, "operator-token"), @@ -2677,19 +2740,16 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i .await; assert_eq!(code, StatusCode::OK, "{body}"); assert_eq!(body["active"], true); - let RegistryLookup::Ready(peer) = state.routing().registry.get(&peer_key) else { - panic!("ready") - }; - assert!(peer_owner.shares_runtime_owner(&peer.engine)); - assert_eq!(peer.engine.schema_contract_digest(), confirmation.contract); assert_eq!( - peer.engine - .snapshot_of(omnigraph::db::ReadTarget::branch("main")) - .await - .unwrap() - .graph_manifest_version(), - confirmation.graph_manifest_version + body["deployment"]["result"]["graphs"]["knowledge"]["outcome"], + "deleted" ); + assert!(!temp.path().join("graphs/knowledge.omni").exists()); + assert!(state.routing().registry.is_empty()); + let (code, body) = json_response(&app, get_request("/readyz", "operator-token")).await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(body["ready"], true); + assert_eq!(body["served_graph_count"], 0); // Management-policy handoff takes effect on all cloned routers. The old // administrator cannot deploy or list; the newly granted one can observe. @@ -2701,7 +2761,7 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i ), ) .unwrap(); - let candidate = omnigraph_cluster::capture_deployment(temp.path(), &BTreeMap::new()).unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; let (code, body) = json_response( &app, diff --git a/crates/omnigraph-server/tests/data_routes.rs b/crates/omnigraph-server/tests/data_routes.rs index 98e13047e..22974c333 100644 --- a/crates/omnigraph-server/tests/data_routes.rs +++ b/crates/omnigraph-server/tests/data_routes.rs @@ -1,4 +1,4 @@ -//! Data-plane routes: read/query/change/ingest/branches/snapshot/export. +//! Data-plane routes: read/query/mutate/load/branches/snapshot/export. //! Moved verbatim from tests/server.rs in the modularization. use omnigraph_server::api::{HTTP_API_CONTRACT, HTTP_API_CONTRACT_HEADER}; @@ -21,7 +21,7 @@ use omnigraph::{ }; use omnigraph_server::api::{ BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorCode, ErrorOutput, ExportRequest, - GraphBatchLoadOutput, IngestRequest, QueryRequest, ReadRequest, + GraphBatchLoadOutput, IngestRequest, QueryRequest, }; use omnigraph_server::{AppState, ProcessDefaults, build_app}; use serde_json::{Value, json}; @@ -1255,7 +1255,7 @@ async fn ingest_creates_branch_returns_metadata_and_stamps_actor() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("authorization", "Bearer token-one") .header("content-type", "application/json") @@ -1327,7 +1327,7 @@ async fn ingest_existing_branch_skips_branch_create_policy_check() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("authorization", "Bearer team-token") .header("content-type", "application/json") @@ -1356,7 +1356,7 @@ async fn ingest_without_from_returns_404_for_missing_branch_and_creates_nothing( &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&ingest).unwrap())) @@ -1398,7 +1398,7 @@ async fn ingest_without_from_loads_into_existing_branch() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&ingest).unwrap())) @@ -1429,7 +1429,7 @@ async fn ingest_denies_missing_branch_without_branch_create_permission() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("authorization", "Bearer team-token") .header("content-type", "application/json") @@ -1463,7 +1463,7 @@ async fn ingest_denies_when_actor_lacks_change_permission() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("authorization", "Bearer team-token") .header("content-type", "application/json") @@ -1494,7 +1494,7 @@ async fn ingest_rejects_payloads_over_32_mib() { .oneshot( Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&oversize).unwrap())) @@ -2270,8 +2270,6 @@ async fn branch_statement_refusals_name_the_door_and_the_envelope() { let (_temp, app) = app_for_loaded_graph().await; let target_refusal = "a branch statement names its branches itself; drop the request target"; let name_refusal = "a branch statement takes no name and no parameters"; - let deprecated_refusal = - "branch statements are not served on deprecated routes; use POST /mutate or POST /query"; let cases = [ ( "/query", @@ -2353,51 +2351,6 @@ async fn branch_statement_refusals_name_the_door_and_the_envelope() { json!({"query": EXPLAIN_ADULTS}), "statement 'explain' is a read; use POST /query", ), - ( - "/change", - json!({"query": EXPLAIN_ADULTS}), - "the explain statement is not served on deprecated routes; use POST /query", - ), - ( - "/read", - json!({"query_source": EXPLAIN_ADULTS}), - "the explain statement is not served on deprecated routes; use POST /query", - ), - ( - "/read", - json!({"query_source": "branch list"}), - deprecated_refusal, - ), - ( - "/read", - json!({"query_source": "branch create b0"}), - deprecated_refusal, - ), - ( - "/read", - json!({"query_source": "branch delete b0"}), - deprecated_refusal, - ), - ( - "/change", - json!({"query": "branch create b0"}), - deprecated_refusal, - ), - ( - "/change", - json!({"query": "branch delete b0"}), - deprecated_refusal, - ), - ( - "/change", - json!({"query": "branch list"}), - deprecated_refusal, - ), - ( - "/change", - json!({"query": "branch merge b0", "branch": "main"}), - deprecated_refusal, - ), ]; for (path, request, expected) in cases { let (status, body) = json_response(&app, json_post(path, &request)).await; @@ -2508,7 +2461,6 @@ async fn parse_error_precedes_policy_denial_on_every_door() { .unwrap() }; let read = json!({"query": FIND_PERSON_GQ, "params": {"name": "Alice"}}); - let legacy_read = json!({"query_source": FIND_PERSON_GQ, "params": {"name": "Alice"}}); let write = json!({ "query": MUTATION_QUERIES, "name": "insert_person", @@ -2517,19 +2469,14 @@ async fn parse_error_precedes_policy_denial_on_every_door() { }); let denied = [ ("/query", "nobody-token", read.clone(), false), - ("/read", "nobody-token", legacy_read, false), ("/mutate", "team-token", write.clone(), false), - ("/change", "team-token", write.clone(), false), ("/mutate/if-graph-commit", "team-token", write, true), ]; for (path, token, body, expected_head) in denied { let (status, out) = json_response(&app, send(path, token, body, expected_head)).await; assert_eq!(status, StatusCode::FORBIDDEN, "{path}: {out}"); - let mut unparseable = json!({"query": "not gq at all", "branch": "main"}); - if path == "/read" { - unparseable = json!({"query_source": "not gq at all", "branch": "main"}); - } + let unparseable = json!({"query": "not gq at all", "branch": "main"}); let (status, out) = json_response(&app, send(path, token, unparseable, expected_head)).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{path}: {out}"); @@ -2543,11 +2490,7 @@ async fn parse_error_precedes_policy_denial_on_every_door() { // The measured shape, a declaration without its parameter list, is // refused at the name's end with the one fix at every door. - let field = if path == "/read" { - "query_source" - } else { - "query" - }; + let field = "query"; let missing = json!({field: "query name { match { $p: Person } return { $p.name } }", "branch": "main"}); let (status, out) = json_response(&app, send(path, token, missing, expected_head)).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{path}: {out}"); @@ -2633,9 +2576,6 @@ const UNKNOWN_SETTING_NEEDLE: &str = "unknown setting `turbo`"; const SET_PARAMETER_SHAPE: &str = "query parameter 'set' takes =, got 'merge_lineage'"; const NO_STATEMENT_NEEDLE: &str = "carries no statement"; const SHOW_AT_WRITE_DOOR: &str = "statement 'show merge_lineage' is a read; use POST /query"; -const STATEMENT_AT_DEPRECATED_ROUTE: &str = - "branch statements are not served on deprecated routes; use POST /mutate or POST /query"; -const SETTINGS_AT_DEPRECATED_ROUTE: &str = "the deprecated /read and /change routes take no settings, neither a settings field nor a set or reset prefix; use POST /query or POST /mutate"; const SHOW_COLUMNS: [&str; 5] = ["name", "value", "default", "source", "scope"]; /// Every settings refusal is `ApiError::bad_request`: the message, the code, @@ -3004,7 +2944,7 @@ async fn settings_field_is_accepted_at_the_conditional_mutation_route() { } #[tokio::test(flavor = "multi_thread")] -async fn settings_show_is_refused_at_the_write_door_and_the_deprecated_read_door() { +async fn settings_show_is_refused_at_the_write_door() { let (_temp, app) = app_for_loaded_graph().await; let (status, body) = json_response( @@ -3013,94 +2953,6 @@ async fn settings_show_is_refused_at_the_write_door_and_the_deprecated_read_door ) .await; assert_settings_refusal(status, &body, SHOW_AT_WRITE_DOOR); - - let (status, body) = json_response( - &app, - json_post("/read", &json!({"query_source": "show merge_lineage;"})), - ) - .await; - assert_settings_refusal(status, &body, STATEMENT_AT_DEPRECATED_ROUTE); -} - -#[tokio::test(flavor = "multi_thread")] -async fn settings_field_is_refused_at_the_deprecated_change_route() { - let (_temp, app) = app_for_loaded_graph().await; - let mutation = "mutation add_person($name: String) {\n insert Person { name: $name }\n}"; - - let (status, body) = json_response( - &app, - json_post( - "/change", - &json!({ - "query_source": mutation, - "params": {"name": "Zed"}, - "settings": {"merge_lineage": "off"} - }), - ), - ) - .await; - assert_settings_refusal(status, &body, SETTINGS_AT_DEPRECATED_ROUTE); - - assert_unknown_settings_field( - &app, - json_post( - "/change", - &json!({ - "query_source": mutation, - "params": {"name": "Zed"}, - "settings": {"stage_write_concurrency": 64} - }), - ), - ) - .await; -} - -/// Both carriers meet the same refusal at both deprecated routes, which -/// serve their legacy bodies under the process defaults alone. -#[tokio::test(flavor = "multi_thread")] -async fn settings_field_and_prefix_are_refused_at_the_deprecated_routes() { - let (_temp, app) = app_for_loaded_graph().await; - - let (status, body) = json_response( - &app, - json_post( - "/read", - &json!({ - "query_source": FIND_PERSON_GQ, - "params": {"name": "Alice"}, - "settings": {"merge_lineage": "off"} - }), - ), - ) - .await; - assert_settings_refusal(status, &body, SETTINGS_AT_DEPRECATED_ROUTE); - - let (status, body) = json_response( - &app, - json_post( - "/read", - &json!({ - "query_source": format!("set merge_lineage = off;\n{FIND_PERSON_GQ}"), - "params": {"name": "Alice"} - }), - ), - ) - .await; - assert_settings_refusal(status, &body, SETTINGS_AT_DEPRECATED_ROUTE); - - let (status, body) = json_response( - &app, - json_post( - "/change", - &json!({ - "query": format!("set merge_lineage = off;\n{MUTATION_QUERIES}"), - "name": "insert_person", - "params": {"name": "Dep", "age": 2} - }), - ), - ) - .await; - assert_settings_refusal(status, &body, SETTINGS_AT_DEPRECATED_ROUTE); } #[tokio::test(flavor = "multi_thread")] @@ -3247,7 +3099,7 @@ async fn repeated_read_after_change_sees_updated_state_from_same_app() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&change).unwrap())) @@ -3257,9 +3109,9 @@ async fn repeated_read_after_change_sees_updated_state_from_same_app() { assert_eq!(change_status, StatusCode::OK); assert_eq!(change_body["affected_nodes"], 1); - let read = ReadRequest { - query_source: fs::read_to_string(fixture("test.gq")).unwrap(), - query_name: Some("get_person".to_string()), + let read = QueryRequest { + query: fs::read_to_string(fixture("test.gq")).unwrap(), + name: Some("get_person".to_string()), params: Some(json!({ "name": "Mina" })), branch: Some("main".to_string()), snapshot: None, @@ -3269,7 +3121,7 @@ async fn repeated_read_after_change_sees_updated_state_from_same_app() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&read).unwrap())) @@ -3561,7 +3413,7 @@ async fn empty_source_is_refused_as_no_query_on_both_doors() { #[tokio::test(flavor = "multi_thread")] async fn mutate_endpoint_runs_inline_mutation() { // Canonical mutation endpoint. Pairs with `/query` on the read side. - // Same wire shape as `/change`, no deprecation signal. + // Mutation receipts use the canonical route. let (_temp, app) = app_for_loaded_graph().await; let request = json!({ @@ -3621,54 +3473,10 @@ async fn mutate_endpoint_runs_inline_mutation() { assert!(no_op["commit"].is_null()); } -#[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: ; - // 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; - - let request = json!({ - "query": MUTATION_QUERIES, - "name": "insert_person", - "params": { "name": "Legacyer", "age": 33 }, - "branch": "main", - }); - let response = app - .clone() - .oneshot( - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) - .method(Method::POST) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&request).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get("deprecation") - .and_then(|v| v.to_str().ok()), - Some("true"), - "POST /change must advertise `Deprecation: true` (RFC 9745)" - ); - assert_eq!( - response.headers().get("link").and_then(|v| v.to_str().ok()), - Some("; rel=\"successor-version\""), - "POST /change must point at /mutate via `Link` rel=successor-version (RFC 8288)" - ); -} - #[tokio::test(flavor = "multi_thread")] async fn load_endpoint_loads_into_existing_branch() { // Canonical bulk-load endpoint (RFC-009 Phase 5). Same wire shape as - // /ingest, no deprecation signal. + // /load, no deprecation signal. let (_temp, app) = app_for_loaded_graph().await; let request = IngestRequest { branch: Some("main".to_string()), @@ -3778,7 +3586,7 @@ node Plain { slug: String @key } vec![1.0, 0.0] ); assert_eq!(provider_requests.load(Ordering::SeqCst), 1); - for path in ["/load", "/load/ndjson", "/ingest"] { + for path in ["/load", "/load/ndjson"] { let temp = init_graph_with_schema(SCHEMA).await; let graph = graph_path(temp.path()); let db = Omnigraph::open(graph.to_str().unwrap()) @@ -4147,102 +3955,6 @@ async fn raw_graph_batch_policy_refusal_does_not_poll_body() { ); } -#[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: ; - // 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; - let request = IngestRequest { - branch: Some("main".to_string()), - from: None, - mode: Some(LoadMode::Merge), - data: r#"{"type":"Person","data":{"name":"Legacyer","age":33}}"#.to_string(), - }; - let response = app - .clone() - .oneshot( - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) - .method(Method::POST) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&request).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get("deprecation") - .and_then(|v| v.to_str().ok()), - Some("true"), - "POST /ingest must advertise `Deprecation: true` (RFC 9745)" - ); - assert_eq!( - response.headers().get("link").and_then(|v| v.to_str().ok()), - Some("; rel=\"successor-version\""), - "POST /ingest must point at /load via `Link` rel=successor-version (RFC 8288)" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn read_endpoint_emits_deprecation_headers() { - let (_temp, app) = app_for_loaded_graph().await; - - let request = ReadRequest { - query_source: fs::read_to_string(fixture("test.gq")).unwrap(), - query_name: Some("get_person".to_string()), - params: Some(json!({ "name": "Alice" })), - branch: Some("main".to_string()), - snapshot: None, - settings: None, - }; - let response = app - .clone() - .oneshot( - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) - .method(Method::POST) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&request).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get("deprecation") - .and_then(|v| v.to_str().ok()), - Some("true"), - "POST /read must advertise `Deprecation: true` (RFC 9745)" - ); - assert_eq!( - response.headers().get("link").and_then(|v| v.to_str().ok()), - Some("; rel=\"successor-version\""), - "POST /read must point at /query via `Link` rel=successor-version (RFC 8288)" - ); - let body_bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - assert_eq!( - body_bytes.as_ref(), - br#"{"query_name":"get_person","target":{"branch":"main","snapshot":null},"row_count":1,"columns":["p.name","p.age"],"rows":[{"p.name":"Alice","p.age":30}]}"#, - "POST /read's envelope is an indefinite compatibility contract (this fixture has no cell whose spelling RFC 0051 changes)" - ); - let body: Value = serde_json::from_slice(&body_bytes).unwrap(); - assert!( - body.get("graph_commit_id").is_none(), - "POST /read has an indefinite byte-stable envelope and must not gain the canonical route's graph_commit_id: {body}" - ); -} - #[tokio::test(flavor = "multi_thread")] async fn query_endpoint_does_not_emit_deprecation_headers() { // Sanity check the inverse: the canonical `/query` endpoint must not @@ -4324,54 +4036,6 @@ async fn query_rows_omit_null_cells() { ); } -#[tokio::test(flavor = "multi_thread")] -async fn change_endpoint_accepts_legacy_field_names() { - // The canonical wire field names on /change are `query` and `name`, but - // serde aliases keep the legacy `query_source`/`query_name` payload - // shape working for clients that haven't migrated yet. Pin both shapes. - let (_temp, app) = app_for_loaded_graph().await; - - let legacy_body = json!({ - "query_source": MUTATION_QUERIES, - "query_name": "insert_person", - "params": { "name": "Legacy", "age": 21 }, - "branch": "main", - }); - let (status, body) = json_response( - &app, - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) - .method(Method::POST) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&legacy_body).unwrap())) - .unwrap(), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["affected_nodes"], 1); - - let canonical_body = json!({ - "query": MUTATION_QUERIES, - "name": "insert_person", - "params": { "name": "Canonical", "age": 22 }, - "branch": "main", - }); - let (status, body) = json_response( - &app, - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) - .method(Method::POST) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&canonical_body).unwrap())) - .unwrap(), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["affected_nodes"], 1); -} - #[tokio::test(flavor = "multi_thread")] async fn remote_branch_list_create_merge_flow_works() { let (temp, app) = app_for_loaded_graph().await; @@ -4486,7 +4150,7 @@ async fn remote_branch_list_create_merge_flow_works() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&change).unwrap())) @@ -4497,9 +4161,9 @@ async fn remote_branch_list_create_merge_flow_works() { assert_eq!(change_body["branch"], "feature"); assert_eq!(change_body["affected_nodes"], 1); - let read_main_before = ReadRequest { - query_source: fs::read_to_string(fixture("test.gq")).unwrap(), - query_name: Some("get_person".to_string()), + let read_main_before = QueryRequest { + query: fs::read_to_string(fixture("test.gq")).unwrap(), + name: Some("get_person".to_string()), params: Some(json!({ "name": "Zoe" })), branch: Some("main".to_string()), snapshot: None, @@ -4509,7 +4173,7 @@ async fn remote_branch_list_create_merge_flow_works() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&read_main_before).unwrap())) @@ -4541,9 +4205,9 @@ async fn remote_branch_list_create_merge_flow_works() { assert_eq!(merge_body["target"], "main"); assert_eq!(merge_body["outcome"], "fast_forward"); - let read_main_after = ReadRequest { - query_source: fs::read_to_string(fixture("test.gq")).unwrap(), - query_name: Some("get_person".to_string()), + let read_main_after = QueryRequest { + query: fs::read_to_string(fixture("test.gq")).unwrap(), + name: Some("get_person".to_string()), params: Some(json!({ "name": "Zoe" })), branch: Some("main".to_string()), snapshot: None, @@ -4553,7 +4217,7 @@ async fn remote_branch_list_create_merge_flow_works() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&read_main_after).unwrap())) @@ -4645,7 +4309,7 @@ async fn branch_merge_delete_branch_retires_parent_with_live_child() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&change).unwrap())) @@ -5002,9 +4666,9 @@ query vector_search_string($q: String) { .unwrap(); let app = build_app(state); - let read = ReadRequest { - query_source: EMBED_QUERY.to_string(), - query_name: Some("vector_search_string".to_string()), + let read = QueryRequest { + query: EMBED_QUERY.to_string(), + name: Some("vector_search_string".to_string()), params: Some(json!({ "q": "alpha" })), branch: Some("main".to_string()), snapshot: None, @@ -5014,7 +4678,7 @@ query vector_search_string($q: String) { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&read).unwrap())) @@ -5065,7 +4729,7 @@ async fn change_long_lived_handle_refreshes_before_preparing_write() { &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from( @@ -5094,7 +4758,7 @@ async fn change_concurrent_inserts_same_key_serialize_without_409() { // token changed discards its complete attempt and reprepares from the // winner's committed branch state. // - // This test spawns N concurrent /change inserts on a single + // This test spawns N concurrent /mutate inserts on a single // node type and asserts: every request returns 200 (no 409), // and the final row count equals the seed count + N (every // staged batch actually committed). @@ -5123,7 +4787,7 @@ async fn change_concurrent_inserts_same_key_serialize_without_409() { .unwrap(); let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -5383,7 +5047,7 @@ async fn change_concurrent_updates_same_key_return_typed_pre_effect_conflicts() .unwrap(); let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -5474,7 +5138,7 @@ async fn change_disjoint_table_concurrency_succeeds_under_branch_occ_gate() { // publisher conflict. // // Setup: test.jsonl seeds 4 Persons + 2 Companies. Spawn N=4 concurrent - // /change inserts on `node:Person` and N=4 concurrent inserts on + // /mutate inserts on `node:Person` and N=4 concurrent inserts on // `node:Company`. All 8 must return 200, and the post-test row counts // must reflect every insert. const PERSON_QUERY: &str = r#" @@ -5512,7 +5176,7 @@ query insert_c($name: String) { .unwrap(); let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -5531,7 +5195,7 @@ query insert_c($name: String) { .unwrap(); let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -5552,7 +5216,7 @@ query insert_c($name: String) { .collect(); assert!( bad.is_empty(), - "expected every disjoint /change insert to return 200, got non-200 for: {:?}", + "expected every disjoint /mutate insert to return 200, got non-200 for: {:?}", bad, ); @@ -5594,11 +5258,11 @@ query insert_c($name: String) { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn ingest_per_actor_admission_cap_returns_429() { - // Pin the admission gate on `/ingest`. With per-actor in-flight cap of 1 + // Pin the admission gate on `/load`. With per-actor in-flight cap of 1 // and 8 concurrent requests from the same actor, at least one request // must be rejected with HTTP 429 and `code: too_many_requests`. // - // Pre-fix bug class: the admission pattern at `server_change` + // Pre-fix bug class: the admission pattern at `server_mutate` // (`crates/omnigraph-server/src/lib.rs:932`) was the only handler // that called `WorkloadController::try_admit`. A heavy actor sending // bulk-ingest traffic would exhaust shared engine capacity (Lance I/O @@ -5606,8 +5270,8 @@ async fn ingest_per_actor_admission_cap_returns_429() { // Pinned at the HTTP boundary so future refactors that drop the // try_admit call from a mutating handler turn this red. // - // Post-fix invariant: `/ingest`, `/branches/create`, `/branches/delete`, - // `/branches/merge`, and `/schema/apply` all gate on + // Post-fix invariant: `/load`, `/branches/create`, `/branches/delete`, + // and `/branches/merge` all gate on // `state.workload.try_admit(&actor_arc, est_bytes)` after Cedar // authorization and before the engine call. Cap exhaustion surfaces as // 429 with `code: too_many_requests`. @@ -5629,7 +5293,7 @@ async fn ingest_per_actor_admission_cap_returns_429() { 1_000_000_000, // per-actor byte budget — large so it never bottlenecks ); // MR-723: install a permit-all policy alongside the bearer token so - // /ingest (action=Change) passes Cedar evaluation. The test is + // /load (action=Change) passes Cedar evaluation. The test is // exercising the admission cap, not policy — the policy is just // enough to clear the State 3 path so the test reaches workload. let policy_path = temp.path().join("policy.yaml"); @@ -5671,7 +5335,7 @@ async fn ingest_per_actor_admission_cap_returns_429() { .unwrap(); let req = Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/ingest")) + .uri(g("/load")) .method(Method::POST) .header("authorization", "Bearer flooder-token") .header("content-type", "application/json") @@ -5699,7 +5363,7 @@ async fn ingest_per_actor_admission_cap_returns_429() { .collect(); assert!( !too_many.is_empty(), - "expected at least one /ingest under cap=1 to return 429; got statuses: {:?}", + "expected at least one /load under cap=1 to return 429; got statuses: {:?}", statuses, ); @@ -6711,3 +6375,33 @@ async fn change_responses_carry_no_storage_vocabulary() { serde_json::from_str(text.lines().rfind(|line| !line.is_empty()).unwrap()).unwrap(); assert_clean(&terminal, "baseline terminal record"); } + +#[tokio::test(flavor = "multi_thread")] +async fn removed_routes_and_legacy_query_fields_refuse_without_graph_effects() { + let (_temp, app) = app_for_loaded_graph().await; + let before = main_head_commit_id(&app).await; + for path in ["/read", "/change", "/ingest"] { + let response = app + .clone() + .oneshot(json_post( + path, + &json!({ + "query": "mutation add() { insert Person { name: \"Unexpected\", age: 1 } }", + "data": "{\"type\":\"Person\",\"data\":{\"name\":\"Unexpected\",\"age\":1}}" + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + } + for path in ["/query", "/mutate"] { + for body in [ + json!({"query_source": FIND_PERSON_GQ}), + json!({"query": FIND_PERSON_GQ, "query_name": "find_person"}), + ] { + let response = app.clone().oneshot(json_post(path, &body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{path}: {body}"); + } + } + assert_eq!(main_head_commit_id(&app).await, before); +} diff --git a/crates/omnigraph-server/tests/multi_graph.rs b/crates/omnigraph-server/tests/multi_graph.rs index f1e031d89..2acb178c4 100644 --- a/crates/omnigraph-server/tests/multi_graph.rs +++ b/crates/omnigraph-server/tests/multi_graph.rs @@ -8,7 +8,7 @@ use axum::body::{Body, to_bytes}; use axum::http::{Method, Request, StatusCode}; use omnigraph::db::Omnigraph; use omnigraph::loader::LoadMode; -use omnigraph_server::api::{ErrorOutput, ExportRequest, ReadRequest}; +use omnigraph_server::api::{ErrorOutput, ExportRequest, QueryRequest}; use omnigraph_server::{AppState, build_app}; use serde_json::Value; use serial_test::serial; @@ -281,7 +281,7 @@ async fn concurrent_branch_ops_morphological_matrix() { } // Cell i: BranchDelete × Change, on a different branch. Delete one - // branch while a /change runs on main. Both should succeed. + // branch while a /mutate runs on main. Both should succeed. { let cell = "i:branch_delete×change:distinct-branch"; let h = matrix::Harness::new().await; @@ -827,9 +827,9 @@ graphs: .unwrap(); let app = build_app(state); - let read = ReadRequest { - query_source: EMBED_QUERY.to_string(), - query_name: Some("vector_search_string".to_string()), + let read = QueryRequest { + query: EMBED_QUERY.to_string(), + name: Some("vector_search_string".to_string()), params: Some(serde_json::json!({ "q": "alpha" })), branch: Some("main".to_string()), snapshot: None, @@ -839,7 +839,7 @@ graphs: &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri("/graphs/knowledge/read") + .uri("/graphs/knowledge/query") .method(Method::POST) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&read).unwrap())) diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index f39662c23..f5be1be9b 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -200,20 +200,16 @@ const EXPECTED_PATHS: &[&str] = &[ "/cluster/deployments/{id}", "/graphs/{graph_id}/snapshot", "/graphs/{graph_id}/blob", - "/graphs/{graph_id}/read", "/graphs/{graph_id}/query", "/graphs/{graph_id}/export", - "/graphs/{graph_id}/change", "/graphs/{graph_id}/mutate", "/graphs/{graph_id}/mutate/if-graph-commit", "/graphs/{graph_id}/queries", "/graphs/{graph_id}/queries/{name}", "/graphs/{graph_id}/queries/{name}/if-graph-commit", "/graphs/{graph_id}/schema", - "/graphs/{graph_id}/schema/apply", "/graphs/{graph_id}/load", "/graphs/{graph_id}/load/ndjson", - "/graphs/{graph_id}/ingest", "/graphs/{graph_id}/branches", "/graphs/{graph_id}/branches/{branch}", "/graphs/{graph_id}/branches/merge", @@ -229,6 +225,9 @@ fn openapi_contains_all_expected_paths() { let doc = openapi_json(); let paths = doc["paths"].as_object().expect("paths must be an object"); let path_keys: HashSet<&str> = paths.keys().map(|k| k.as_str()).collect(); + for retired in ["read", "change", "ingest", "schema/apply"] { + assert!(!path_keys.contains(format!("/graphs/{{graph_id}}/{retired}").as_str())); + } for expected in EXPECTED_PATHS { assert!( @@ -541,12 +540,6 @@ fn openapi_healthz_is_get() { assert!(doc["paths"]["/healthz"]["get"].is_object()); } -#[test] -fn openapi_read_is_post() { - let doc = openapi_json(); - assert!(doc["paths"]["/graphs/{graph_id}/read"]["post"].is_object()); -} - #[test] fn openapi_blob_supports_get_and_explicit_head() { let doc = openapi_json(); @@ -730,12 +723,6 @@ fn export_documents_pre_header_failures() { } } -#[test] -fn openapi_change_is_post() { - let doc = openapi_json(); - assert!(doc["paths"]["/graphs/{graph_id}/change"]["post"].is_object()); -} - #[test] fn openapi_mutate_is_post() { let doc = openapi_json(); @@ -773,32 +760,6 @@ fn openapi_conditional_mutation_routes_are_post() { } } -// Deprecation flagging — `/read` and `/change` are kept indefinitely for -// back-compat but are flagged so OpenAPI codegens (typescript-fetch, -// openapi-generator, oapi-codegen, etc.) emit @deprecated on the generated -// SDK methods. The canonical successors `/query` and `/mutate` are not -// flagged. See `deprecation_headers` in `omnigraph-server/src/lib.rs` for -// the matching runtime signal (RFC 9745 + RFC 8288 headers). -#[test] -fn openapi_read_is_deprecated() { - let doc = openapi_json(); - assert_eq!( - doc["paths"]["/graphs/{graph_id}/read"]["post"]["deprecated"], - serde_json::Value::Bool(true), - "/read must be flagged deprecated in OpenAPI; use /query instead" - ); -} - -#[test] -fn openapi_change_is_deprecated() { - let doc = openapi_json(); - assert_eq!( - doc["paths"]["/graphs/{graph_id}/change"]["post"]["deprecated"], - serde_json::Value::Bool(true), - "/change must be flagged deprecated in OpenAPI; use /mutate instead" - ); -} - #[test] fn openapi_query_is_not_deprecated() { let doc = openapi_json(); @@ -825,12 +786,6 @@ fn openapi_mutate_is_not_deprecated() { ); } -#[test] -fn openapi_ingest_is_post() { - let doc = openapi_json(); - assert!(doc["paths"]["/graphs/{graph_id}/ingest"]["post"].is_object()); -} - #[test] fn openapi_load_is_not_deprecated() { // RFC-009 Phase 5: /load is the canonical bulk-load endpoint. @@ -883,17 +838,6 @@ fn openapi_raw_graph_batch_has_ndjson_body_and_logical_result() { assert!(declaration_props.contains_key("entities_loaded")); } -#[test] -fn openapi_ingest_is_deprecated() { - // RFC-009 Phase 5: /ingest is now the deprecated alias of /load. - let doc = openapi_json(); - assert_eq!( - doc["paths"]["/graphs/{graph_id}/ingest"]["post"]["deprecated"], - serde_json::Value::Bool(true), - "/ingest must be flagged deprecated now that /load is canonical" - ); -} - #[test] fn openapi_branches_supports_get_and_post() { let doc = openapi_json(); @@ -1006,15 +950,12 @@ const EXPECTED_SCHEMAS: &[&str] = &[ "MergeConflictKindOutput", "MergeConflictOutput", "ReadOutput", - "ReadRequest", "ReadSetConflictOutput", "ReadTargetOutput", "PreconditionFailureOutput", "RecoveryRequiredOutput", "ResourceLimitOutput", "PublishedDatasetVersionConflictOutput", - "SchemaApplyOutput", - "SchemaApplyRequest", "SnapshotDatasetOutput", "SnapshotOutput", ]; @@ -1040,6 +981,10 @@ fn openapi_omits_retired_vocabulary_components() { let doc = openapi_json(); let schemas = doc["components"]["schemas"].as_object().unwrap(); for retired in [ + "ReadRequest", + "LegacyReadOutput", + "SchemaApplyRequest", + "SchemaApplyOutput", "IngestTableOutput", "ChangeEntityKind", "ManifestConflictOutput", @@ -1067,31 +1012,6 @@ fn health_output_schema_has_expected_fields() { assert!(props.contains_key("source_version")); } -#[test] -fn read_request_schema_has_expected_fields() { - let doc = openapi_json(); - let schema = &doc["components"]["schemas"]["ReadRequest"]; - let props = schema["properties"].as_object().unwrap(); - assert!(props.contains_key("query_source")); - assert!(props.contains_key("query_name")); - assert!(props.contains_key("params")); - assert!(props.contains_key("branch")); - assert!(props.contains_key("snapshot")); -} - -#[test] -fn read_request_query_source_is_required() { - let doc = openapi_json(); - let schema = &doc["components"]["schemas"]["ReadRequest"]; - let required: Vec<&str> = schema["required"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert!(required.contains(&"query_source")); -} - #[test] fn read_output_schema_has_expected_fields() { let doc = openapi_json(); @@ -1105,9 +1025,6 @@ fn read_output_schema_has_expected_fields() { #[test] fn change_request_schema_has_expected_fields() { - // Canonical field names on the wire are now `query` and `name`. The - // schema descriptions document `query_source` and `query_name` as - // legacy deserialization aliases for backward compatibility. let doc = openapi_json(); let schema = &doc["components"]["schemas"]["ChangeRequest"]; let props = schema["properties"].as_object().unwrap(); @@ -1115,13 +1032,8 @@ fn change_request_schema_has_expected_fields() { assert!(props.contains_key("name")); assert!(props.contains_key("params")); assert!(props.contains_key("branch")); - let query_desc = schema["properties"]["query"]["description"] - .as_str() - .unwrap_or_default(); - assert!( - query_desc.contains("query_source"), - "expected `query` description to mention the legacy `query_source` alias, got: {query_desc}" - ); + assert!(!props.contains_key("query_source")); + assert!(!props.contains_key("query_name")); } #[test] @@ -1413,7 +1325,6 @@ fn error_output_schema_has_expected_fields() { ); for path in [ "/graphs/{graph_id}/query", - "/graphs/{graph_id}/read", "/graphs/{graph_id}/queries/{name}", ] { let response = &doc["paths"][path]["post"]["responses"]["409"]; @@ -1534,15 +1445,6 @@ fn commit_output_schema_has_expected_fields() { assert!(props.contains_key("created_at")); } -#[test] -fn schema_apply_output_uses_graph_manifest_version() { - let doc = openapi_json(); - let schema = &doc["components"]["schemas"]["SchemaApplyOutput"]; - let props = schema["properties"].as_object().unwrap(); - assert!(props.contains_key("graph_manifest_version")); - assert!(!props.contains_key("manifest_version")); -} - #[test] fn snapshot_output_schema_has_expected_fields() { let doc = openapi_json(); @@ -1655,14 +1557,12 @@ fn external_blob_source_error_is_structured_and_declared_on_write_routes() { assert_eq!(output_ref, "#/components/schemas/ExternalBlobSourceOutput"); for path in [ - "/graphs/{graph_id}/change", "/graphs/{graph_id}/mutate", "/graphs/{graph_id}/mutate/if-graph-commit", "/graphs/{graph_id}/queries/{name}", "/graphs/{graph_id}/queries/{name}/if-graph-commit", "/graphs/{graph_id}/load", "/graphs/{graph_id}/load/ndjson", - "/graphs/{graph_id}/ingest", "/graphs/{graph_id}/branches/merge", ] { assert_eq!( @@ -1743,18 +1643,14 @@ fn openapi_defines_bearer_token_security_scheme() { fn protected_endpoints_reference_bearer_token_security() { let doc = openapi_json(); let protected_paths = [ - ("/graphs/{graph_id}/read", "post"), ("/graphs/{graph_id}/blob", "get"), ("/graphs/{graph_id}/blob", "head"), - ("/graphs/{graph_id}/change", "post"), - ("/graphs/{graph_id}/schema/apply", "post"), ("/graphs/{graph_id}/queries", "get"), ("/graphs/{graph_id}/queries/{name}", "post"), ("/graphs/{graph_id}/mutate/if-graph-commit", "post"), ("/graphs/{graph_id}/queries/{name}/if-graph-commit", "post"), ("/graphs/{graph_id}/load", "post"), ("/graphs/{graph_id}/load/ndjson", "post"), - ("/graphs/{graph_id}/ingest", "post"), ("/graphs/{graph_id}/export", "post"), ("/graphs/{graph_id}/snapshot", "get"), ("/graphs/{graph_id}/branches", "get"), @@ -1880,38 +1776,14 @@ fn openapi_operations_have_tags() { // --------------------------------------------------------------------------- #[test] -fn read_endpoint_200_references_legacy_read_output_schema() { - let doc = openapi_json(); - let content = &doc["paths"]["/graphs/{graph_id}/read"]["post"]["responses"]["200"]["content"]; - let schema = &content["application/json"]["schema"]; - let ref_path = schema["$ref"].as_str().unwrap(); - assert!( - ref_path.contains("LegacyReadOutput"), - "POST /read 200 should reference LegacyReadOutput, got {ref_path}" - ); -} - -#[test] -fn legacy_read_output_schema_cannot_carry_graph_commit_id() { - let doc = openapi_json(); - let schema = &doc["components"]["schemas"]["LegacyReadOutput"]; - let props = schema["properties"].as_object().unwrap(); - assert!(props.contains_key("query_name")); - assert!(props.contains_key("target")); - assert!(props.contains_key("row_count")); - assert!(props.contains_key("rows")); - assert!(!props.contains_key("graph_commit_id")); -} - -#[test] -fn change_endpoint_200_references_change_output_schema() { +fn mutate_endpoint_200_references_change_output_schema() { let doc = openapi_json(); - let content = &doc["paths"]["/graphs/{graph_id}/change"]["post"]["responses"]["200"]["content"]; + let content = &doc["paths"]["/graphs/{graph_id}/mutate"]["post"]["responses"]["200"]["content"]; let schema = &content["application/json"]["schema"]; let ref_path = schema["$ref"].as_str().unwrap(); assert!( ref_path.contains("ChangeOutput"), - "POST /change 200 should reference ChangeOutput, got {ref_path}" + "POST /mutate 200 should reference ChangeOutput, got {ref_path}" ); } @@ -1931,10 +1803,10 @@ fn healthz_200_references_health_output_schema() { fn error_responses_reference_error_output_schema() { let doc = openapi_json(); let paths_with_errors = [ - ("/graphs/{graph_id}/read", "post", "400"), - ("/graphs/{graph_id}/read", "post", "401"), - ("/graphs/{graph_id}/change", "post", "400"), - ("/graphs/{graph_id}/change", "post", "409"), + ("/graphs/{graph_id}/query", "post", "400"), + ("/graphs/{graph_id}/query", "post", "401"), + ("/graphs/{graph_id}/mutate", "post", "400"), + ("/graphs/{graph_id}/mutate", "post", "409"), ("/graphs/{graph_id}/branches", "post", "409"), ]; @@ -1953,14 +1825,12 @@ fn error_responses_reference_error_output_schema() { fn recovery_barrier_write_endpoints_document_recovery_required() { let doc = openapi_json(); for (path, method) in [ - ("/graphs/{graph_id}/change", "post"), ("/graphs/{graph_id}/mutate", "post"), ("/graphs/{graph_id}/mutate/if-graph-commit", "post"), ("/graphs/{graph_id}/queries/{name}", "post"), ("/graphs/{graph_id}/queries/{name}/if-graph-commit", "post"), ("/graphs/{graph_id}/load", "post"), ("/graphs/{graph_id}/load/ndjson", "post"), - ("/graphs/{graph_id}/ingest", "post"), ("/graphs/{graph_id}/branches", "post"), ("/graphs/{graph_id}/branches/{branch}", "delete"), ("/graphs/{graph_id}/branches/merge", "post"), @@ -1982,14 +1852,12 @@ fn recovery_barrier_write_endpoints_document_recovery_required() { fn bounded_keyed_write_endpoints_document_resource_limit() { let doc = openapi_json(); for (path, method) in [ - ("/graphs/{graph_id}/change", "post"), ("/graphs/{graph_id}/mutate", "post"), ("/graphs/{graph_id}/mutate/if-graph-commit", "post"), ("/graphs/{graph_id}/queries/{name}", "post"), ("/graphs/{graph_id}/queries/{name}/if-graph-commit", "post"), ("/graphs/{graph_id}/load", "post"), ("/graphs/{graph_id}/load/ndjson", "post"), - ("/graphs/{graph_id}/ingest", "post"), ("/graphs/{graph_id}/branches/merge", "post"), ] { let response = &doc["paths"][path][method]["responses"]["413"]; @@ -2013,10 +1881,9 @@ fn bounded_keyed_write_endpoints_document_resource_limit() { fn post_endpoints_have_request_body() { let doc = openapi_json(); let post_paths = [ - ("/graphs/{graph_id}/read", "ReadRequest"), - ("/graphs/{graph_id}/change", "ChangeRequest"), - ("/graphs/{graph_id}/schema/apply", "SchemaApplyRequest"), - ("/graphs/{graph_id}/ingest", "IngestRequest"), + ("/graphs/{graph_id}/query", "QueryRequest"), + ("/graphs/{graph_id}/mutate", "ChangeRequest"), + ("/graphs/{graph_id}/load", "IngestRequest"), ("/graphs/{graph_id}/export", "ExportRequest"), ("/graphs/{graph_id}/branches", "BranchCreateRequest"), ("/graphs/{graph_id}/branches/merge", "BranchMergeRequest"), @@ -2149,10 +2016,8 @@ async fn auth_mode_spec_has_security_on_protected_operations() { // RFC-011 cluster-only: the served spec always nests protected // routes under `/graphs/{graph_id}/...`. let protected_paths = [ - ("/graphs/{graph_id}/read", "post"), ("/graphs/{graph_id}/blob", "get"), ("/graphs/{graph_id}/blob", "head"), - ("/graphs/{graph_id}/change", "post"), ("/graphs/{graph_id}/snapshot", "get"), ("/graphs/{graph_id}/branches", "get"), ("/graphs/{graph_id}/commits", "get"), @@ -2234,16 +2099,12 @@ fn openapi_spec_is_up_to_date() { const EXPECTED_CLUSTER_PATHS: &[&str] = &[ "/graphs/{graph_id}/snapshot", "/graphs/{graph_id}/blob", - "/graphs/{graph_id}/read", "/graphs/{graph_id}/export", - "/graphs/{graph_id}/change", "/graphs/{graph_id}/mutate/if-graph-commit", "/graphs/{graph_id}/schema", "/graphs/{graph_id}/queries/{name}/if-graph-commit", - "/graphs/{graph_id}/schema/apply", "/graphs/{graph_id}/load", "/graphs/{graph_id}/load/ndjson", - "/graphs/{graph_id}/ingest", "/graphs/{graph_id}/branches", "/graphs/{graph_id}/branches/{branch}", "/graphs/{graph_id}/branches/merge", @@ -2318,14 +2179,10 @@ async fn multi_mode_openapi_drops_flat_protected_paths() { let flat_protected = [ "/snapshot", "/blob", - "/read", "/export", - "/change", "/schema", - "/schema/apply", "/load", "/load/ndjson", - "/ingest", "/branches", "/branches/{branch}", "/branches/merge", @@ -2544,20 +2401,16 @@ async fn served_spec_always_nests_under_cluster_prefix() { let flat_protected = [ "/snapshot", "/blob", - "/read", "/query", "/export", - "/change", "/mutate", "/mutate/if-graph-commit", "/queries", "/queries/{name}", "/queries/{name}/if-graph-commit", "/schema", - "/schema/apply", "/load", "/load/ndjson", - "/ingest", "/branches", "/branches/{branch}", "/branches/merge", diff --git a/crates/omnigraph-server/tests/s3.rs b/crates/omnigraph-server/tests/s3.rs index 067b8e5f8..69d0a15a6 100644 --- a/crates/omnigraph-server/tests/s3.rs +++ b/crates/omnigraph-server/tests/s3.rs @@ -9,7 +9,7 @@ use axum::http::{Method, Request, StatusCode}; use omnigraph::db::{Omnigraph, ReadTarget}; use omnigraph::loader::LoadMode; use omnigraph::{BlobCell, BlobContent, EntityKind}; -use omnigraph_server::api::{IngestRequest, ReadRequest}; +use omnigraph_server::api::{IngestRequest, QueryRequest}; use omnigraph_server::{AppState, build_app}; use serde_json::json; @@ -96,9 +96,9 @@ async fn server_opens_object_store_graph_directly_and_serves_snapshot_and_read(u assert_eq!(snapshot_status, StatusCode::OK); assert!(snapshot_body["datasets"].is_array()); - let read = ReadRequest { - query_source: fs::read_to_string(fixture("test.gq")).unwrap(), - query_name: Some("get_person".to_string()), + let read = QueryRequest { + query: fs::read_to_string(fixture("test.gq")).unwrap(), + name: Some("get_person".to_string()), params: Some(json!({ "name": "Alice" })), branch: Some("main".to_string()), snapshot: None, @@ -108,7 +108,7 @@ async fn server_opens_object_store_graph_directly_and_serves_snapshot_and_read(u &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("authorization", "Bearer object-store-token") .header("content-type", "application/json") diff --git a/crates/omnigraph-server/tests/schema_routes.rs b/crates/omnigraph-server/tests/schema_routes.rs index 6e8bce4d3..358d495b9 100644 --- a/crates/omnigraph-server/tests/schema_routes.rs +++ b/crates/omnigraph-server/tests/schema_routes.rs @@ -1,566 +1,58 @@ -//! Schema read/apply routes: migrations over HTTP, drift, gating. -//! Moved verbatim from tests/server.rs in the modularization. +//! Accepted schema reads and refusal of the removed schema-apply route. -use omnigraph_server::api::{HTTP_API_CONTRACT, HTTP_API_CONTRACT_HEADER}; use std::fs; -use std::sync::Arc; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use omnigraph::db::{Omnigraph, ReadTarget}; -use omnigraph::loader::LoadMode; -use omnigraph_server::api::{ErrorOutput, SchemaApplyRequest, SchemaOutput}; -use omnigraph_server::{ - AppState, GraphHandle, GraphId, GraphKey, PolicyEngine, build_app, workload, +use omnigraph_server::api::{ + ErrorOutput, HTTP_API_CONTRACT, HTTP_API_CONTRACT_HEADER, SchemaOutput, }; +use omnigraph_server::{AppState, build_app}; use serde_json::json; +use tower::ServiceExt; mod support; use support::*; #[tokio::test(flavor = "multi_thread")] -async fn schema_apply_refuses_a_wildcard_member_missing_a_used_property_issue_659() { - let source = r#"query neighbor_since() { - match { $p: Person { name: "Alice" } $f: Person $p $e:* $f } - return { $f.name, $e.since } - order { $f.name } - }"#; - let (temp, app) = app_with_stored_queries( - &[("neighbor_since", source, true)], - &[("act-ragnor", "admin-token")], - STORED_QUERY_SCHEMA_APPLY_POLICY_YAML, - ) - .await; - let (status, before) = json_response( - &app, - invoke_request("neighbor_since", "admin-token", json!({})), - ) - .await; - assert_eq!(status, StatusCode::OK, "{before}"); - assert_eq!(before["row_count"], 2); - let desired = format!( - "{}\nedge Likes: Person -> Person\n", - fs::read_to_string(fixture("test.pg")).unwrap() - ); - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: desired, - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{payload}"); - let message = payload["error"].as_str().unwrap_or_default(); - assert!( - message.contains("neighbor_since") - && message.contains("schema check") - && message.contains("since"), - "{payload}" - ); - let reopened = Omnigraph::open(graph_path(temp.path()).to_str().unwrap()) - .await - .unwrap(); - assert!(!reopened.catalog().edge_types.contains_key("Likes")); - let (status, after) = json_response( - &app, - invoke_request("neighbor_since", "admin-token", json!({})), - ) - .await; - assert_eq!(status, StatusCode::OK, "{after}"); - assert_eq!(after["rows"], before["rows"]); -} - -#[tokio::test] -async fn schema_apply_route_updates_graph_for_authorized_admin() { - let (temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - let schema = additive_schema_with_nickname(); - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: schema, - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["applied"], true); +async fn schema_apply_route_is_absent_without_graph_effects() { + let (temp, app) = app_for_loaded_graph().await; let graph = graph_path(temp.path()); - let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - assert!( - reopened.catalog().node_types["Person"] - .properties - .contains_key("nickname") - ); -} - -#[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() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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(), - }) - .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 --server --config "), - "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() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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(), - }) - .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( - &[("find_person", FIND_PERSON_GQ, true)], - &[("act-ragnor", "admin-token")], - STORED_QUERY_SCHEMA_APPLY_POLICY_YAML, - ) - .await; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: renamed_age_schema(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "body: {payload}"); - let message = payload["error"].as_str().unwrap_or_default(); - assert!( - message.contains("find_person") && message.contains("schema check"), - "registry breakage should name the stored query; body: {payload}" - ); - - let reopened = Omnigraph::open(graph_path(temp.path()).to_str().unwrap()) + let db = Omnigraph::open_read_only(graph.to_str().unwrap()) .await .unwrap(); - let person = &reopened.catalog().node_types["Person"]; - assert!(person.properties.contains_key("age")); - assert!(!person.properties.contains_key("years")); - - let (invoke_status, invoke_body) = json_response( - &app, - invoke_request( - "find_person", - "admin-token", - json!({ "params": { "name": "Alice" } }), - ), - ) - .await; - assert_eq!(invoke_status, StatusCode::OK, "body: {invoke_body}"); - assert_eq!(invoke_body["row_count"], 1); -} - -#[tokio::test(flavor = "multi_thread")] -async fn schema_apply_route_noop_keeps_valid_stored_query_registry() { - let (_temp, app) = app_with_stored_queries( - &[("find_person", FIND_PERSON_GQ, true)], - &[("act-ragnor", "admin-token")], - STORED_QUERY_SCHEMA_APPLY_POLICY_YAML, - ) - .await; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: fs::read_to_string(fixture("test.pg")).unwrap(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - assert_eq!(status, StatusCode::OK, "body: {payload}"); - assert_eq!(payload["applied"], false); -} - -#[tokio::test] -async fn schema_apply_route_requires_schema_apply_policy_permission() { - let (_temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - POLICY_YAML, - ) - .await; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!( - payload["code"], - serde_json::to_value(omnigraph_server::api::ErrorCode::Forbidden).unwrap() - ); -} - -#[tokio::test] -async fn schema_apply_route_requires_bearer_token_when_policy_enabled() { - let (_temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!( - payload["code"], - serde_json::to_value(omnigraph_server::api::ErrorCode::Unauthorized).unwrap() - ); -} - -#[tokio::test] -async fn schema_apply_route_can_rename_type() { - let (temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: renamed_person_schema(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["applied"], true); - let graph = graph_path(temp.path()); - let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - let snapshot = reopened - .snapshot_of(ReadTarget::branch("main")) + let before = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); + let source = db.schema_source().to_string(); + let response = app + .oneshot( + Request::builder() + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .method(Method::POST) + .uri(g("/schema/apply")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "schema_source": additive_schema_with_nickname() + })) + .unwrap(), + )) + .unwrap(), + ) .await .unwrap(); - assert!(snapshot.dataset("node:Human").is_some()); - assert!(snapshot.dataset("node:Person").is_none()); -} - -#[tokio::test] -async fn schema_apply_route_can_rename_property() { - let (temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: renamed_age_schema(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["applied"], true); - let graph = graph_path(temp.path()); - let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - let person = &reopened.catalog().node_types["Person"]; - assert!(person.properties.contains_key("years")); - assert!(!person.properties.contains_key("age")); -} - -#[tokio::test] -async fn schema_apply_route_can_add_index() { - let (temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy( - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - let graph = graph_path(temp.path()); - let before_index_count = { - let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); - let dataset = snapshot.open_dataset("node:Person").await.unwrap(); - dataset.load_indices().await.unwrap().len() - }; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace("age: I32?", "age: I32? @index"), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["applied"], true); - // iss-848: the /schema/apply route accepts the index-add and applies it as a - // metadata change — it records the `@index` intent in the catalog/IR but does - // NOT build the physical index inline or spawn a server-only build (the - // build is deferred to explicit ensure_indices/optimize maintenance). - // Pin the detached-work half structurally instead of waiting an arbitrary - // amount of wall-clock time for a task that must not exist. The physical - // assertion below independently covers synchronous/awaited index builds. - let handlers = include_str!("../src/handlers.rs"); - let (_, after_apply_signature) = handlers - .split_once("pub(crate) async fn server_schema_apply") - .expect("server_schema_apply handler must remain present"); - let (apply_handler, _) = after_apply_signature - .split_once("async fn authorize_load_scope") - .expect("schema apply handler must precede load authorization"); - assert!( - !apply_handler.contains("tokio::spawn") && !apply_handler.contains(".ensure_indices("), - "schema apply must not schedule implicit physical index convergence" - ); - - // The physical index count is unchanged by the apply. - let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - let snapshot = reopened - .snapshot_of(ReadTarget::branch("main")) + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let reopened = Omnigraph::open_read_only(graph.to_str().unwrap()) .await .unwrap(); - let dataset = snapshot.open_dataset("node:Person").await.unwrap(); - let after_index_count = dataset.load_indices().await.unwrap().len(); - assert_eq!( - after_index_count, before_index_count, - "schema apply records @index intent but defers the physical build (iss-848)" - ); -} - -#[tokio::test] -async fn schema_apply_route_rejects_unsupported_plan() { - let (_temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: unsupported_schema_change(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(reopened.schema_source().as_str(), source); assert_eq!( - payload["code"], - serde_json::to_value(omnigraph_server::api::ErrorCode::BadRequest).unwrap() - ); -} - -#[tokio::test] -async fn schema_apply_route_rejects_when_non_main_branch_exists() { - let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await; - let graph = graph_path(temp.path()); - let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - db.branch_create("feature").await.unwrap(); - drop(db); - - let policy_path = temp.path().join("policy.yaml"); - fs::write(&policy_path, SCHEMA_APPLY_POLICY_YAML).unwrap(); - let state = AppState::open_with_bearer_tokens_and_policy( - graph.to_string_lossy().to_string(), - vec![("act-ragnor".to_string(), "admin-token".to_string())], - Some(&policy_path), - ) - .await - .unwrap(); - let app = build_app(state); - - let request = Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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(), - }) - .unwrap(), - )) - .unwrap(); - let (status, payload) = json_response(&app, request).await; - - assert_eq!(status, StatusCode::CONFLICT); - assert_eq!( - payload["code"], - serde_json::to_value(omnigraph_server::api::ErrorCode::Conflict).unwrap() + reopened + .snapshot_of(ReadTarget::branch("main")) + .await + .unwrap() + .graph_manifest_version(), + before.graph_manifest_version(), ); } @@ -654,241 +146,3 @@ async fn schema_route_denied_when_actor_lacks_read_permission() { Some(omnigraph_server::api::ErrorCode::Forbidden) ); } - -#[tokio::test(flavor = "multi_thread")] -async fn schema_apply_route_drops_property_via_http() { - let (temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - // Load a row that has the column we're about to drop. - let graph = graph_path(temp.path()); - { - let db = session(Omnigraph::open(graph.to_str().unwrap()).await.unwrap()); - db.load( - "main", - r#"{"type":"Person","data":{"name":"PreDrop","age":42}}"#, - LoadMode::Append, - ) - .await - .unwrap(); - } - let pre_version = manifest_dataset_version(&graph).await; - - let (status, payload) = json_response( - &app, - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: schema_without_age(), - }) - .unwrap(), - )) - .unwrap(), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["applied"], true); - - // Catalog reflects the drop: `age` is gone from the live schema. - let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - assert!( - !reopened.catalog().node_types["Person"] - .properties - .contains_key("age"), - "catalog should not contain `age` after drop" - ); - - // The drop preserves the prior version — `age` is still readable - // via time travel to the pre-drop manifest version. Mirrors the - // SDK-side assertion in `apply_schema_drops_a_nullable_property_and_preserves_prior_version`. - let pre_drop_snapshot = reopened - .snapshot_at_graph_manifest_version(pre_version) - .await - .unwrap(); - let pre_drop_ds = pre_drop_snapshot.open_dataset("node:Person").await.unwrap(); - let pre_drop_fields = pre_drop_ds - .schema() - .fields - .iter() - .map(|f| f.name.clone()) - .collect::>(); - assert!( - pre_drop_fields.iter().any(|f| f == "age"), - "the drop should leave the pre-drop dataset's `age` column \ - time-travel-reachable; got fields {pre_drop_fields:?}" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn schema_apply_route_drops_node_type_via_http() { - let (temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - let graph = graph_path(temp.path()); - - let (status, payload) = json_response( - &app, - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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: schema_without_company(), - }) - .unwrap(), - )) - .unwrap(), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["applied"], true); - - let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - assert!( - !reopened.catalog().node_types.contains_key("Company"), - "catalog should not contain `Company` after drop" - ); - assert!( - !reopened.catalog().edge_types.contains_key("WorksAt"), - "catalog should not contain `WorksAt` after cascade" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn schema_apply_route_ignores_the_removed_allow_data_loss_field() { - // The request no longer has `allow_data_loss`, and it does not deny - // unknown fields: a client still sending it applies the same plan, whose - // drop step carries no mode. - let (temp, app) = app_for_graph_with_auth_tokens_and_policy( - &fs::read_to_string(fixture("test.pg")).unwrap(), - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - let graph = graph_path(temp.path()); - - let (status, payload) = json_response( - &app, - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .method(Method::POST) - .uri(g("/schema/apply")) - .header("content-type", "application/json") - .header("authorization", "Bearer admin-token") - .body(Body::from( - serde_json::to_vec(&json!({ - "schema_source": schema_without_age(), - "allow_data_loss": true, - })) - .unwrap(), - )) - .unwrap(), - ) - .await; - assert_eq!(status, StatusCode::OK, "{payload}"); - assert_eq!(payload["applied"], true); - let steps = payload["steps"].as_array().expect("steps array"); - let drop_step = steps - .iter() - .find(|s| s["kind"] == "drop_property") - .expect("plan should include drop_property step"); - assert_eq!( - drop_step, - &json!({ - "kind": "drop_property", - "type_kind": "node", - "type_name": "Person", - "property_name": "age", - }) - ); - let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - assert!( - !reopened.catalog().node_types["Person"] - .properties - .contains_key("age"), - "catalog should not contain `age` after the drop" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn schema_apply_route_additive_property_preserves_existing_rows() { - // SDK suite covers rename and drop data preservation. Additive - // AddProperty wasn't pinned with a row-count check anywhere. - // Load N rows, apply schema adding nullable property, verify - // every row is still readable and the new column is null. - let (temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy( - &[("act-ragnor", "admin-token")], - SCHEMA_APPLY_POLICY_YAML, - ) - .await; - let graph = graph_path(temp.path()); - - // Standard fixture data is loaded before the app is built, so the server - // handle applies schema from the same manifest it is serving. - let pre_count = { - let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - let snap = db - .snapshot_of(omnigraph::db::ReadTarget::branch("main")) - .await - .unwrap(); - snap.open_dataset("node:Person") - .await - .expect("Person") - .count_rows(None) - .await - .unwrap() - }; - assert!(pre_count > 0, "fixture should have loaded Person rows"); - - let (status, payload) = json_response( - &app, - Request::builder() - .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .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(), - }) - .unwrap(), - )) - .unwrap(), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["applied"], true); - - // Row count preserved. - let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); - let snap = db - .snapshot_of(omnigraph::db::ReadTarget::branch("main")) - .await - .unwrap(); - let post_count = snap - .open_dataset("node:Person") - .await - .expect("Person") - .count_rows(None) - .await - .unwrap(); - assert_eq!( - post_count, pre_count, - "AddProperty should preserve row count", - ); -} diff --git a/crates/omnigraph-server/tests/stored_queries.rs b/crates/omnigraph-server/tests/stored_queries.rs index b86f40dd9..ec4863179 100644 --- a/crates/omnigraph-server/tests/stored_queries.rs +++ b/crates/omnigraph-server/tests/stored_queries.rs @@ -48,15 +48,17 @@ async fn stored_wildcard_keeps_both_authorization_gates_issue_659() { } #[tokio::test] -async fn signed_stored_invocation_requires_both_outer_and_inner_grants() { +async fn signed_stored_invocation_requires_both_outer_and_inner_policy_permissions() { let tokens = data_tokens::DataTokens::new(); let temp = init_loaded_graph().await; let graph = graph_path(temp.path()); let policy_path = temp.path().join("policy.yaml"); - let policy = format!( - "{}\n - id: invoke\n allow:\n actors: {{group: permitted}}\n actions: [invoke_query]\n", - permit_all_policy_yaml(&[&tokens.actor]) - ); + let invoker = "01M00000000000000000000005"; + let writer = "01M00000000000000000000006"; + let policy = INVOKE_POLICY_YAML + .replace("act-noinvoke", &tokens.actor) + .replace("\"act-invoke\"", &format!("\"principal:{invoker}\"")) + .replace("act-full", &format!("principal:{writer}")); std::fs::write(&policy_path, policy).unwrap(); let registry = stored_query_registry(&[ ( @@ -80,14 +82,23 @@ async fn signed_stored_invocation_requires_both_outer_and_inner_grants() { .unwrap() .with_data_token_trust(tokens.trust.clone()); let app = omnigraph_server::build_app(state); - let read = tokens.token(json!([{"graph_id":"default","actions":["read"]}])); + let retired = + tokens.token(json!([{"graph_id":"default","actions":["invoke_query","change","read"]}])); + let (status, _) = + json_response(&app, invoke_request("signed_insert", &retired, json!({}))).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "retired grants cannot authorize invocation" + ); + let read = tokens.identity_token(); let (status, _) = json_response(&app, invoke_request("signed_read", &read, json!({}))).await; assert_eq!( status, StatusCode::NOT_FOUND, "missing invoke grant must hide the query" ); - let invoke = tokens.token(json!([{"graph_id":"default","actions":["invoke_query","read"]}])); + let invoke = tokens.identity_token_for(invoker); let (status, _) = json_response(&app, invoke_request("signed_read", &invoke, json!({}))).await; assert_eq!(status, StatusCode::OK); let (_, before) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; @@ -104,11 +115,10 @@ async fn signed_stored_invocation_requires_both_outer_and_inner_grants() { ); let (_, after) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; assert_eq!(after, before); - let write = - tokens.token(json!([{"graph_id":"default","actions":["invoke_query","change","read"]}])); + let write = tokens.identity_token_for(writer); let (status, body) = json_response(&app, invoke_request("signed_insert", &write, params)).await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["actor_id"], tokens.actor); + assert_eq!(body["actor_id"], format!("principal:{writer}")); } async fn assert_receipt_commit_matches_get(app: &axum::Router, output: &Value, token: &str) { diff --git a/crates/omnigraph-server/tests/support/data_tokens.rs b/crates/omnigraph-server/tests/support/data_tokens.rs index 20a4456c0..62adf3d3c 100644 --- a/crates/omnigraph-server/tests/support/data_tokens.rs +++ b/crates/omnigraph-server/tests/support/data_tokens.rs @@ -37,15 +37,22 @@ impl DataTokens { } pub fn token(&self, grants: Value) -> String { - self.signed(Some(grants)) + self.signed(Some(grants), None) } pub fn identity_token(&self) -> String { - self.signed(None) + self.signed(None, None) } - fn signed(&self, grants: Option) -> String { + pub fn identity_token_for(&self, subject: &str) -> String { + self.signed(None, Some(subject)) + } + + fn signed(&self, grants: Option, subject: Option<&str>) -> String { let mut claims = self.fixture["claims"].clone(); + if let Some(subject) = subject { + claims["sub"] = json!(subject); + } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() diff --git a/crates/omnigraph-server/tests/support/mod.rs b/crates/omnigraph-server/tests/support/mod.rs index b81020e21..db3416ba6 100644 --- a/crates/omnigraph-server/tests/support/mod.rs +++ b/crates/omnigraph-server/tests/support/mod.rs @@ -20,7 +20,7 @@ use omnigraph::error::OmniError; use omnigraph::loader::LoadMode; use omnigraph::settings::SessionSettings; use omnigraph_policy::{PolicyChecker, PolicyEngine}; -use omnigraph_server::api::{BranchCreateRequest, BranchMergeRequest, ChangeRequest, ReadRequest}; +use omnigraph_server::api::{BranchCreateRequest, BranchMergeRequest, ChangeRequest, QueryRequest}; use omnigraph_server::queries::{QueryRegistry, RegistrySpec}; use omnigraph_server::{AppState, ProcessDefaults, build_app}; use serde_json::{Value, json}; @@ -91,19 +91,6 @@ rules: target_branch_scope: unprotected "#; -pub const SCHEMA_APPLY_POLICY_YAML: &str = r#" -version: 1 -groups: - admins: [act-ragnor] -protected_branches: [main] -rules: - - id: admins-schema-apply - allow: - actors: { group: admins } - actions: [schema_apply] - target_branch_scope: protected -"#; - pub fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../omnigraph/tests/fixtures") @@ -230,28 +217,6 @@ rules: branch_scope: any "#; -pub const STORED_QUERY_SCHEMA_APPLY_POLICY_YAML: &str = r#" -version: 1 -groups: - admins: [act-ragnor] -protected_branches: [main] -rules: - - id: admins-can-invoke - allow: - actors: { group: admins } - actions: [invoke_query] - - id: admins-can-read - allow: - actors: { group: admins } - actions: [read] - branch_scope: any - - id: admins-can-schema-apply - allow: - actors: { group: admins } - actions: [schema_apply] - target_branch_scope: protected -"#; - pub const FIND_PERSON_GQ: &str = "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }"; @@ -721,7 +686,7 @@ pub mod matrix { .oneshot( Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -790,9 +755,9 @@ pub mod matrix { /// `get_person` query from `test.gq` for identity rather than /// just count. pub async fn person_exists(&self, branch: &str, name: &str) -> bool { - let body = serde_json::to_vec(&ReadRequest { - query_source: include_str!("../../../omnigraph/tests/fixtures/test.gq").to_string(), - query_name: Some("get_person".to_string()), + let body = serde_json::to_vec(&QueryRequest { + query: include_str!("../../../omnigraph/tests/fixtures/test.gq").to_string(), + name: Some("get_person".to_string()), params: Some(json!({ "name": name })), branch: Some(branch.to_string()), snapshot: None, @@ -805,7 +770,7 @@ pub mod matrix { .oneshot( Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/read")) + .uri(g("/query")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -858,7 +823,7 @@ pub mod matrix { /// C6: insert a uniquely-named sentinel on main and verify it /// landed. Catches engine-state poisoning where a cell's /// concurrent ops left the engine half-broken — subsequent - /// /change either deadlocks or returns a non-200. + /// /mutate either deadlocks or returns a non-200. pub async fn assert_post_op_sentinel(&self, cell: &str, sentinel: &str) { let body = serde_json::to_vec(&ChangeRequest { query: MUTATION_QUERIES.to_string(), @@ -874,7 +839,7 @@ pub mod matrix { .oneshot( Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -885,7 +850,7 @@ pub mod matrix { assert_eq!( r.status(), StatusCode::OK, - "[{}] post-op sentinel /change on main failed (engine poisoned?)", + "[{}] post-op sentinel /mutate on main failed (engine poisoned?)", cell ); assert!( @@ -956,7 +921,7 @@ pub mod matrix { .oneshot( Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header("content-type", "application/json") .body(Body::from(body)) @@ -1143,7 +1108,7 @@ pub async fn http_change_decision( &app, Request::builder() .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) - .uri(g("/change")) + .uri(g("/mutate")) .method(Method::POST) .header(AUTHORIZATION, format!("Bearer {token}")) .header("content-type", "application/json") @@ -1217,16 +1182,10 @@ pub async fn http_merge_decision( /// fixture to the server. These tests own every writer of the temporary root. pub async fn apply_cluster_fixture(config_dir: &Path) { let caller = omnigraph_cluster::DeploymentCaller::storage_owner(None); - let captured = omnigraph_cluster::capture_deployment(config_dir, &Default::default()).unwrap(); - let applied = omnigraph_cluster::apply_deployment( - config_dir, - None, - &caller, - &Default::default(), - |_, _, _| {}, - ) - .await - .unwrap(); + let captured = omnigraph_cluster::capture_deployment(config_dir).unwrap(); + let applied = omnigraph_cluster::apply_deployment(config_dir, None, &caller, |_, _, _| {}) + .await + .unwrap(); assert!( matches!(applied, omnigraph_cluster::DeploymentLookup::Complete { ref result } if result.converged), "{applied:?}" diff --git a/crates/omnigraph-storage/src/lib.rs b/crates/omnigraph-storage/src/lib.rs index 0a349317b..dfc8a5958 100644 --- a/crates/omnigraph-storage/src/lib.rs +++ b/crates/omnigraph-storage/src/lib.rs @@ -1127,7 +1127,8 @@ pub trait StorageAdapter: Debug + Send + Sync { /// when nothing exists there (idempotent). Local: `remove_dir_all` /// (directories are a local-FS concept; list+delete would leave empty /// directory skeletons that local existence probes report as present); - /// object stores: list + delete (NOT atomic — callers must tolerate + /// object stores: stream descendants + delete the exact root marker + /// (NOT atomic — callers must tolerate /// partial prefixes on crash, which the cluster delete protocol does by /// retry). async fn delete_prefix(&self, prefix_uri: &str) -> Result<()>; @@ -2176,22 +2177,25 @@ impl StorageAdapter for ObjectStorageAdapter { } let prefix = self.object_path(prefix_uri.trim_end_matches('/'))?; let mut entries = self.store.list(Some(&prefix)); - let mut locations = Vec::new(); + // Consume the backend's paginated stream without retaining the full + // graph inventory. The caller keeps writers excluded throughout purge. while let Some(meta) = entries .try_next() .await .map_err(|err| storage_backend_error("delete_prefix", prefix_uri, err))? { - locations.push(meta.location); - } - for location in locations { - match self.store.delete(&location).await { + match self.store.delete(&meta.location).await { Ok(()) => {} Err(object_store::Error::NotFound { .. }) => {} Err(err) => return Err(storage_backend_error("delete_prefix", prefix_uri, err)), } } - Ok(()) + // ObjectStore::list is segment-scoped and excludes an exact prefix + // object on some backends. Such a marker belongs to this root too. + match self.store.delete(&prefix).await { + Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()), + Err(err) => Err(storage_backend_error("delete_prefix", prefix_uri, err)), + } } } @@ -3881,6 +3885,9 @@ mod tests { adapter.delete(&claim).await.unwrap(); assert!(!adapter.exists(&claim).await.unwrap()); + let sibling = format!("{root}/contract-peer/keep.json"); + adapter.write_text(&sibling, "peer").await.unwrap(); + // delete_prefix: recursive + idempotent; nothing under the prefix // (including local directory skeletons) survives. adapter @@ -3889,10 +3896,15 @@ mod tests { .unwrap(); assert!(!adapter.exists(&a).await.unwrap()); assert!(!adapter.exists(&format!("{root}/contract")).await.unwrap()); + assert_eq!(adapter.read_text(&sibling).await.unwrap(), "peer"); adapter .delete_prefix(&format!("{root}/contract")) .await .unwrap(); + adapter + .delete_prefix(&format!("{root}/contract-peer")) + .await + .unwrap(); } #[tokio::test] @@ -3908,6 +3920,27 @@ mod tests { // strong-CAS path (ETag tokens + PutMode::Update) without a bucket. let adapter = ObjectStorageAdapter::in_memory(); contract_suite(&adapter, "mem-root").await; + // Object stores permit an exact root marker alongside descendants. + // A purge must remove both, without matching a sibling name. + adapter + .write_text("mem-root/graph", "marker") + .await + .unwrap(); + adapter + .write_text("mem-root/graph/nested/data", "data") + .await + .unwrap(); + adapter + .write_text("mem-root/graph-peer/data", "peer") + .await + .unwrap(); + adapter.delete_prefix("mem-root/graph").await.unwrap(); + assert!(!adapter.exists("mem-root/graph").await.unwrap()); + assert_eq!( + adapter.read_text("mem-root/graph-peer/data").await.unwrap(), + "peer" + ); + adapter.delete_prefix("mem-root/graph").await.unwrap(); } #[tokio::test] diff --git a/crates/omnigraph/src/db/omnigraph.rs b/crates/omnigraph/src/db/omnigraph.rs index c399aa910..a43773db1 100644 --- a/crates/omnigraph/src/db/omnigraph.rs +++ b/crates/omnigraph/src/db/omnigraph.rs @@ -1139,6 +1139,18 @@ impl Omnigraph { desired_schema_source: &str, actor: Option<&str>, ) -> Result { + self.prepare_schema_apply_with_plan_as(desired_schema_source, actor) + .await + .map(|(intent, _)| intent) + } + + /// Return the migration preview and exact intent from the same accepted + /// schema capture. The plan is descriptive; execution revalidates the intent. + pub async fn prepare_schema_apply_with_plan_as( + &self, + desired_schema_source: &str, + actor: Option<&str>, + ) -> Result<(PreparedSchemaApply, SchemaMigrationPlan)> { schema_apply::prepare_schema_apply(self, desired_schema_source, actor).await } diff --git a/crates/omnigraph/src/db/omnigraph/schema_apply/prepared.rs b/crates/omnigraph/src/db/omnigraph/schema_apply/prepared.rs index e2c6ddf7e..563bd4617 100644 --- a/crates/omnigraph/src/db/omnigraph/schema_apply/prepared.rs +++ b/crates/omnigraph/src/db/omnigraph/schema_apply/prepared.rs @@ -277,13 +277,14 @@ pub(in crate::db::omnigraph) async fn prepare_schema_apply( db: &Omnigraph, source: &str, actor: Option<&str>, -) -> Result { +) -> Result<(PreparedSchemaApply, SchemaMigrationPlan)> { authorize(db, actor)?; let _schema_gate = db.write_queue().acquire_schema_exclusive().await; let captured = capture(db, source, None) .await .map_err(OmniError::before_effect)?; - captured.issue(db, source, actor) + let intent = captured.issue(db, source, actor)?; + Ok((intent, captured.planned.plan)) } pub(in crate::db::omnigraph) async fn reconcile_schema_apply( diff --git a/crates/omnigraph/src/loader/mod.rs b/crates/omnigraph/src/loader/mod.rs index 48f2c7599..06fcc2ce7 100644 --- a/crates/omnigraph/src/loader/mod.rs +++ b/crates/omnigraph/src/loader/mod.rs @@ -1090,6 +1090,12 @@ fn validate_graph_batch_json_structure(raw_json: &[u8]) -> Result<()> { Ok(()) } +/// Decode JSON while refusing duplicate object members at every depth. +/// This performs no graph work; callers retain their own byte and schema bounds. +pub fn parse_unique_json(source: &str) -> std::result::Result { + serde_json::from_str::(source).map(|value| value.0) +} + /// JSON value decoded with duplicate-member rejection at every object depth. /// `serde_json::Value` normally keeps the last duplicate, which is unsuitable /// at a write authority boundary because two producers can disagree about the diff --git a/crates/omnigraph/tests/forbidden_apis.rs b/crates/omnigraph/tests/forbidden_apis.rs index de6ec45a5..58d0802f8 100644 --- a/crates/omnigraph/tests/forbidden_apis.rs +++ b/crates/omnigraph/tests/forbidden_apis.rs @@ -291,6 +291,7 @@ const READ_ONLY_SURFACES: &[(&str, &str)] = &[ ("db/omnigraph.rs", "plan_schema"), ("db/omnigraph.rs", "preview_schema_apply"), ("db/omnigraph.rs", "prepare_schema_apply_as"), + ("db/omnigraph.rs", "prepare_schema_apply_with_plan_as"), ("db/omnigraph.rs", "prepare_schema_settlement_as"), ("db/omnigraph.rs", "reconcile_schema_apply_as"), ("db/omnigraph.rs", "snapshot_of"), diff --git a/crates/omnigraph/tests/schema_apply.rs b/crates/omnigraph/tests/schema_apply.rs index ad34ea991..1369856b4 100644 --- a/crates/omnigraph/tests/schema_apply.rs +++ b/crates/omnigraph/tests/schema_apply.rs @@ -806,10 +806,16 @@ async fn prepared_schema_receipt_reconciles_its_own_publication_after_restart_an let predecessor = db.list_commits(None).await.unwrap()[0].clone(); let files_before = schema_storage_bytes(dir.path()); let desired = TEST_SCHEMA.replace("age: I32?", "age: I32?\n nickname: String?"); - let prepared = db - .prepare_schema_apply_as(&desired, Some("deployer")) + let (prepared, migration) = db + .prepare_schema_apply_with_plan_as(&desired, Some("deployer")) .await .unwrap(); + assert!(migration.supported); + assert!(migration.steps.iter().any(|step| matches!( + step, + SchemaMigrationStep::AddProperty { type_name, property_name, .. } + if type_name == "Person" && property_name == "nickname" + ))); assert!(!prepared.is_noop()); assert_eq!(prepared.actor(), Some("deployer")); assert_eq!( @@ -844,6 +850,7 @@ async fn prepared_schema_receipt_reconciles_its_own_publication_after_restart_an .unwrap(); let commit = result.commit.as_ref().unwrap(); assert!(result.applied); + assert_eq!(result.steps, migration.steps); assert_eq!( intent_nonce(&commit.graph_commit_id).unwrap(), commit_id, diff --git a/crates/omnigraph/tests/writes.rs b/crates/omnigraph/tests/writes.rs index 6acdc4826..515d1c95e 100644 --- a/crates/omnigraph/tests/writes.rs +++ b/crates/omnigraph/tests/writes.rs @@ -100,7 +100,7 @@ async fn load_does_not_create_run_branch() { assert_eq!(qr.num_rows(), 1); } -/// `omnigraph change` writes directly to the target. After the call, +/// `omnigraph mutate` writes directly to the target. After the call, /// `branch_list()` shows only `main`; no run record exists. #[tokio::test] async fn mutation_does_not_create_run_branch() { diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index 9fb5daac2..43045a46a 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -1,6 +1,6 @@ # Cluster control plane -The cluster control plane turns a local declarative bundle into a durable applied revision that servers can consume without the source checkout. It owns graph lifecycle, accepted schemas, stored queries, Cedar bundles, embedding-provider bindings, and external-Blob ingress policy. It does not own graph rows. +The cluster control plane turns a local declarative bundle into a durable applied revision that servers can consume without the source checkout. It owns graph lifecycle, accepted schemas, stored queries, Cedar bundles, embedding-provider bindings, and external-Blob ingress policy. It does not execute row queries or mutations. ## Authority model @@ -49,7 +49,7 @@ All stored control objects use the shared storage adapter. Filesystem replacemen The engine has no ordinary graph recovery sidecar: schema, table pins and lineage publish together in `__manifest`. Ledger v2 stores prepared schema and -graph-birth authority in one outstanding record. Legacy control evidence is +graph-birth and graph-deletion authority in one outstanding record. Legacy control evidence is preserved for explicit conversion checks, never executed by a fallback sweep. ## Deployment ledger @@ -61,20 +61,26 @@ resets graphs nor migrates their storage format. Unknown versions and `state.lock: false` refuse. There is no offline/online mode field and no legacy apply, import, refresh or approval executor. +The same explicit `cluster upgrade-ledger --writers-stopped` path removes +obsolete runtime fields from completed v2 receipts after validating their prior +shape and consistency. It preserves ledger identity, deployment sequence, exact +outcomes and applied resources with one conditional replacement. Normal decoding +stays strict; outstanding work must finish under its originating build. + Apply manages graphs, schemas, queries, policies and provider/Blob bindings. Runtime changes are validated before effects and activated with the achieved -revision. Roots, trust and storage format stay fixed. Exact lifecycle input -permits graph removal with retained storage, adoption, missing-root recreation -and targeted catalog repair. Normal engine open requires v14; server HTTP -requires v0.12. +revision. Retained roots, trust and storage format stay fixed. Removing a graph +from desired configuration deletes its exact managed root, including all branches, +retained history and managed Blob bytes. It does not follow external Blob +references or delete peer roots. Adoption, missing-root recreation and catalog +repair refuse. Normal engine open requires v14; server HTTP requires v0.12. The applied revision and every achieved base retain each graph's exact source/IR digests and identity domain/version, captured coherently during conversion and advanced by successful schema outcomes. Serving and preparation of every affected graph compare this identity even after receipt eviction, rejecting a recreated graph with -identical schema text. Unaffected graph opens are skipped. Explicit correction supplies the exact observed contract -through `--schema-correction FILE`; it becomes immutable deployment input and -requires schema-apply authority even when the source is unchanged. +identical schema text. Unaffected graph opens are skipped. Ordinary apply never +adopts foreign schema identity as an implicit correction. Config capture reads each source once, preserving exact bytes by digest. Immutable bundles and prepared engine intents are bounded before acceptance, @@ -89,13 +95,28 @@ The original ID is `::`; the nonce exists before exposure and full ID plus input digest identifies resubmission. Acceptance CAS consumes the exact next sequence. One outstanding deployment records each graph as `NotStarted`, `Started` or `Settled`; the confirmed `Started` CAS -precedes schema or graph-creation invocation. Recovery never replays it. It records -`NotStarted` as not attempted, or persists an engine-issued settlement intent -before reconciling `Started`. Strict numeric publication at the prepared base +precedes schema, graph-creation or graph-deletion invocation. Schema and birth +recovery never replay the original invocation. They record `NotStarted` as not +attempted, or persist an engine-issued settlement intent before reconciling +`Started`. Strict numeric publication at the prepared base plus one permits a neutral lineage fence or a qualified occupied-version proof to establish nonpublication. Missing evidence remains unknown. Such proof does not authorize adopting a foreign schema into the applied projection. +Deletion is recorded in that same outstanding record with its exact canonical +root and applied schema contract. `Started` is durable before any file removal. +Every deletion caller, including a direct storage owner, must satisfy an +installed graph policy's `read` and `schema_apply` gates before effects or recovery. +Accepted deletion cannot settle as refused or not attempted. An error leaves +it outstanding; reconciliation resumes only that root under exclusive admission, +including after its manifest is gone. Fresh deletion accepts an already-absent +root; a present root must match the applied contract. Recovery refuses a readable +foreign contract, while a partial root remains bound to its original intent. +Completion requires verified root absence before a `Deleted` outcome removes +the graph's applied resources and contract. A lost completion acknowledgement is +reconciled by original ID. No deletion queue, side record or retained-owner pool +is introduced, and matching source text never authorizes a replacement root. + A graph-birth token binds canonical root, exact source/IR contract and genesis identity. Reconciliation accepts only that birth, not matching text. Under explicit prior-owner quiescence, settlement may abandon an exact owned, @@ -167,7 +188,7 @@ I/O settlement. Azure writers also acquire the external admission lease through --cluster ``` -A directory lets the server resolve the storage root from `cluster.yaml`; a URI reads the applied deployment artifact directly. There is no single-graph positional boot, `--target`, or independent graph add/remove API; inventory additions use deployment. +A directory lets the server resolve the storage root from `cluster.yaml`; a URI reads the applied deployment artifact directly. There is no single-graph positional boot, `--target`, or independent graph add/remove API; inventory additions and destructive removals use deployment. Serving captures ledger/resource digests, graph identity, expected contracts and validated policies before binding the listener. The captured registry initially contains loading or preflight-blocked entries, with the same captured policy later installed on the engine. One process-owned startup batch opens at most four graphs concurrently, checks accepted contracts, projects external-Blob policy, and validates stored queries. Each completed graph becomes ready or blocked through the process/registry admission boundary; shutdown prevents late activation and drains entered opens. Default startup admits healthy graphs individually. `--require-all-graphs` installs successful handles atomically only when every graph succeeds; any failure stops startup. A nonempty inventory with zero healthy graphs fails after the batch finishes. No attempt timeout, startup retry, reopen or native-settlement proof is introduced. @@ -180,7 +201,10 @@ disconnect; no polling worker or second storage-writing CLI is involved. The controller projects captured input through the existing serving validator, resolves required provider secrets and validates new graph bindings before any deployment effect. It reserves one batch transition, atomically closes affected graph -admission and drains request descendants. Existing engine handles remain owned. +admission and drains request descendants, including held response bytes. Existing +engine handles remain owned through execution. A removal cannot delete storage +before this boundary; a pre-effect refusal can resume the unchanged predecessor +under a fresh epoch only when the existing abort proof permits it. It executes under the server's lifetime admission, loads the achieved bindings, and atomically installs exact contracts, query registries, immutable engine runtime bindings, management policy and graph inventory. Rebound engine views @@ -188,12 +212,22 @@ share the coordinator, write queues, schema and Lance sessions without reopening storage. Unchanged siblings continue serving. Pre-effect refusals restore predecessor views with new epochs. Uncertain effects use existing process containment. -After installation, the ledger records the result revision/config digest and -server admission incarnation. `GET /cluster/deployments/{id}` reports active -only for the current achieved revision, matching process and ready affected -views. Old-ID submission validates immutable input and returns the original -record before graph closure; it cannot reinstall an old view. Boot digests -remain boot facts. Explicit OIDC public-admission refresh is separate below. +Activation identity is installed in the same immutable registry snapshot as +serving bindings: canonical root, process incarnation, original ID/input digest, +achieved revision and config digest. The durable achieved receipt stays unchanged; +there is no second ledger write after activation. `GET /cluster/deployments/{id}` +reports active only for that exact current identity while every graph is ready +under its installed schema contract and process admission remains open. Row +commits do not invalidate unchanged deployment bindings. Partial convergence, +blocked/loading/transitioning entries and shutdown report inactive. + +A normal configured boot captures the latest matching converged receipt under +root admission and establishes fresh runtime evidence only as its verified graph +opens complete. Generic embedding constructors do not attest deployment bindings. +Missing or expired receipt identity is not inferred from schema text or boot +revision alone. Old-ID submission validates immutable input and returns the +original record before graph closure; it cannot reinstall an old view. Boot +digests remain boot facts. Explicit OIDC public-admission refresh is separate below. Protected graph and registry HTTP calls require the v0.12 contract header after authentication and before graph resolution. CLI discovery and response validation @@ -208,17 +242,10 @@ uses immutable public trust loaded before graph open. The Core's opt-in root-bound serving snapshot supplies the canonical storage root from the same resolution as the applied revision; the server checks that root against trust without reading a managed identity marker. The verifier resolves -`principal:` and retains a private, verified credential profile: - -- Version 1 keeps its existing per-graph action ceilings. Graph selection - checks the ceiling before registry lookup; the common authorization gate - checks actions before Cedar. -- Version 2 authenticates a cluster-bound identity and rejects permission - fields. The same common gate requires applied Cedar policy for protected - operations, with no token-derived graph/action ceiling. - -Cedar must explicitly permit either signed profile even when no static -credentials exist. Static credential authority remains unchanged. Issuer +`principal:` and retains a private, verified version-2 identity. Permission +fields and unsupported token versions are refused. The common authorization +gate requires applied Cedar policy for protected operations, with no token-derived +graph/action ceiling. Static credential authority remains unchanged. Issuer reachability is outside the serving request path. The profile boundary and applied-policy ownership are described in [Identity credentials and applied policy authorization](../rfcs/2026-09-09-identity-credentials-and-applied-policy.md). @@ -228,22 +255,18 @@ IDs and display names for the loading, ready and blocked graph inventory capture at boot. It neither scans storage nor discloses availability, paths, policy, schema, or query definitions. It needs no policy membership. The separate `GET /graphs` uses the same registry with a `graph_list` gate and returns one -list containing runtime availability, sanitized failure and action. Version 1 -filtering remains an additional restriction. Neither inventory synthesizes graph +list containing runtime availability, sanitized failure and action. Neither inventory synthesizes graph entries from the boot witness. Discovery still discloses no availability. -HTTP and MCP graph resolution apply credential graph scope before lookup and -atomically capture a serving view with its graph-epoch lease. A loading, blocked or +HTTP and MCP graph resolution atomically capture a serving view with its graph-epoch lease. A loading, blocked or transitioning graph yields 503 only after graph `read` authorization on `main` or management `graph_list` authorization; otherwise it remains undisclosed as 404. An invalid graph policy or configuration cannot authorize the graph-read fallback. Availability booleans describe the runtime, not a policy grant; route authorization still applies to ready graphs. -The CLI's versioned keychain cache records the issuance profile and verifies -its endpoint and identity bindings before replacement. A legacy issuance -request cannot return an identity profile, and restricted caches are never -silently upgraded. The provider-native client acquires missing/expired identity +The CLI's versioned keychain cache verifies endpoint and identity bindings before +replacement. Unsupported caches refuse without automatic replacement. The provider-native client acquires missing/expired identity credentials before an operation and selects discovery in a managed folder. Explicit server addressing keeps the existing catalog unless `graphs list --discovery` is requested; the CLI never @@ -319,14 +342,13 @@ The embedding entry point is `AppState::prepare_same_view`. It binds the actual process runtime; callers cannot substitute a new runtime to bypass stopping. It grants no schema/query replacement or deployment authority. The internal batch capability used by `deployment.rs` validates exact achieved schema/query -bindings and explicit graph lifecycle under the same shutdown/attempt fence. -Logical deletion retains the engine owner by canonical storage identity; -readoption reuses that owner and validates its exact confirmation. At most 2,048 -active plus retained roots are admitted per process. Retained owners are never -evicted without settlement; restart clears them. Recreating a graph with a -retained owner is a pre-effect refusal. Blocked startup entries with no engine -can receive a newly created graph. There is no public arbitrary-view replacement -endpoint. +bindings, graph additions and completed graph deletions under the same +shutdown/attempt fence. Inventory removal requires an exact durable `Deleted` +result for a graph closed by this transition; it is installed atomically with +surviving bindings and management policy. There is no retired-owner cache, +readoption path, root-recreation shortcut or public arbitrary-view replacement +endpoint. Interrupted deletion keeps affected admissions closed; it never +restores a partially deleted predecessor. These registrations account for server lifetimes, not universal storage-I/O settlement. A joined future or zero operation counter cannot authorize runtime @@ -364,13 +386,12 @@ The binary's `--data-token-trust FILE` selects this managed path. `ResolvedActor` is a public identity projection, not proof of authentication. Middleware and protected handlers retain `AuthenticatedActor`, whose private -state carries verified claims and graph selection. `DataTokenTrust::verify_at` +state carries verified claims. `DataTokenTrust::verify_at` returns the identity projection for existing callers; `verify_authenticated_at` returns the opaque authenticated result used by the server. Public actor construction cannot grant signed-token permissions. -`DataTokenClaims` and `DataGrant` keep their version-1 shapes; -`IdentityTokenClaims` is a separate strict type. Read-only claim accessors on -`AuthenticatedActor` expose only the matching verified profile. +`IdentityTokenClaims` is the strict signed identity type; its read-only accessor +on `AuthenticatedActor` exposes only verified claims. Policy embedders must handle `PolicyAction::ConfigManage`, `PolicyResourceKind::Cluster`, and `PolicyEngineKind::Cluster`. These public diff --git a/docs/dev/execution.md b/docs/dev/execution.md index 3871dd678..bdf7bb22c 100644 --- a/docs/dev/execution.md +++ b/docs/dev/execution.md @@ -421,7 +421,7 @@ and a cancelled worker stops at its next cooperative `memory.check()`. operator and the hash-join build loop run without a check, so a cancelled worker inside one of them finishes that stretch first. An aborted blocking job that is still queued holds its lease until the blocking pool dequeues it. The -`/query` and `/read` routes have no route timeout. No benchmark or timing test +The `/query` route has no route timeout. No benchmark or timing test measures the latency this wait adds to a query that errors or stops early. Custom operators diff --git a/docs/dev/ingestion.md b/docs/dev/ingestion.md index c0a19ffcf..87eb815b3 100644 --- a/docs/dev/ingestion.md +++ b/docs/dev/ingestion.md @@ -31,7 +31,7 @@ The HTTP handler authorizes both change and any requested branch creation before `POST /graphs/{graph_id}/load` and the engine `load_as` accept a JSON envelope whose `data` field contains loader-compatible NDJSON. This preserves historical coercions and shapes that the strict graph-batch boundary intentionally refuses: a nullable scalar or list item of the wrong JSON type becomes NULL (a non-nullable property then refuses the load), and a list that is not a JSON array is refused. `Date` and `DateTime` are the exception on both paths: their string form already goes through a fallible parser, so a NULL for a wrong-typed value would be a lost error rather than a coercion, and such a value is refused with the property named. -`POST /graphs/{graph_id}/ingest`, the CLI `ingest` command, and the engine `ingest*` methods are deprecated compatibility aliases over the same loader. They do not have a separate fast path or durability contract. New integrations use `load` or `/load/ndjson`. +The engine `ingest*` methods are deprecated aliases over the same loader, with no separate fast path or durability contract. CLI and HTTP integrations use `load` or `/load/ndjson`. ## Transaction contract diff --git a/docs/dev/invariants.md b/docs/dev/invariants.md index 69f77058a..65de0412f 100644 --- a/docs/dev/invariants.md +++ b/docs/dev/invariants.md @@ -143,12 +143,16 @@ Cases an RFC has argued: the flat locator objects and legacy data files under - The server is cluster-only and ledger v2 is the sole operational protocol. Server-owned apply activates schemas, queries, policies, provider/Blob bindings - and explicitly confirmed graph lifecycle changes under existing writer admission. + under existing writer admission. Runtime views share engine authority and retain immutable request bindings. Direct apply bootstraps or deploys while serving is stopped. Legacy ledger - conversion is explicit and preserves data; no v1 executor remains. Graph - removal retains storage; adoption and missing-root recreation require exact - confirmations and never infer identity from matching schema text. + conversion is explicit and preserves data; no v1 executor remains. Removing + a graph declaration authorizes deletion of its exact managed root and all + retained history, after affected admission closes and admitted work drains. + The existing ledger records deletion intent before effects and owns resumption + through verified root absence. Adoption and missing-root recreation remain + unsupported; unexpected roots and schema drift refuse rather than infer + identity from matching text. One outstanding durable authority owns completion/recovery. Generic drain does not prove native-I/O settlement or authorize lock release; excluded raw and older writers remain an operator obligation. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index e13331011..165fc7572 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -138,11 +138,12 @@ availability. The same owners cover coherent schema/query batch activation; thes are not generic native settlement. Server `boot_settings` exercises authenticated submission, parked requests, caller disconnect, pre-effect refusal and historical ID observation. CLI `cli_cluster_e2e` proves one PID/listener survives schema/query -replacement, graph addition, policy grant/revocation, management handoff and -confirmed graph removal/readoption while preserving data and history. It checks +replacement, graph addition, policy grant/revocation and management handoff. +Extend that same journey for graph deletion, proving target storage/history +removal, peer preservation and unchanged PID/listener. It checks the achieved configuration again after restart. The local journey also exercises counted embedding-provider replacement, external-Blob admission changes, catalog -repair, exact schema correction and missing-root recreation. S3/Azure wrappers +integrity reporting, schema-drift refusal and missing-root refusal. S3/Azure wrappers share the transport-independent phases; local success does not qualify their storage-fault paths. CI requires the local journey to execute successfully. @@ -152,7 +153,9 @@ Deployment tests extend these owners: cluster `tests.rs` pins no-reset ledger conversion, captured source bytes, exact-ID lookup, bounded results and exact applied schema identity after receipt eviction; `admission.rs` pins lifetime exclusion and exact reconciliation admission. Cluster `tests/failpoints.rs` owns -interruption windows, killed-process recovery and corrective successors; +interruption windows, killed-process recovery and corrective successors, including +deletion before start, during partial removal, after root absence and before +terminal ledger acknowledgement; `tests/identity_recovery.rs` owns current-actor authorization and adoption of a persisted settlement without replacing its author. CLI `tests/cli_cluster_e2e.rs` owns the root-only deployment round trip, and diff --git a/docs/rfcs/0009-unified-access-paths.md b/docs/rfcs/0009-unified-access-paths.md index 5249db640..77dab642b 100644 --- a/docs/rfcs/0009-unified-access-paths.md +++ b/docs/rfcs/0009-unified-access-paths.md @@ -7,7 +7,7 @@ implementation: complete authors: - OmniGraph maintainers created: 2026-06-12 -updated: 2026-08-23 +updated: 2026-10-05 discussion: null supersedes: [] superseded_by: [] @@ -176,12 +176,10 @@ literal `http(s)://` URIs. ### Phase 5 — Route alignment (landed) -Added a canonical `POST /load` (shared `run_ingest` body; the deprecated -`/ingest` is now a thin alias carrying `#[deprecated]` + RFC 9745/8288 -`Deprecation`/`Link: ` headers, exactly mirroring `/mutate`↔`/change`) -and pointed the CLI's remote `load` arm at it; `/ingest` stays on its -deprecation path. `/load` reuses `IngestRequest`/`IngestOutput` (as canonical -`/mutate` reuses `Change*`); a DTO rename is a separate change. +The served data routes are `POST /query`, `POST /mutate`, `POST /load` +and strict `POST /load/ndjson`. Deprecated `/read`, `/change`, and `/ingest` +are removed. JSON `/load` reuses `IngestRequest`/`IngestOutput`, and `/mutate` +reuses `Change*`; the native CLI's load adapter uses `/load/ndjson`. Registration finding: the server **hand-mounts** routes (`.route(...)`) beside a manual `#[openapi(paths(...))]` list, not `utoipa-axum`'s `OpenApiRouter`/ @@ -218,8 +216,8 @@ registration — the migration is a worthwhile but orthogonal cleanup, deferred. through Phases 2–3 (the storage-collapse playbook). - Phase 2: `openapi.rs` byte-stability + existing server tests. - Phase 4: one test per capability class asserting the deliberate error. -- Phase 5: parity matrix leg for `load` flips to `/load`; an `/ingest` shim - test stays until removal. +- Phase 5: load parity covers canonical `/load/ndjson`; route tests refuse + removed aliases before graph effects. ## Open questions @@ -247,3 +245,10 @@ consumes; RFC 0008 removed the competing config authority; RFC 0002 remains the umbrella whose remaining unimplemented pieces (`GraphLocator`, the State layer) would build on the trait introduced here rather than on per-command forks. + +## Decision log + +- 2026-10-05: The maintainer requested removal of compatibility interfaces. + This replaces Phase 5's retained `/ingest` alias and deprecation-header + sentences, and Testing's shim-retention rule. Canonical routes and CLI + dispatch remain shared; no storage or publication behavior changes. diff --git a/docs/rfcs/0011-cli-addressing-and-config.md b/docs/rfcs/0011-cli-addressing-and-config.md index 49df70019..d3da19cca 100644 --- a/docs/rfcs/0011-cli-addressing-and-config.md +++ b/docs/rfcs/0011-cli-addressing-and-config.md @@ -7,7 +7,7 @@ implementation: complete authors: - OmniGraph maintainers created: 2026-06-14 -updated: 2026-08-23 +updated: 2026-10-05 discussion: null supersedes: ["0010"] superseded_by: [] @@ -273,9 +273,9 @@ The CLI validates through verb capability, not plane jargon: | Capability | Meaning | Examples | |---|---|---| -| `any` | graph-scoped data; served via a server scope; direct only against a **store** scope (local dev / break-glass); **errors on a cluster scope** | `query`, `mutate`, `load`, `export`, branch reads, `schema show/apply` | +| `any` | graph-scoped data; served via a server scope; direct only against a **store** scope (local dev / break-glass); **errors on a cluster scope** | `query`, `mutate`, `load`, `export`, branch reads, `schema show` | | `served` | requires an HTTP server; may be graph-scoped or scope-scoped | `graphs list`, `queries list` | -| `direct` | graph-scoped storage-native or graph-backed validation; no server form exists | `init`, `optimize`, `repair`, `cleanup`, `schema plan`, graph-backed `lint` | +| `direct` | graph-scoped storage-native or graph-backed validation; no server form exists | `init`, `optimize`, `repair`, `cleanup`, `schema plan/apply`, graph-backed `lint` | | `control` | cluster-scoped catalog/control-plane work; addresses the cluster, not a single raw store | `cluster *`, `queries validate` | | `local` | does not address a graph or scope | `config`, `profile`, `lint --query ... --schema ...` | @@ -520,7 +520,7 @@ deferred (see below); they do not block the model. `branch delete`) require confirmation when the scope is not local; `--yes` skips it; **no TTY without `--yes` errors** (never silently proceed). `--json`/CI never prompt — destructive without `--yes` errors. -10. **Cluster graphs evolve only via `cluster apply`.** `schema apply` (an `any` +10. **Cluster graphs evolve only via `cluster apply`.** `schema apply` (a `direct` verb) targets standalone graphs; against a cluster-managed graph it errors and points at `cluster apply` (which records ledger/recovery/approvals — RFC 0004). Mirrors `init`'s refusal of a cluster-managed path. @@ -596,15 +596,15 @@ this RFC canonicalizes how every verb *addresses* a graph. The full command set under this model, organized by **capability** (the new classifying axis) instead of plane — the end-state counterpart to the -current-taxonomy appendix below. Every command, with its end-state addressing. +historical taxonomy appendix below. Every command, with its end-state addressing. ``` omnigraph │ ├─ any — data verbs · served by default (server scope, or --server ); │ --graph selects the graph in scope; --store forces ad-hoc direct (no catalog) -│ ├─ query (alias: read*) invoke a stored query by NAME; -e/--file for ad-hoc -│ ├─ mutate (alias: change*) invoke a stored mutation by name; -e/--file for ad-hoc +│ ├─ query invoke a stored query by NAME; -e/--file for ad-hoc +│ ├─ mutate invoke a stored mutation by name; -e/--file for ad-hoc │ ├─ load bulk write — --data, --mode required; --from forks a missing branch │ ├─ export dump graph data (NDJSON / Arrow) │ ├─ snapshot current per-table versions @@ -640,8 +640,9 @@ omnigraph └─ version (-v) ``` -`*` `read`/`change` remain as deprecated aliases (warn on use); `ingest` and the -`check`→`lint` argv-shim are **removed**. `get` aliases `schema show`. +`read`, `change`, `ingest`, `check`, `query lint`, and `query check` compatibility +spellings are removed. `get` aliases `schema show`. Export always emits JSONL +and does not accept `--jsonl`. ### Addressing forms (end state) @@ -664,29 +665,26 @@ from a URI scheme and never toggled. | Command(s) | Today (plane) | End state (capability) | |---|---|---| -| `query`/`mutate`/`load`/`export`/`snapshot`/`branch`/`commit`/`schema show`/`schema apply` | Data | **`any`** (served-default; `--store` ad-hoc) | +| `query`/`mutate`/`load`/`export`/`snapshot`/`branch`/`commit`/`schema show` | Data | **`any`** (served-default; `--store` ad-hoc) | | `graphs list` | Data (remote-only) | **`served`** | | `queries list` | Session | **`served`** (catalog read) | -| `init`/`optimize`/`repair`/`cleanup`/`schema plan`/graph-backed `lint` | Storage | **`direct`** (privileged) | +| `init`/`optimize`/`repair`/`cleanup`/`schema plan`/`schema apply`/graph-backed `lint` | Storage | **`direct`** (privileged) | | `queries validate` | Storage | **`control`** (catalog validation) | | `cluster *` | Control | **control** (unchanged) | | `policy *`/`embed`/`login`/`logout`/`config`/`version`/offline `lint --query --schema` | Session | **`local`** | | `ingest`; `--target`; `--cluster-graph`; `--uri http` dispatch | present | **removed** | | — | — | **added:** `profile { list | show }` (read-only) | -Cross-capability families: `schema` (`plan` is `direct`, `show`/`apply` are +Cross-capability families: `schema` (`plan`/`apply` are `direct`, `show` is `any`), `queries` (`list` is `served`, `validate` is `control`), and `lint` (offline with `--schema` is `local`, graph-backed is `direct`) split per subcommand/mode, exactly where their authority and data dependencies differ. -## Appendix: current CLI taxonomy (today) +## Appendix: historical CLI taxonomy before this RFC -The **as-is** command surface this RFC transforms, kept so the RFC is -self-contained. The source of truth is the exhaustive `command_plane` match in -`crates/omnigraph-cli/src/planes.rs`. -Where it disagrees with the design above (four planes, `--target`, -`--cluster-graph`, scheme-inferred transport), the design is the *target* and this -is *today*. +This appendix records the input to the original design. Its commands, aliases, +planes and enforcement descriptions are historical; current supported behavior +is documented in the [CLI reference](../user/cli/reference.md). ### The four planes (today) @@ -765,9 +763,21 @@ Reviewed against the current CLI taxonomy, `planes.rs`, `cli.rs`, `helpers.rs`, - Graph selection is no longer universal. Graph-scoped verbs select a graph; scope-scoped verbs such as `graphs list`, `queries list`, `queries validate`, and `cluster *` address the whole server/cluster scope. -- The current-state appendix still matches the implemented CLI: four planes, +- The historical appendix records the pre-change CLI: four planes, `--target`, `--cluster-graph`, scheme-inferred transport, `schema plan` as Storage, and `schema show/apply` as Data. Decisions and deferrals are tracked in [Decisions](#decisions) above — not duplicated here. + +## Decision log + +- 2026-10-05: The maintainer requested removal of compatibility interfaces. + This replaces the target taxonomy's `read`/`change` aliases and its + retained-alias sentence; `check` and query-lint/check argv rewrites and + hidden `ingest` are removed, and export has no `--jsonl` switch. The + pre-change appendix and matching audit sentence are explicitly historical. + +- 2026-10-05: `schema apply` is direct-only; the unused served schema-apply + route is removed. Served changes use cluster deployment. Decision 10 and + the capability taxonomy now reflect that boundary. diff --git a/docs/rfcs/0047-search-plan-truth.md b/docs/rfcs/0047-search-plan-truth.md index 35a8ea311..63e8cba68 100644 --- a/docs/rfcs/0047-search-plan-truth.md +++ b/docs/rfcs/0047-search-plan-truth.md @@ -7,7 +7,7 @@ implementation: in-progress authors: - Ragnor Comerford (@ragnorc) created: 2026-09-01 -updated: 2026-09-30 +updated: 2026-10-05 discussion: "https://github.com/ModernRelay/omnigraph/pull/791" supersedes: [] superseded_by: [] @@ -51,8 +51,8 @@ engine v2, the only query engine since v0.12.0 (PR #795), and on the compiler. validation for a defined exact-search subset. Boundaries that do not change: no storage format change, no change to BM25 or -vector scoring, the deprecated `POST /read` envelope stays byte-stable, the -frozen reference engine keeps its bytes, and GQ gains no syntax. +vector scoring, the frozen reference engine keeps its bytes, and GQ gains +no syntax. Related decisions: [Shared expression model](2026-09-24-shared-expression-model.md) owns the query surface this RFC works within; RFC 0048 @@ -282,7 +282,6 @@ of the internal rewrite-validation guarantee. - `embedding_coverage` is `known` with counts only when the run already established them, and `unknown` otherwise. Exact counts on request belong to RFC 0048's result metadata contract. -- The deprecated `POST /read` envelope carries neither array. The extension is one additive change to the envelope, made together with the result column types that the self-contained server testing RFC needs. @@ -905,6 +904,9 @@ None. ## Decision log +- 2026-10-05: Aligned this proposal with the maintainer-authorized removal of + deprecated HTTP routes. Removed Summary's `/read` byte-stability clause and + the read-descriptor exception for its envelope. The proposal remains draft. - 2026-09-01: first draft opened in PR #606, written against engine v1. - 2026-09-19: accepted as drafted in PR #606. - 2026-09-28: rewritten against engine v2 (`main` `b14c22c5`) and returned diff --git a/docs/rfcs/0049-control-plane-seams.md b/docs/rfcs/0049-control-plane-seams.md index aa9e08615..15a91dc9f 100644 --- a/docs/rfcs/0049-control-plane-seams.md +++ b/docs/rfcs/0049-control-plane-seams.md @@ -7,7 +7,7 @@ implementation: partial authors: - OmniGraph maintainers created: 2026-09-03 -updated: 2026-10-03 +updated: 2026-10-05 discussion: null supersedes: [] superseded_by: [] @@ -22,7 +22,7 @@ Three small, independently shippable contracts let an external control plane drive a cluster without a second implementation of anything the cluster crate already does, and without bypassing it: -1. **Observe-only reads.** `cluster plan --observe` and a new +1. **Observe-only reads.** `cluster plan` and `cluster observe` read the ledger and the live graphs without taking the cluster lock and without writing anything, and label their output `authority: observed` together with the exact `state_cas` they read. @@ -42,7 +42,7 @@ inventory response shapes with coordinated in-tree consumer changes and no legacy aliases. The wider [Server runtime and online deployment](2026-09-29-server-runtime-and-online-deployment.md) decision owns the initial loading listener and startup ownership. Deploying, -startup retry and online activation remain unimplemented. Observe-only authority and the absolute +startup retry remains separate; online activation is owned by that decision. Observe-only authority and the absolute shutdown deadline remain unchanged. Restoring a ledger is deliberately not here: its real use arrives with coherent restore points, where the ledger and the graphs come back together, and it will be designed once, against those. @@ -52,15 +52,10 @@ together, and it will be designed once, against those. An operator that manages many clusters needs two things from the engine it does not have today. -**A drift signal without a lock.** `plan` and `refresh` take -`__cluster/lock.json` (create-if-absent, deleted on release). A service that -observes hundreds of clusters would create and delete lock files on roots it -does not own, would be refused whenever an apply holds the lock, and would -have no way to say that what it returned was an observation rather than a -locked read. `refresh` additionally writes the ledger and runs the recovery -sweep, so it can only run while nothing else moves the cluster. The -`state.lock: false` bypass exists but is a configuration setting with a -warning, not a per-command intent. +**A drift signal without a lock.** A preview should work while the server owns +the cluster. Writer admission supplies effect authority; taking it merely to +observe would block deployments or create unnecessary ownership handoffs. +Planning therefore always reports observations and reserves nothing. **An honest replica.** `/healthz` reports the process is alive. Nothing reports which applied revision a replica actually booted from, which graphs it @@ -79,27 +74,25 @@ preempt those contracts. ### Observe-only reads ```bash -omnigraph cluster plan --observe --config ./company-brain +omnigraph cluster plan --config ./company-brain omnigraph cluster observe --config ./company-brain ``` -`plan --observe` is `plan` without the lock: it reads the ledger once, diffs -the desired bundle against it, and reports. `cluster observe` is `refresh` -without the lock, the sweep, or the write: it verifies catalog payloads and -observes every declared graph through the read-only open, and reports the -resource statuses and observations `refresh` would have recorded. +`plan` reads the ledger once, diffs the captured desired bundle and runs shared +effect-free preparation. `cluster observe` verifies catalog payloads and observes +every declared graph through read-only opens. Both report without writer +admission or ledger writes. -Both outputs carry `authority: "observed"`. The existing paths carry -`"locked"`, or `"unlocked"` when the bundle sets `state.lock: false`, so a -read never claims a lock it did not hold. Every output carries the -`state_cas` and `state_revision` of the ledger it read. +Both outputs carry `authority: "observed"` and the `state_cas` and +`state_revision` of the ledger they read. There is no locked plan mode or +`--observe` flag. An existing lock is reported in `state_observations` (`locked`, `lock_id`, `lock_operation`, `lock_age_seconds`) and does not refuse the command. Pending recovery sidecars are reported as the `cluster_recovery_pending` warning that read-only commands already emit; nothing is swept. An observed result is never authority for an effect: `apply` still re-plans -under the lock, and an approval still binds to the digests `apply` sees. +under writer admission against its immutable input. ### Readiness witness @@ -181,17 +174,13 @@ crash. Nothing is deleted or repaired at the deadline. ## Design -**Observe.** `plan_config_dir_with_options(dir, PlanOptions { observe })` -and `observe_config_dir(dir)` in `omnigraph-cluster`. The observe path calls -`ClusterStore::observe_lock` (a read of `lock.json`) where the locked path -calls `acquire_lock`, runs `warn_pending_recovery_sidecars` where `refresh` -runs `sweep_recovery_sidecars`, mutates only its in-memory copy of the -ledger, and returns before `write_state`. `PlanOutput` and `StateSyncOutput` -gain `authority: LedgerAuthority` (`locked` | `unlocked` | `observed`); -`StateSyncOperation` gains `observe`. The graph observation pass already -opens graphs read-only and never runs the recovery sweep, so no engine change -is needed. `refresh` refuses with `state_revision_overflow` instead of -saturating at `u64::MAX`. +**Observe.** `plan_config_dir(dir)` and `observe_config_dir(dir)` in +`omnigraph-cluster` call `ClusterStore::observe_lock` to report existing +ownership. Plan captures source bytes and the ledger once and uses shared +deployment preparation for validation and migration steps. It never acquires +admission or writes state. Apply prepares again under the current owner. +`PlanOutput` and `StateSyncOutput` report observed authority. The graph +observation pass opens graphs read-only and does not run recovery. **Witness.** `ServingSnapshot` supplies applied revision/digest/CAS boot facts and graph startup inputs, including graph-specific admission refusals. The diff --git a/docs/rfcs/0051-json-output-via-arrow.md b/docs/rfcs/0051-json-output-via-arrow.md index b8f0d3886..4774f42a6 100644 --- a/docs/rfcs/0051-json-output-via-arrow.md +++ b/docs/rfcs/0051-json-output-via-arrow.md @@ -7,7 +7,7 @@ implementation: partial authors: - azimafroozeh created: 2026-09-04 -updated: 2026-09-05 +updated: 2026-10-05 discussion: null supersedes: [] superseded_by: [] @@ -71,11 +71,11 @@ An issue or local refactor is not enough because the change moves a wire contrac The differential test in Evidence and tests enforces every row but `enum`, whose cells reach the writer as strings. No property type reaches the writer as a struct; the differential test covers structs only as writer input. -**Query results keep their envelope; only the listed spellings change.** The response body keeps its shape: a `ReadOutput` object whose `rows` field is an array of objects keyed by column name in schema order; from phase 1 null cells are omitted. The bytes change only for the phase 1 rows in the table below. `POST /read` keeps its envelope byte-stable as documented; the cell spelling inside its `rows` follows this RFC like every query route, and `Accept` is ignored on `POST /read`. +**Query results keep their envelope; only the listed spellings change.** The response body keeps its shape: a `ReadOutput` object whose `rows` field is an array of objects keyed by column name in schema order; from phase 1 null cells are omitted. The bytes change only for the phase 1 rows in the table below. **Export lines keep their grammar and change the listed spellings.** An export line is `{"type":,"data":}` or `{"edge":,"from":,"to":,"data":}`, where `` is the writer's rendering of the row in schema column order with `id` first, `src` and `dst` removed, and `Blob` columns substituted per rule 2. The grammar is unchanged; the key order inside `data` changes from alphabetical (the old `serde_json::Map`) to `id` first and then the catalog's column order; a consumer that keys by name sees no difference. ``, ``, and every id are JSON strings. Dates and datetimes become the strings in the spelling table instead of raw counts. Null cells drop their key; the loader reads a missing key as null. Floats take the spelling table's exponent form. No version field is added: a `Date` or `DateTime` cell is a number in a line written before phase 2 and a string after; a file may mix both and loads either way. -**Arrow IPC is selected by the `Accept` header on the query routes.** Content negotiation on `Accept`, the server choosing the response format from the client's `Accept` header, is new to the server. It applies to `POST /query` and to stored-query invocation; the deprecated `POST /read` always answers JSON. A stored mutation answers its `ChangeOutput` envelope as JSON whatever `Accept` says; there are no batches to stream and the mutation envelope is not a serialization choice. An `Accept` that is absent, `*/*`, or `application/json` receives JSON. An `Accept` whose first supported token is exactly `application/vnd.apache.arrow.stream` (parameters ignored) receives the result as an Arrow IPC stream with that response `Content-Type`; an `Accept` list naming neither form receives 406 with the `ErrorOutput` body; any other media range, `application/*` included, counts as naming neither form. The IPC body carries the same batches the JSON body would serialize, after policy and projection have been applied. The envelope fields travel as response headers: `Omnigraph-Graph-Commit-Id`, `Omnigraph-Query-Name`, `Omnigraph-Branch`, and `Omnigraph-Snapshot-Id`, the spelling the blob routes already send; `row_count` is the sum of batch lengths and `columns` is the IPC schema. The CLI gains `--format arrow`, writing that stream to stdout. +**Arrow IPC is selected by the `Accept` header on the query routes.** Content negotiation on `Accept`, the server choosing the response format from the client's `Accept` header, is new to the server. It applies to `POST /query` and to stored-query invocation. A stored mutation answers its `ChangeOutput` envelope as JSON whatever `Accept` says; there are no batches to stream and the mutation envelope is not a serialization choice. An `Accept` that is absent, `*/*`, or `application/json` receives JSON. An `Accept` whose first supported token is exactly `application/vnd.apache.arrow.stream` (parameters ignored) receives the result as an Arrow IPC stream with that response `Content-Type`; an `Accept` list naming neither form receives 406 with the `ErrorOutput` body; any other media range, `application/*` included, counts as naming neither form. The IPC body carries the same batches the JSON body would serialize, after policy and projection have been applied. The envelope fields travel as response headers: `Omnigraph-Graph-Commit-Id`, `Omnigraph-Query-Name`, `Omnigraph-Branch`, and `Omnigraph-Snapshot-Id`, the spelling the blob routes already send; `row_count` is the sum of batch lengths and `columns` is the IPC schema. The CLI gains `--format arrow`, writing that stream to stdout. **Spelling changes a JSON consumer can observe.** Each line is the complete list for its phase; nothing outside it changes. @@ -105,7 +105,7 @@ The differential test in Evidence and tests enforces every row but `enum`, whose **One writer replaces two.** `QueryResult` gains `to_json_bytes()` (the row array) and `to_json_lines()` (one object per line, used by export and the row images), both on `arrow_json::WriterBuilder` with default options. The engine's export and entity paths call the same writer on their one-row batches. No omnigraph code matches on Arrow types to produce JSON. No schema type maps to an Arrow `Timestamp`. Duplicate return-column names are refused by the typechecker (`T25`): the old encoder collapsed them to the last value, and the writer would emit both keys. -**Rows travel as text.** `ReadOutput.rows` and `LegacyReadOutput.rows` become `Box`, JSON text validated once by `RawValue::from_string` and carried as bytes, never rebuilt as a `serde_json::Value`; the OpenAPI schema for `rows` is unchanged. The three server handlers pass the text through unchanged. The CLI parses it for the `table`, `csv`, and `kv` formats; `--format json` keeps its pretty-printed envelope with the writer's compact bytes inside `rows`, and `--format jsonl` re-splits the row array. `QueryResult::to_sdk_json` is deleted; `to_rust_json` and `deserialize` parse the writer's bytes; the server never parses. +**Rows travel as text.** `ReadOutput.rows` becomes `Box`, JSON text validated once by `RawValue::from_string` and carried as bytes, never rebuilt as a `serde_json::Value`; the OpenAPI schema for `rows` is unchanged. The query handlers pass the text through unchanged. The CLI parses it for the `table`, `csv`, and `kv` formats; `--format json` keeps its pretty-printed envelope with the writer's compact bytes inside `rows`, and `--format jsonl` re-splits the row array. `QueryResult::to_sdk_json` is deleted; `to_rust_json` and `deserialize` parse the writer's bytes; the server never parses. **OmniGraph owns the blob substitution, the date-range check, the export envelope, and the change-feed null re-insertion.** The `base64:` substitution for `Blob` cells on export and entity fetch, applied before the writer. The range check on `Date` and `DateTime` columns before the writer. The export line envelope and its key order. The re-insertion of explicit nulls into change-feed images by `logical_row_image`, after the writer. Nothing else that spells a cell. @@ -135,7 +135,7 @@ No invariant is weakened. No deny-list item is invoked. **The spelling table is the contract; `arrow-json` is pinned by the lock file.** The JSON spelling of every property type is the table in User and operational behavior. `arrow-json` is pinned by `Cargo.lock` (58.3.0 at merge); phase 1 adds it under the workspace `arrow-*` requirement `"58"`, and a lock bump is the review point. An upgrade that changes any spelling in that table is a wire change and is treated as one: the differential test turns red, and the change ships with a release note and an updated spelling table, never silently and never through a builder option. -**Rust consumers of `omnigraph-api-types` see `rows` change type.** `ReadOutput.rows` and `LegacyReadOutput.rows` change type in phase 1; `omnigraph-api-types` enables the `serde_json/raw_value` feature. +**Rust consumers of `omnigraph-api-types` see `rows` change type.** `ReadOutput.rows` changes type in phase 1; `omnigraph-api-types` enables the `serde_json/raw_value` feature. **A revert restores both encoders.** The phases share one pull request; reverting it restores the compiler's encoder, the engine's encoder, and the `serde_json::Value` rows together. The Arrow IPC route is additive and can be removed without touching JSON. @@ -173,4 +173,8 @@ None that block acceptance. ## Decision log +- 2026-10-05: Aligned this proposal with the maintainer-authorized removal of + `/read` and `LegacyReadOutput`. Removed the query-envelope and Arrow + negotiation exceptions for that route, and the retired DTO from the raw-row + representation and Rust compatibility clauses. The proposal remains draft. - 2026-09-05, amendment from the implementation pull request, after this RFC merged: one pull request carries the three phases; the change feed is a third consumer of the deleted engine encoder and keeps explicit nulls; export `data` key order is `id` first then catalog order; colliding return names were refused by `T25` in #621 ahead of phase 1; no schema-default value path exists; a pre-existing out-of-range date count fails reads and exports of that column until the row is updated; the export stream ends at a 256-row render-window boundary; `RawValue::from_string` validates the bytes once. diff --git a/docs/rfcs/0053-offline-data-token-verification.md b/docs/rfcs/0053-offline-data-token-verification.md index 35904194e..85ef0efbc 100644 --- a/docs/rfcs/0053-offline-data-token-verification.md +++ b/docs/rfcs/0053-offline-data-token-verification.md @@ -7,7 +7,7 @@ implementation: complete authors: - andrew created: 2026-09-05 -updated: 2026-09-16 +updated: 2026-10-05 discussion: https://github.com/ModernRelay/omnigraph/pull/633 supersedes: [] superseded_by: [] @@ -17,17 +17,16 @@ blocked_on: [] # RFC 0053: Offline data-token verification > The [identity and applied-policy extension](2026-09-09-identity-credentials-and-applied-policy.md#provider-native-access-and-standard-clients) -> adds identity-only credentials, automatic acquisition and an offline OAuth -> resource profile. This RFC retains the public version-1 restricted -> credential contract; normal managed access uses identity-only credentials. +> defines identity-only credentials, automatic acquisition and an offline OAuth +> resource profile. Native signed data credentials use version 2 only. ## Summary Accept bounded, short-lived ES256 data credentials at the existing HTTP boundary. An operator supplies public trust at boot; the server verifies every credential offline, derives its actor from the authenticated principal, and -intersects its graph/action grants with the existing Cedar policy. An issuer -cannot use this credential to bypass graph policy or authorize schema changes. +requires the existing applied Cedar policy to authorize every protected +operation. An issuer cannot use this credential to bypass that policy. The contract is generic OSS behavior. No managed-service library, network discovery, token database, engine fork, or graph-storage format is introduced. @@ -40,7 +39,7 @@ never be forwarded to graph endpoints. Short-lived data credentials let an authorized caller query and mutate an existing cluster while serving remains independent of the issuer's availability. -Identity, attenuation, expiry, trust custody, and root binding are public +Identity, expiry, trust custody, and root binding are public security contracts. They require a decision before implementation rather than an incidental alternative authentication branch. @@ -64,8 +63,9 @@ remaining indistinguishable from missing queries. Denial occurs before graph lookup or effect wherever that contract applies. Token validity is checked at request admission. Expiry does not cancel an -already admitted mutation or stream. Revocation of a grant or login session -prevents future issuance; an issued token remains usable until its expiry or +already admitted mutation or stream. Applied policy changes govern subsequent +protected operations with the same token. Login-session revocation can prevent +future issuance; an issued identity remains authenticated until its expiry or the serving process restarts with trust that excludes its signing key. There is no immediate online revocation promise. @@ -80,12 +80,12 @@ that returns ASN.1 DER must convert it before serialization. `kid` is the 64-character lowercase hexadecimal SHA-256 of the signing public key's SPKI DER representation. -The version 1 claims are a JSON object with these fields, without duplicates +The version 2 claims are a JSON object with these fields, without duplicates or unknown fields: ```json { - "version": 1, + "version": 2, "iss": "https://control.example", "aud": "urn:omnigraph:data:cluster-1", "sub": "principal-id", @@ -96,8 +96,7 @@ or unknown fields: "assurance": "verified_human", "iat": 1788612000, "exp": 1788612900, - "jti": "token-id", - "grants": [{"graph_id": "knowledge", "actions": ["read", "change"]}] + "jti": "token-id" } ``` @@ -125,17 +124,9 @@ including `nbf`, are unsupported in this fixed version and refuse rather than being silently ignored. The issuer may issue a shorter token than the caller requests according to its current permissions and configured limit. -`grants` contains 1–64 unique graph ids. Each id passes the server's existing -graph-id validation: 1–64 ASCII letters, digits, or hyphens, excluding -`policies`, `healthz`, `openapi`, and `graphs`. The managed issuer additionally -requires Core configuration validity, so it issues only for the intersection -of those grammars. Core graph names unsupported by existing server routing -remain unsupported; this increment does not widen routing. Each action set is nonempty, -contains no duplicate, and uses only these existing exact policy action names: -`read`, `export`, `change`, `branch_create`, `branch_delete`, `branch_merge`, -`invoke_query`, `graph_list`. Wildcards, `schema_apply`, `admin`, and unknown -actions refuse the whole token. There are no implicit action implications or -branch grants; Cedar retains branch and target-branch policy ownership. +Credentials contain no grants, roles, actions, or policy membership. Those +fields and unsupported versions refuse the whole token; a version-1 token +cannot become a version-2 identity by removing its grants. ### Public trust and root binding @@ -185,31 +176,27 @@ the permitted issuance-clock skew. Expiry still has zero leeway. ### Authorization and compatibility -The bearer boundary produces an authenticated principal and immutable grant -set. Graph routing narrows it to the selected graph before registry lookup. -The common policy gate checks the token action ceiling before the existing -Cedar decision. Signed credentials require an applied policy and explicit -permit; they never inherit the static-token no-policy read default. An allowed token action is insufficient -if Cedar denies it or has no corresponding actor. No policy entity is created +The bearer boundary produces an authenticated principal. The common Cedar +gate requires applied policy and an explicit permit; signed identities never +inherit the static-token no-policy read default. No policy entity is created from a request or token. Policy bundles explicitly enroll `principal:` through the ordinary cluster configuration and apply path. -| Existing route family | Required action checks | +| Existing route family | Required applied-policy checks | |---|---| -| Query/read, snapshot, Blob GET/HEAD, schema/catalog, branch/commit/change-feed reads | `read` | +| Query, snapshot, Blob GET/HEAD, schema/catalog, branch/commit/change-feed reads | `read` | | Export and change-feed baseline | `export` | -| Mutate/change, conditional mutation, load/ingest | `change` | -| Stored query or mutation | `invoke_query`, then its existing inner `read` or `change` | -| Load creating a branch | Existing `branch_create` check, then `change` | +| Mutate, conditional mutation, load | `change` | +| Stored query or mutation | `invoke_query`, then its inner `read` or `change` | +| Load creating a branch | `branch_create`, then `change` | | Branch create/delete/merge | Corresponding `branch_*` action | -| Schema apply | Refused by this token profile | -| Graph registry | Server Cedar `graph_list`, plus per-result `graph_list` grant | +| Cluster schema/configuration deployment | Existing `schema_apply` and `config_manage` checks | +| Graph registry | Server `graph_list` | -The graph registry filters both served and quarantined graph ids. A grant on -one graph never exposes another. Deprecated route aliases and conditional -variants use the same checks. Merge's optional source deletion keeps its -existing separate check: a successful merge can report a denied deletion -through `branch_deleted: false` without falsely failing the durable merge. +Minimal graph discovery accepts every authenticated identity and carries no +policy grant. Merge's optional source deletion retains a separate policy +check: a successful merge can report denied deletion through +`branch_deleted: false` without falsely failing the durable merge. Explicit static break-glass credentials may coexist. The existing exact constant-time hash match runs first; a configured static credential retains @@ -221,16 +208,15 @@ Cedar/default-deny semantics. ### Managed CLI data access The companion issuance API supplies a data endpoint and signed credential. -`omnigraph cluster token --config DIR --graph ID --actions read,change --ttl 1h` -requests an explicit graph/action subset of the caller's current data grant. -TTL defaults to 3,600 seconds and must be 60–86,400 seconds; requested authority -never comes from control-plane admin or apply permission. `--clear` instead +`omnigraph cluster token --config DIR --ttl 1h` requests a version-2 identity +credential. TTL defaults to 3,600 seconds and must be 60–86,400 seconds. +Issuance adds no policy permissions. `--clear` instead forgets the selected cluster's local data credential. Token command output is metadata only, never the credential. The CLI stores the result in a separate OS-keychain service under canonical API origin plus cluster id. The entry contains the endpoint, credential, -expiry, key id, and exact grants and is validated before use. It is separate +expiry, key id, and cluster incarnation and is validated before use. It is separate from the control-plane session, so ordinary data requests need no API call. Control-plane logout remains unchanged; an issued data credential survives until expiry or trust retirement, and local clearing is not server revocation. @@ -238,7 +224,7 @@ Automation may use the explicit origin-bound control credential to mint into the same keychain; unattended raw-token consumers use the issuance API. Only implicitly addressed `query`, `mutate`, `load`, `commit list`, and -`commit show` consult managed context in the exact current directory. An +`commit show`, plus `graphs list`, consult managed context in the exact current directory. An explicit `--server`, `--profile`, `--store`, or `--cluster` retains ordinary addressing and command-applicability validation, without reading that context; a positional load/commit-list URI or `commit show --uri` does the same. Every other @@ -256,22 +242,23 @@ ordinary ambient resolution, or clear the competing ambient target to use the managed folder. Merely defining profiles, presentation defaults, or a direct actor preference does not select a target. -An unambiguous managed request requires explicit `--graph`, rejects explicit -`--as`, and uses the separately cached data endpoint/credential. Missing, -malformed, expired, or under-scoped credentials never fall through to static -credentials, a profile, or direct storage. Legacy token settings never supply -managed authority. Managed reads refuse redirects and have a 30-second +An unambiguous graph operation requires explicit `--graph`, rejects explicit +`--as`, and uses the separately cached data endpoint/credential; discovery +requires no graph. Missing or expired identity credentials may be acquired +before the operation. Unsupported or malformed caches refuse. No refusal +falls through to static credentials, a profile, or direct storage. Ordinary +token settings never supply managed authority. Managed reads refuse redirects and have a 30-second deadline and 8 MiB response bound. Managed `mutate`, including ad-hoc mutations, stored mutation invocations and branch-write statements, uses the same 30-second request deadline with a 10-second connection bound and the same 8 MiB response bound. Managed `commit list` and `commit show` -use the separately cached `read` grant and the existing native commit +use the cached identity, applied `read` policy, and existing native commit protocol, with the read deadline and response bound. They permit exact graph lineage inspection after an uncertain write without exposing a data credential or opening storage directly. Managed `load` uses the existing authenticated NDJSON endpoint with `change` authority, additionally requiring -`branch_create` when `--from` is present; the server still intersects signed grants -with Cedar and owns branch creation and graph publication. One managed load accepts at most +`branch_create` when it creates the target branch; the server owns Cedar +authorization, branch creation and graph publication. One managed load accepts at most 32 MiB of UTF-8 input, retains the 8 MiB response bound, and uses a 300-second request deadline with a 10-second connection bound. The existing engine's keyed table row and parsed-byte limits remain unchanged. No load request is retried by @@ -285,7 +272,7 @@ requirements. New named managed connections are a separate change. Invariant 10 keeps actor identity at the authenticated server boundary and retains every engine policy check. Invariant 11 bounds token parsing, trust, -key search, scope search, and time. Invariant 12 adds no authoritative token +key search, and time. Invariant 12 adds no authoritative token database or mutable cache. Graph publication, accepted snapshots, recovery, and direct-engine policy behavior are unchanged. No Lance-shaped behavior is changed or relied on, and no Lance documentation domain applies to this HTTP @@ -294,8 +281,9 @@ authentication work. No deny-list exception is requested. ## Compatibility and reversibility Existing deployments without trust are unchanged. New credentials do not work -on old servers; they cannot degrade into static credentials. Removing the -trust flag disables signed credentials deliberately. The trust supplier must +on old servers; they cannot degrade into static credentials. Version-1 restricted tokens, their public data types, and CLI `--actions` are +removed. Old caches refuse without replacement; `cluster token` explicitly +obtains a current credential. Removing the trust flag disables signed credentials deliberately. The trust supplier must not leave a token-only deployment in unauthenticated mode after rollback. No graph storage, ledger, manifest, policy-file, or managed-marker format changes. The public API gains authentication semantics, not a new graph route. @@ -304,8 +292,7 @@ The public `ServerConfig`, `ServingSnapshot`, and `ResolvedActor` retain their pre-data-token field shapes, including literal construction and exhaustive destructuring. Managed embedders use additive root-bound snapshot and server boot entry points. `ResolvedActor` is an identity projection; an opaque -`AuthenticatedActor` retains verified signed claims and the selected graph -through authorization. The existing verifier's identity-only return remains +`AuthenticatedActor` retains verified identity claims through authorization. The existing verifier's identity-only return remains available; server middleware uses its authenticated-result counterpart. Constructing a public identity, including a signed-looking scope, cannot manufacture authenticated request authority. Callers that adopted the interim @@ -317,7 +304,7 @@ root/trust fields migrate to the additive managed APIs described in the - Online introspection or JWKS fetching adds a serving dependency and cannot preserve the required offline contract. - Putting credentials in each graph's static-token store adds rotation and - expiry state when signed, expiring capabilities already express the grant. + expiry state when signed credentials already bind identity and lifetime. - Treating a signature as complete authorization bypasses Cedar and would grant actions or branches the graph owner never allowed. - Importing private managed identity formats into the server couples OSS @@ -326,7 +313,7 @@ root/trust fields migrate to the additive managed APIs described in the ## Evidence and tests -The existing `auth_policy`, `multi_graph`, `stored_queries`, `boot_settings`, +Historical version-1 qualification: the existing `auth_policy`, `multi_graph`, `stored_queries`, `boot_settings`, `data_routes`, and `openapi` owners plus server unit tests passed 359 checks. The eight verifier cases cover the issuer's shared golden signature, tampering, wrong algorithms/keys/bindings, duplicate and oversized fields, @@ -334,7 +321,7 @@ invalid curves, key fingerprints, exact time boundaries, unsupported versions, and kind/assurance mismatches. A focused Core regression verifies that directory and storage-URI snapshots report the same canonical root and state CAS. -Protected-route tests prove the grant ceiling despite permissive Cedar policy, +That historical qualification proved the grant ceiling despite permissive Cedar policy, Cedar denial despite a valid credential, actor-header spoofing failure, stored-read/write double gating, cross-graph and registry isolation, and unchanged static/unauthenticated behavior. Denied mutations leave the published @@ -356,7 +343,7 @@ Record this public contract before code. Implement and qualify the offline verifier, then deploy prepared trust and policy to an existing cluster. Only then enable issuance and client access. Keep a deliberate static break-glass path during the controlled transition. -User docs and release notes must describe the final flag, grant semantics, +User docs and release notes describe the flag, identity and policy boundary, expiry, and controlled key rotation. New engine or storage behavior is out of scope, as are full cloud provisioning, online refresh, and dynamic discovery. @@ -367,6 +354,18 @@ qualification remain separate from acceptance. ## Decision log +2026-10-05: The maintainer requested removal of compatibility interfaces. +Native verification and CLI issuance now accept only version-2 identities; +unsupported caches/tokens refuse, and replacement remains explicit. This +amends the Summary sentence about intersecting grants; the Signed credential +version, claims example and grant-list rules; the Authorization paragraphs, +route table and filtered-registry rule; the Managed CLI `--actions`, cache, +local grant checks and load authorization sentences; and Compatibility's +retained version-1 types and selected-graph clauses. Earlier version-1 evidence +is labeled historical. Current verifier, cache, policy and route owners test +version-1 refusal and preserve identity binding and applied-policy checks; +this entry does not claim a completed qualification run. + 2026-09-08: Managed queries and mutations use a bounded 30-second request deadline. Loads retain their separate 300-second deadline. The 10-second connection deadline, response limit, redirect refusal and disabled automatic diff --git a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md index f6f7989a1..85a6ac47c 100644 --- a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md +++ b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md @@ -7,7 +7,7 @@ implementation: complete authors: - andrew created: 2026-09-09 -updated: 2026-09-16 +updated: 2026-10-05 discussion: https://github.com/ModernRelay/omnigraph/pull/691 supersedes: [] superseded_by: [] @@ -18,8 +18,7 @@ blocked_on: [] ## Summary -Add an identity-only version 2 signed credential alongside the restricted -version 1 profile in [RFC 0053](0053-offline-data-token-verification.md). +Use the identity-only version 2 signed credential described in [RFC 0053](0053-offline-data-token-verification.md). The credential authenticates a principal for one cluster; the cluster's applied policy configuration defines graph and schema permissions. Authenticated users can discover every graph's existence without obtaining @@ -27,10 +26,8 @@ permission to read its data or schema. Add an identity-authorized cluster planning/apply API that checks the same policy engine against the current applied configuration before effects. -Existing direct storage-holder APIs and version 1 restrictions remain intact. -Version 2 is the normal credential profile. Version 1 remains an explicit -compatibility interface for existing restricted clients; its presence does -not require a new deployment to operate both profiles. +Existing direct storage-holder APIs remain intact. Native signed credentials +use version 2 only; version-1 credentials and restricted issuance are refused. ## Motivation @@ -62,10 +59,9 @@ select the new minimal endpoint with `omnigraph graphs list --discovery --server `. No JWT-shaped static token is reinterpreted to select a different command. -Explicit legacy `--actions` requests retain version 1 restrictions. Existing -cached restricted credentials never become identity-only credentials through -renewal, permission denial, or omission of a field. Unsupported profiles -refuse without switching credentials or storage addressing. +CLI `--actions` is removed. Unsupported caches refuse without automatic +replacement; `cluster token` explicitly requests a current identity credential. +Permission denial never switches credentials or storage addressing. `GET /graphs/discovery` returns only `{"graphs":[{"graph_id":"knowledge","display_name":"knowledge"}]}` @@ -73,7 +69,6 @@ for a valid version 2 identity. It uses the effective inventory, including unavailable graphs. No policy group or `graph_list` permit is required. Graph data, schema, query bodies, roots, diagnostics and topology are absent. The existing `/graphs` response and authorization retain their contract. -Version 1 credentials cannot use discovery to evade their existing filters. Display names currently equal graph IDs. Inventory availability presumes a running server; an invalid cluster policy can still fail existing boot validation rather than silently disabling that validation. @@ -92,9 +87,9 @@ account, cluster/incarnation, assurance and temporal checks of version 1. Its claims contain `version: 2`, `iss`, `aud`, `sub`, `account_id`, `cluster_id`, `cluster_incarnation`, `principal_kind`, `assurance`, `iat`, `exp`, and `jti`. It contains no `grants`, roles or policy membership. -Unknown or duplicate fields refuse; removing `grants` from a version 1 -credential does not change its profile. The verifier privately represents -the profiles distinctly and preserves public version 1 construction APIs. +Unknown or duplicate fields and unsupported versions refuse; removing `grants` +from a version-1 credential cannot make it valid. Only the strict +`IdentityTokenClaims` type represents native signed credentials. The token remains bounded to 8,192 bytes, lifetime 60–86,400 seconds, default 3,600 seconds, no expiry leeway and at most 30 seconds future issue time. @@ -102,11 +97,9 @@ Public trust and canonical-root binding retain RFC 0053's bounds. Verification and policy evaluation have no synchronous issuer dependency. `principal:` remains the immutable authenticated actor; caller overrides cannot replace it. -Credential acquisition explicitly requests `version: 2`; an omitted version -retains the version 1 request contract. Version 2 responses explicitly report +Credential acquisition explicitly requests `version: 2`. Version 2 responses explicitly report their version, identity, endpoint and expiry metadata without a grant list. -The client checks the response profile and keeps separate versioned cache -metadata. The issuer must check current identity and exact cluster admission; +The client checks the response version and keeps versioned cache metadata. The issuer must check current identity and exact cluster admission; it must not exchange a restricted credential for a broader one. Issuer policy enrollment and lifecycle remain outside this library's trust boundary. @@ -182,9 +175,10 @@ documented trust boundary, never an automatic fallback from identity denial. ## Compatibility and reversibility -Version 1 tokens retain their ceilings, filtered catalog and schema exclusion. -Existing static/unauthenticated modes, direct APIs and public version 1 data -types retain their behavior. New authorized entry points are additive. +Version-1 tokens, cache entries and public claim/grant types are unsupported; +CLI `--actions` is removed. A new explicit `cluster token` request replaces an +old cache. Existing static/unauthenticated modes and direct APIs retain their +behavior. Authorized entry points remain additive. The public exhaustive policy enums gain `ConfigManage`/`Cluster` variants; embedders with exhaustive matches must add the corresponding arms. Policy configuration using `config_manage` requires a supporting binary; old binaries @@ -210,16 +204,16 @@ Teach each caller to evaluate policy: duplicates semantics and permits drift. Extend the server token/auth-policy and catalog suites, CLI token/cache and dispatch suites, policy tests, and cluster plan/apply tests. Required cases: -- Version 1 remains restricted and unchanged; malformed cross-profile claims - and invalid signature/time/root/identity fail. +- Version-1 credentials and caches refuse without graph effects or automatic + replacement; invalid signature/time/root/identity and permission claims fail. - An authenticated unenrolled principal sees every graph ID/name, including an unavailable graph, and cannot read data/schema or private catalog fields. - Allowed and denied schema/config effects follow the applied policy; a candidate self-grant, wrong actor or stale base fails before effects. - A policy activation changes permissions for the same identity credential. - Bootstrap cannot be repeated on an initialized or policy-free cluster. -- Old CLI restriction flags, cache entries and automation exchanges cannot - silently gain authority; explicit addressing stays compatible. +- Removed CLI restriction flags fail parsing; explicit addressing retains + its independent authority and never falls back from managed refusal. - OpenAPI accurately describes the additive discovery response. The implementation changes authentication and authorization around existing @@ -238,13 +232,9 @@ execution boundary then activates with compatible server/client versions. Retained graphs, configuration and accepted operations require their existing storage and ownership qualification regardless of the authentication cutover. -The published version 1 verifier, data types and explicit restricted CLI -requests remain compatible. Issuers that continue serving restricted clients -retain their exact ceilings; unsupported requests refuse without widening them. -An issuer may stop producing version 1 credentials once it has accounted for -its clients. Retiring their verification trust also requires waiting at least -86,430 seconds after the last issuance. This bounds old credential validity; -it is not a requirement to build a legacy issuance path in new deployments. +Upgrade callers to version-2 issuance and explicitly replace old caches. +There is no dual-profile rollout or automatic conversion of restricted tokens. +Existing signed identities and current policy remain the authority boundary. ## Unresolved questions @@ -253,6 +243,15 @@ of a particular deployment remain separate from the accepted library contract. ## Decision log +- 2026-10-05: The maintainer requested removal of compatibility interfaces. + This replaces Summary's retained version-1 profile, User behavior's + `--actions`/restricted-cache and filtered-discovery sentences, Credential + profiles' dual parser/public type and omitted-version contracts, + Compatibility's version-1 preservation, Evidence's legacy-success cases, + Rollout's retained verifier/issuer clauses, and Provider-native access's + restricted-token compatibility promises. Version 2 and applied policy stay + authoritative; unsupported tokens/caches refuse and explicit issuance is + required to replace a cache. Focused implementation gates remain required. - 2026-09-09: Proposed the separate identity profile, authenticated minimal discovery, and authorization against applied policy, retaining legacy restrictions and direct storage-holder behavior. @@ -274,14 +273,11 @@ serialized refresh with a pending-before-send marker and atomic credential replacement, and no automatic retry after an uncertain exchange. Old managed sessions require new login. The API and CLI switch together; no broker compatibility path or gradual authentication migration is required. -Direct/static configuration remains supported. Published restricted -data-token verification is a separate compatibility contract and does not -retain the retired login broker. +Direct/static configuration remains supported. Normal native data commands acquire a self-only identity credential before execution. A permission refusal never triggers login or mutation replay. -Native restricted credentials retain their existing verifier semantics; a -restricted cache is never silently promoted to a less restricted profile. +Unsupported caches and tokens refuse without automatic replacement. An additional generic server profile accepts externally signed human OAuth resource tokens. `--oidc-identity-trust FILE` loads public JSON containing a diff --git a/docs/rfcs/2026-09-16-session-settings.md b/docs/rfcs/2026-09-16-session-settings.md index 81670a551..d998aea99 100644 --- a/docs/rfcs/2026-09-16-session-settings.md +++ b/docs/rfcs/2026-09-16-session-settings.md @@ -7,7 +7,7 @@ implementation: in-progress authors: - azimafroozeh created: 2026-09-16 -updated: 2026-09-27 +updated: 2026-10-05 discussion: null supersedes: [] superseded_by: [] @@ -92,7 +92,7 @@ logic-test runner; this document gives it the statement and nothing else. The boundary that does not change: nothing is written to `__manifest` or anywhere in the store; the `/branches` routes and every response type keep their fields; three request types gain one optional field, `settings`, -honored at four routes and refused at the two deprecated ones, and the two +honored at four routes, and the two `GET` change routes one optional `set` query parameter; there is still no current branch, checkout or identity carried between requests, since every request keeps naming its branch and its actor; @@ -214,16 +214,10 @@ door (defined below: RFC 0055's word for the route or CLI verb a statement enters through), which is `POST /query` and `omnigraph query`, and takes the scope-free `read` authorization `GET /branches` and `branch list` take (`authorize_scope_free_read`, `omnigraph-server/src/handlers.rs`: the -graph's Cedar `read` gate with no branch in the request). The deprecated -`POST /read` and `POST /change` serve no statement and refuse a `settings` -field and any `set` or `reset` prefix alike, with HTTP 400 `the deprecated -/read and /change routes take no settings, neither a settings field nor a -set or reset prefix; use POST /query or POST /mutate` -(`query_file_refusals::SETTINGS_AT_DEPRECATED_ROUTE`); a `show` at `/read` -is RFC 0055's `DEPRECATED_ROUTE` refusal. At the write door (below), `/change` -included, `show` is refused with the same HTTP 400 that RFC 0055 gives +graph's Cedar `read` gate with no branch in the request). At the write door +(below), `show` is refused with the same HTTP 400 that RFC 0055 gives `branch list` at `/mutate`, the statement in the message being `show engine` -or `show all`. +or `show all`. The removed `/read` and `/change` routes return 404. A name outside the definition, a value of the wrong type, a value outside the declared values or range, and a `process` setting arriving in a request are @@ -307,9 +301,9 @@ through. A setting reaches the engine through three of them. **CLI.** `omnigraph query` and `omnigraph mutate` parse the source before sending, as they do today for a branch statement (`omnigraph-cli/src/main.rs:1155`, -`:1221`). Both, the verbs that run a routed operation without GQ text +`:1221`). These commands, the verbs that run a routed operation without GQ text (`omnigraph branch merge`, the commit-changes and change-feed verbs), and -`omnigraph load` and `omnigraph ingest` gain a repeatable `--set name=value`; +`omnigraph load` accept a repeatable `--set name=value`; the value is written in GQ spelling, the same as after `=` in a `set` line (`--set engine=v2`, `--set merge_lineage=verify`), and every `--set` goes through `SettingId::parse_assignment`, the one `name=value` door, shared @@ -318,15 +312,15 @@ with the `set=` query parameter (HTTP). Embedded (`GraphClient::Embedded`, environment and the `--set` values and calls it with the file, whose `set` lines apply to that call (Prefix application, Design); a `process` setting is accepted from all three, by the `process` scope rule (The settings), and -an embedded `load` or `ingest` applies its `--set` to the session it loads +an embedded `load` applies its `--set` to the session it loads through. Remote (`GraphClient::Remote`, `client.rs:71`), the CLI sends the GQ text unchanged and the `--set` values in the request's `settings` field, so the server sees the same two inputs an embedded run does, and applies the scope rule; a remote `--set` of a `process` setting is refused by the CLI before anything is sent, with the fifth message, since the typed field -cannot carry it. A served `load` or `ingest` refuses any `--set` with `load -and ingest take --set only on an embedded store; the served load and ingest -routes carry no settings field` (`SETTINGS_AT_SERVED_LOAD`, +cannot carry it. A served `load` refuses any `--set` with `load +takes --set only on an embedded store; the served load routes carry no settings +field` (`SETTINGS_AT_SERVED_LOAD`, `omnigraph-cli/src/client.rs`), since neither route's request type has a field to carry it and a value could only be dropped. On the commit-changes and change-feed verbs a `--set` is validated and sent as `set=`; nothing @@ -338,15 +332,7 @@ per-declaration line `output.rs:145-150` prints today. **HTTP.** Three request types carry the field to a settings-aware operation: `POST /query` (`QueryRequest`), `POST /mutate` and `POST /mutate/if-graph-commit` (`ChangeRequest`; the field is honored alike -on both), and `POST /branches/merge` (`BranchMergeRequest`). The deprecated -`POST /change` carries `ChangeRequest` too, and the deprecated `POST /read` -(`ReadRequest`) carries `settings` as raw JSON only so the refusal can name -the field; both serve no statement and refuse a present `settings` field and -any `set` or `reset` prefix in the source with the -`SETTINGS_AT_DEPRECATED_ROUTE` message (The statements; -`omnigraph-server/src/handlers.rs`, `server_read` and `server_change`; -`handlers/dispatch.rs`, `refuse_settings_at_deprecated_route`), running -their legacy bodies under the process defaults alone. The three types gain +on both), and `POST /branches/merge` (`BranchMergeRequest`). The three types gain one optional field, `settings`: `SettingsRequest`, hand-written in `omnigraph-api-types` with one `Option` field per `request` row, `#[serde(deny_unknown_fields, rename_all = "snake_case")]`, @@ -817,31 +803,23 @@ known gap changes. ## Compatibility and reversibility **Wire.** Within the Summary's boundary, the wire change is one optional -field, `settings`, on three request types, honored at four routes and -refused at the deprecated `/read` and `/change`, and one optional repeatable -query parameter, `set`, on the two `GET` change routes; absent means the -process defaults. Additive: a client that never sends them sees no change. -An older server that does not know the field drops it silently (the three -request types carry no `deny_unknown_fields`, `api-types/src/lib.rs:191`, -`:708`, `:734`, `:889`) and answers 200 under `v1`. That is accepted as a -tradeoff: an older server drops the field, which is harmless for the -answer-preserving settings and changes approximate rows for `ann_nprobes`; -a client that needs the cap checks the server version, and a client that -must know sends a `show` first (`show merge_lineage;` at rollout -step 1, `show engine;` after step 2), which an older server answers with a -parse-error 400. The parameter -an older server refuses the way it refuses any unknown change-surface -parameter (`handlers.rs:2840`). The field is a typed struct, so the OpenAPI -golden (`crates/omnigraph-server/tests/openapi.rs`) shows each `request` +field, `settings`, on three request types, honored at four routes, and one +optional repeatable query parameter, `set`, on the two `GET` change routes; +absent means the process defaults. CLI and server require the v0.12 HTTP +contract; older-server fallback is unsupported. Query and mutation request +bodies refuse unknown fields. The typed field means the OpenAPI golden +(`crates/omnigraph-server/tests/openapi.rs`) shows each `request` setting's type and values, and a future `request` setting is a visible schema change. No response type changes. The routes that reach no `request` -setting take nothing: `/schema`, `/schema/apply`, `/snapshot`, `/commits`, +setting take nothing: `/schema`, `/snapshot`, `/commits`, `/commits/{commit_id}`, `/blob`, `/export`, `/changes/baseline`, `/load`, -`/load/ndjson` and `/ingest` (these three reach only the `process` setting +`/load/ndjson` (these two reach only the `process` setting `stage_write_concurrency`), `/branches` create, delete and list (`GET /branches` included), `/graphs`, `/queries`, `/openapi.json`, the health routes; `POST /queries/{name}` and `POST /queries/{name}/if-graph-commit` -are Unresolved question 1. +are Unresolved question 1. `/read`, `/change`, `/ingest`, and `/schema/apply` +are absent. Standalone `schema apply` uses direct storage; served schema changes +use `cluster apply --server URL --config DIR`. **Language.** `set`, `reset` and `show` are legal only before the first statement of a file, so every file that parses today keeps its meaning; a @@ -1032,10 +1010,8 @@ refused with HTTP 400 and the fifth message, and in the `settings` field with HTTP 400 and serde's unknown-field message; the typed `settings` field round-trips each `request` setting and its `show` source is `request`; text overrides the field; `POST /mutate/if-graph-commit` honors the field like -`/mutate`; `show engine;` at `/mutate` is the wrong-door 400 and at `/read` -the `DEPRECATED_ROUTE` refusal; a `settings` field, and a `set` or `reset` -prefix in the source, at `/read` and at `/change` is 400 -`SETTINGS_AT_DEPRECATED_ROUTE`; a file of only `set` lines is 400 `a file of +`/mutate`; `show engine;` at `/mutate` is the wrong-door 400; removed routes +return 404 without graph effects. A file of only `set` lines is 400 `a file of only settings lines carries no statement` at `/query` and `/mutate`; `GET /changes?set=engine=v2` records a planner decision (rollout step 2; at step 1 `?set=merge_lineage=off` is accepted and selects nothing) and @@ -1200,3 +1176,12 @@ routed) follows step 2. reworded. The rest of the body's `v1` text records the draft. Engine v1 is the frozen test reference `omnigraph-reference-engine`, reached only through a GQT step's `--- expect same as v1`. + +- 2026-10-05: The maintainer requested removal of compatibility interfaces. + The Summary, statements, transport doors, wire compatibility, and server + tests now omit retained `/read`, `/change`, `/ingest`, and `/schema/apply` + behavior. This replaces their former settings-specific refusal contracts + and the older-server fallback with the v0.12 contract. CLI `ingest` is + removed; `load` retains its embedded settings behavior. `schema apply` is + direct-only and served schema changes use cluster deployment. The earlier + decision-log entries record the original rollout. diff --git a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md index edb02590f..1b7339d23 100644 --- a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md +++ b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md @@ -7,7 +7,7 @@ implementation: in-progress authors: - OmniGraph maintainers created: 2026-09-29 -updated: 2026-10-05 +updated: 2026-10-06 discussion: https://github.com/ModernRelay/omnigraph/pull/799 supersedes: - "0034" @@ -30,7 +30,7 @@ blocked_on: The server owns admitted writes through completion and reports exact outcomes and graph availability. Ledger v2 is the sole deployment protocol: direct apply bootstraps a stopped cluster; server-owned apply changes schemas and stored -queries, policy/provider/Blob bindings and graph inventory without restarting. +queries, policy/provider/Blob bindings and graph additions without restarting. The engine publishes graph contents and their accepted schema together; the existing cluster ledger owns deployment input and applied results. No new transaction manager, content-recovery log or job queue is added. @@ -76,14 +76,15 @@ captures coherent request bindings and supports atomic activation of a deployment's affected views. Startup loading remains observable; transient startup retry is separate work. -The deployment class covers graph creation, schemas, stored queries, graph and -cluster policies, embedding-provider definitions and bindings, and external-Blob -rules. Exact lifecycle confirmations authorize graph removal, adoption and -recreation; explicit catalog repair restores digest-verified payloads. Existing -graph roots, storage format and credential/trust configuration stay fixed. Schema apply remains main-only and -requires a single live branch. Unsupported migrations refuse before effects; -dropping declarations does not reclaim retained data or require a destructive- -schema override. +The deployment class covers graph creation and physical deletion, schemas, +stored queries, graph and cluster policies, embedding-provider definitions and +bindings, and external-Blob rules. Adoption, missing-root recreation, +schema-drift acceptance and catalog repair are outside ordinary deployment. +Retained graph roots, storage format and credential/trust configuration stay +fixed. Schema apply remains main-only and requires a single live branch. +Unsupported migrations refuse before effects; dropping schema declarations does +not reclaim retained data or require a destructive-schema override. Removing a +graph declaration deletes the whole managed root, including retained history. The following are outside this RFC: - independently available historical reads during a schema transition; @@ -225,8 +226,8 @@ epochs. Preparation and resumption bind the real process runtime and share shutdown's synchronous boundary. The batch validates schema and query replacements together. A runtime-binding replacement shares the existing engine coordinator, writer gates, schema authority and Lance sessions without reopening -storage; its policy, provider client and Blob admission remain immutable. New, -adopted and explicitly recreated graphs supply validated handles. A completed refusal before effects +storage; its policy, provider client and Blob admission remain immutable. New +graphs supply validated handles. A completed refusal before effects can explicitly abort, even after a drain deadline, restoring unchanged views under fresh epochs. Their gates retain old descendants, so a subsequent transition still waits for those owners. Expired or abandoned attempts without @@ -289,7 +290,7 @@ conversion is the only legacy reader and preserves existing graph data. | `version`, `ledger_id`, `state_revision`, `next_sequence` | Strict encoding; immutable ledger incarnation (ULID); checked monotonic replacement and deployment counters. A canonical deployment ID is `::`; sequences start at 1. | | `applied_revision` | Serving-sufficient resource projection, an exact-inventory `schema_contracts` map, and `result_revision`; this revision advances only when achieved configuration changes. Full convergence alone sets `config_digest`. | | `outstanding` | Null or one immutable input/base/authority identity, per-graph prepared intents and mutable exact outcomes, optional persisted settlement intents, and reserved completion capacity. | -| `deployment_results` | Bounded terminal records: original ID/input/base, exact outcomes, achieved-result reference, initiating actor and recovery executors. Optional server activation witness; direct execution records no runtime claim. | +| `deployment_results` | Bounded terminal records: original ID/input/base, exact outcomes, achieved-result reference, initiating actor and recovery executors. No persisted runtime activation or restart-needed claim. | Submission reserves **one outstanding deployment per cluster** under lock/CAS. A competing new ID refuses busy; there is no queue, supersession or timeout that @@ -318,27 +319,28 @@ inactive or partially converged predecessor. Each applied `schema_contracts` entry retains the engine-issued source hash, accepted IR hash, schema identity domain and identity version for that graph. Conversion captures these from one coherent accepted engine view; successful -schema outcomes advance them with the resource projection. Refusal and -not-attempted outcomes preserve them. Bind the same map in the achieved base, +schema outcomes advance them with the resource projection; completed deletion +removes the graph's contract and resources together. Refusal and not-attempted +outcomes preserve them. Bind the same map in the achieved base, reserve its encoded capacity, and compare the full identity before serving or accepting changes to that graph, including runtime-only changes and after terminal receipt eviction. Unaffected graphs are not opened during preparation; -their unavailability does not prevent an independent deployment. Matching or changed source text alone cannot adopt a recreated graph -or different accepted IR. Explicit schema correction supplies a map from graph -ID to the full observed contract through `--schema-correction FILE`. Bind this -map into the immutable deployment input, compare every acknowledgement against -the same observed contract used for preparation, and refuse unknown, stale or -unneeded entries before effects. Correction still requires current schema-apply -authority, even when its source is unchanged; a successful exact no-op may -establish the acknowledged contract. Status and boot never manufacture one. +their unavailability does not prevent an independent deployment. Matching or +changed source text alone cannot adopt a replacement graph or different accepted +IR. New preparation refuses unexpected roots and mismatched accepted contracts. +Missing managed roots refuse unless the requested effect is deletion. Accepted +deletion resumes its recorded root through absence, as specified below. Restore authoritative state +before a new deployment to a damaged graph; +status and boot never manufacture an identity or accept drift. + The immutable input contains normalized configuration semantics and exact schema and stored-query bytes, including query deletions. Persist and verify their content-addressed bytes before acceptance. Roots and format remain fixed; runtime-binding changes are explicit differences from the applied projection. -New graph declarations receive exact prepared creation identities. Lifecycle -confirmations are immutable input, never a bypass around current authority. -Validate the whole +New graph declarations receive exact prepared creation identities. Removed +graph declarations carry deletion intents bound to the current root and applied +schema contract. Validate the whole effect set and every query against its intended accepted schema before the first schema effect. Execution and recovery never reread mutable configuration files. There is no digest-based sweep/import/refresh execution path. @@ -353,9 +355,12 @@ not bypass admission. The server retains ownership for its entire lifetime. While `outstanding` exists, serving and other writers/cleanup refuse even after a previous lock is removed; only the original executor or an explicitly admitted reconciler can progress it. Read-only ledger lookup remains available. -The executor performs no sweep, cleanup or native reference mutation; schema -staging/publication must keep native automatic cleanup disabled. This exclusion +The executor performs no recovery sweep or Lance version cleanup; schema +staging/publication keeps native automatic cleanup disabled. This exclusion protects predecessor and candidate evidence until the terminal result CAS. +Explicit whole-graph deletion instead removes the root named by its durable +intent, after that graph's users have drained; no retained version in that root +survives the operation. Read-only CLI graph consumers use a read-only opener and validate canonical membership, captured ledger authority and the accepted contract without taking @@ -396,7 +401,9 @@ principal in the accepted deployment. Candidate policy never authorizes a change to an existing graph; new graph bindings are part of the authorized creation input. No persisted approval is a capability. Recovery/lookup check the **current** caller's required authority: identity-authorized recovery requires existing `ConfigManage` plus -graph `SchemaApply` and `Read` for schema settlement. Deployment status and result +graph `SchemaApply` and `Read` for schema settlement or graph deletion. Direct +deletion enforces those same graph actions whenever a policy is installed. +Deployment status and result metadata require current `ConfigManage`, without graph-data `Read` on unrelated graphs; they disclose no rows or stored query/policy source. No new policy action or read-only right permits fence publication. Recovery can @@ -424,7 +431,7 @@ actors and collections before constructing the plan. Prepare **all** engine intents before the first schema effect: source limits do not bound encoded IR, escaping or duplicated predecessor/desired contracts. Preflight the encoded outstanding record plus worst-case exact receipts/refusals and settlement -intents and activation witnesses, including their IDs/actors, against the ledger +intents, including their IDs/actors, against the ledger and result limits. Reserve that completion capacity at acceptance; refuse oversized work before any schema effect, not after an earlier graph has published. No later progress @@ -525,8 +532,8 @@ A valid occupied candidate proves which publication won. A foreign occupant must be verified from that version's own identity and contract, not inferred from HEAD. Its presence can prove the original `NotPublished` but does not authorize adopting foreign schema into the applied projection. If the accepted contract no -longer matches that projection, report drift and refuse serving until explicit -authorized correction validates their agreement. Neither old applied digests nor +longer matches that projection, report drift and refuse serving until +authoritative state is restored and their agreement is validated. Neither old applied digests nor requested input can be relabeled as achieved to hide that mismatch. A lost fence acknowledgement is reconciled by its persisted identity. Another @@ -545,11 +552,11 @@ independently of configuration discovery or current input files. | Command | Behavior | |---|---| -| `omnigraph --cluster ROOT cluster upgrade-ledger --writers-stopped` | Explicit conversion to ledger v2; validate existing applied resources and v14 graphs, then conditionally replace the ledger. | +| `omnigraph --cluster ROOT cluster upgrade-ledger --writers-stopped` | Explicit stopped conversion of v1 to v2, or removal of obsolete completed-result activation fields in v2; conditionally replace the ledger without graph effects. | | `omnigraph --cluster ROOT cluster status [--deployment-id ID]` | Bounded authorized lookup; return incarnation, next sequence, outstanding full ID and lock identity without graph opens or effects. | -| `omnigraph cluster plan --config DIR [--lifecycle FILE]` | Run the same read-only preflight with the intended actor; show lifecycle work even when source digests are unchanged. | -| `omnigraph cluster apply --config DIR [--deployment-id ID] [--lifecycle FILE]` | Bootstrap or execute under direct exclusive admission; allocate and emit the original ID unless supplied. | -| `omnigraph cluster apply --server URL --config DIR [--deployment-id ID] [--lifecycle FILE]` | Submit captured input to the running owner; keep the PID/listener and activate coherent schema/query/policy/provider/Blob bindings and explicit graph lifecycle changes. | +| `omnigraph cluster plan --config DIR` | Observe without a writer lock; derive migration steps from the same captured preparation with the intended actor. | +| `omnigraph cluster apply --config DIR [--deployment-id ID]` | Bootstrap or execute under direct exclusive admission; allocate and emit the original ID unless supplied. | +| `omnigraph cluster apply --server URL --config DIR [--deployment-id ID]` | Submit captured input to the running owner; keep the PID/listener and activate coherent schema/query/policy/provider/Blob bindings and graph additions. | | `omnigraph cluster status --server URL [--deployment-id ID]` | Authorized ledger lookup plus an observation of whether that result is active in this process. | | `omnigraph --cluster ROOT cluster apply --deployment-id ID --writers-stopped` | Reconcile that original deployment only; never submit, use new files or replay schema effects. A terminal result is lookup-only. | | `omnigraph --cluster ROOT cluster force-unlock LOCK_ID` | Exact-ID release under the operator-exclusion/quiescence procedure; outstanding evidence remains intact. | @@ -575,37 +582,55 @@ no outstanding/result records. Write verified referenced resources before the single conversion CAS; an interrupted/lost-ack conversion rereads the root and recognizes the same admitted v2 ledger. No automatic migration/downgrade occurs. Graph-format conversion remains separately gated by the storage-upgrade decision. - -### Explicit lifecycle and repair - -`cluster plan` and `cluster apply` accept `--lifecycle FILE`, a bounded JSON -`DeploymentOptions` value captured with the sources. `schema_corrections` is the -existing exact-contract correction map; `--schema-correction` is its mutually -exclusive shorthand. No v1 executor, approval artifact or implicit refresh is -introduced. - -- `delete_graphs` and `adopt_graphs` map IDs to the exact schema contract and - observed graph manifest version. Removal must agree with the desired inventory; - adoption requires a readable graph at the canonical derived root and matching - desired schema. Stale or unnecessary confirmations refuse before effects. -- `recreate_graphs` acknowledges the previous achieved schema contract of a - missing managed graph. Its entire derived root must be absent. Creation uses - the existing prepared-birth protocol and records a new identity; it cannot - overwrite partial, foreign or retained data. -- `repair_catalog` names exact policy/query resource addresses to restore from - content-addressed desired source bytes. Only those repairs affect admission. A storage owner may supply a missing applied policy only when - those bytes match its already-recorded digest. An authenticated caller cannot - authorize itself using a replacement for unreadable current policy. - -Graph removal unregisters its serving inventory and retains storage, rows, -branches and history; the result explicitly reports retained storage. Reuse of -that root requires exact adoption. Physical reclamation is not part of this -ledger-only effect and cannot be inferred from a removal receipt. Preserve a -removed runtime's engine owner by canonical URI until restart; exact readoption -reuses it. Bound active plus retained owners at 2,048 and refuse additional roots -before effects. Recreation refuses while a live or retained engine still owns -that root, because draining request descendants does not prove native disposal. -Startup-blocked entries without an engine can be recreated online. +The same explicit converter removes obsolete `activation` and `restart_required` +fields from completed v2 receipts after validating their old shape. It preserves +ledger identity, sequence and exact achieved outcomes and advances the ledger +CAS revision once. Ordinary readers remain strict; unknown fields, unsupported +receipt variants and outstanding work refuse conversion. Repeating a completed +conversion is a no-op. + +### Deployment scope and preparation + +Ordinary apply creates graphs, deletes removed graphs and changes configuration. +It has no lifecycle options file, unregister operation, schema-correction +override, graph adoption, missing-root recreation or catalog repair mode. Missing +or corrupt authoritative payloads must be restored; candidate sources cannot +substitute for current permissions. + +Removing a graph declaration is destructive. Plan reports the delete effect; +apply authorizes it through the currently applied `ConfigManage` policy and +existing exclusive root admission. The intent binds the canonical derived graph +root and its applied schema contract before effects. A present root must match +that contract; an already-absent root can complete deletion. The same outstanding +ledger record persists `Started` before the first removal, then records +`Deleted` only after root absence is verified. Only that completed result removes +the graph's applied resources and contract. No second deletion ledger or queue +is introduced. + +The delete traverses only the exact managed graph root. It removes every branch, +retained version, staged file, index and managed Blob object there; it never +follows external Blob references or a sibling-root prefix. This is whole-graph +lifetime deletion, not Lance cleanup with a retention window. Object-store +completion means active-namespace absence, not purging provider versions or +bypassing retention. A failure after `Started` retains outstanding authority +and admission; timeout does not prove a remote delete request has settled. +Accepted deletion cannot become a not-attempted or refused terminal outcome. +After supported owner handoff, +reconciliation resumes the original ID and root even if the manifest is already +gone, verifies absence, and completes the result. A readable foreign contract +refuses recovery; an unreadable partial root remains owned by the original +intent under the stated quiescence boundary. Recovery does not create missing +storage, adopt replacement contents or reinterpret changed configuration as a +cancellation. A repeated completed-ID lookup never deletes again. + +Online removal closes affected admissions and drains tracked request descendants +and response-body ownership before touching storage. The existing exclusive +owner remains responsible for accepted writes; uncertain completion keeps the +graph closed and triggers containment. Generic drain is not a new proof of all +native-I/O settlement and does not authorize admission-lock release or arbitrary +runtime reuse. Offline deletion and recovery retain stopped-owner/accepted-I/O +quiescence requirements and exclude raw or older writers. There is no retained +owner pool or readoption path. Policy grants, revocations and rebinding use the currently applied authority. Removing a provider still referenced by desired graphs fails configuration @@ -614,9 +639,10 @@ root-disjointness and server-safe projection checks. A terminal partial result publishes only achieved graph state; serving must install achieved management policy too, so a policy handoff cannot strand the corrective deployment. -Plan runs the shared effect-free preparation for the captured lifecycle options -and actor. Missing graphs, schema drift, unsupported migrations and branch -restrictions are errors, not successful plans with a warning. Apply repeats +Plan always observes without writer admission. It captures source bytes and +the ledger once, and derives migration steps from shared effect-free preparation +for that input and actor. Missing retained graphs, schema drift, unsupported +migrations and branch restrictions are errors, not successful plans with a warning. Apply repeats those checks under writer admission; a plan is not a reservation or permission to replay a deployment. @@ -639,18 +665,29 @@ future belongs to the server through completion even if its HTTP caller leaves. Before any effect, validate captured input through the shared serving projection, resolve required provider secrets and validate all changed runtime bindings. Close only -affected graph admissions in one batch. Drain their request descendants, then execute prepared -intents using their existing engine handles and the server's retained root -admission. Unaffected graphs continue serving. Install exact achieved schema +affected graph admissions in one batch. Drain their request descendants, then +execute prepared intents under the server's retained root admission, using +existing engines for retained graphs. Deletion keeps its predecessor owned until +the destructive operation settles; it never serves a partially deleted root. +Unaffected graphs continue serving. Install exact achieved schema contracts, validated query registries, engine runtime bindings, management policy -and graph inventory in one registry snapshot under the shutdown/attempt/deadline fence. Deterministic pre-effect +and graph inventory in one registry snapshot under the shutdown/attempt/deadline +fence. Remove an inventory entry only for an exact durable `Deleted` outcome +belonging to this closed transition; never retain it for re-adoption. +Deterministic pre-effect refusals resume the predecessor views under new epochs. Uncertain effects retain closed admission and the existing process-containment rule. -After activation, persist a witness binding the achieved revision and config -digest to this admission incarnation. Status reports `active` only when the -witness belongs to this process, still matches the current achieved revision, -and affected retained graphs remain ready while removed graphs remain absent. Boot revision/digest stays a boot fact. +Publish the activation identity in the same immutable registry snapshot as the +installed bindings. It contains canonical root, admission incarnation, original +ID/input digest, achieved revision and config digest. Keep the durable achieved +receipt unchanged; there is no post-activation ledger CAS or mutable durable +restart-needed flag. Status reports `active` only when this exact identity +matches the requested current receipt, every graph is ready under its installed +schema contract, and process admission remains open. Partial convergence, +loading, blocked or transitioning graphs and shutdown report inactive. Ordinary +row commits do not invalidate unchanged deployment bindings. Boot revision/digest +stays a boot fact. Original-ID resubmission is lookup-only before graph closure, including a historical inactive result. A late attempt cannot install an older view. A terminal inactive or partially converged predecessor permits a corrective @@ -797,8 +834,12 @@ This logical startup owner supplies no native-settlement or reuse proof. No historical-only readiness mode is introduced. Keep boot revision/digest as boot facts. Direct apply records achieved results -without a runtime claim; online activation records a process-incarnation witness -for the achieved revision and its digest. Never infer activation from boot facts or successful apply. Status reads bounded snapshots outside blocked data lanes; +without a runtime claim. Online activation installs exact identity in the +registry snapshot alongside verified bindings. A normal configured boot captures +the latest matching converged receipt under root admission, then establishes +fresh runtime evidence as its verified opens complete. Generic embedding +constructors do not attest deployment bindings. Missing/expired receipt identity +is not inferred from schema text or a successful apply. Status reads bounded snapshots outside blocked data lanes; it cannot open graphs, start completion or reconstruct history. Protect inventory and diagnostics with current authorization and scrub secrets and sensitive inputs. @@ -817,7 +858,7 @@ caller-backoff guidance without scheduling work. Neither header authorizes write replay. A timer alone is not recovery progress. Declared `@embed` configuration does not imply population occurred. HTTP JSON, -NDJSON and deprecated ingest results and CLI JSON now carry the required nullable +NDJSON and JSON load results and CLI JSON now carry the required nullable `embedding_generation` capability: `unsupported` when a loaded node declaration has `@embed`, including when all vectors were supplied, and `null` otherwise. Human CLI output provides actionable guidance. A successful nullable load remains @@ -901,7 +942,7 @@ server controls. Keep T/B identifiers stable, with these acceptance obligations: | T1–T3 | Own-publication receipts; lost delivery never replays; v0.12 admission/refusal and whole-command outcomes, including compound deletion failure. | | T4 | Atomic contract/table publication, exact pins and same-handle schema/cache progress across faults; legacy evidence refuses unchanged. | | T5–T7 | Disconnect, outstanding accepted I/O, shutdown and actual process restart preserve ownership and settlement through reached boundaries. | -| T8.live | Same engine/PID/listener and coherent schema/query activation, including parked requests, native tails and cleanup/shutdown races; unaffected graph progress; submission-only CLI, writer exclusion, Azure refusal and crash safety. T8.history/retention are deferred outside scope. | +| T8.live | Same engine/PID/listener and coherent schema/query activation; deletion waits for parked requests and held response bytes, then physically removes only its target and atomically removes its inventory entry. Cover shutdown races, unaffected graph progress, submission-only CLI and writer exclusion. Native-tail/provider claims require their own evidence. T8.history/retention are deferred outside scope. | | T9 | Concurrent new submission refuses while one revision is pending, including across restart. Settled partial/inactive D1 permits corrective D2; stale-base submission refuses without effects; original lookup never replays. E0 additionally proves strict numeric publication/fence races and durable exclusive admission through recovery. | | T10 | Declared bounds and completion/status reserves hold under saturation, slow/abandoned consumers and repeated failed deployments; reconcile counters with independent allocation/lifetime evidence. | | T11 | Wide-feed progress, explicit ingestion-embedding diagnostics and bounded authorized status; unavailable 503 versus unknown 404, correct disclosure, liveness/readiness, bounded transient startup retries and permanent policy refusals. | @@ -920,7 +961,7 @@ The embedding diagnostic has focused HTTP regression coverage in `data_routes` with a counted OpenAI-compatible endpoint, successful embedding calls before and after the load matrix, and zero provider requests attributable to loads. The same test checks exact supplied vectors, durable Arrow nulls, required-vector -refusal and unannotated loads; `parity_matrix` covers CLI load/ingest output. +refusal and unannotated loads; `parity_matrix` covers CLI load output. T11.embed remains incomplete: [RFC 0045](0045-gq-logic-tests.md) explicitly excludes `@embed` schemas, and GQT has no load step with outcome expectations. Setup-only seed loading cannot supply that evidence. Changing @@ -941,6 +982,15 @@ and native controls against admission and outstanding recovery. Prove abandoned guards leave the lock and stale owners/control writes cannot act after supported handoff. Local success alone does not qualify S3 or Azure behavior. +Whole-graph deletion extends these same cluster failpoint and CLI/server owners: +kill before `Started`, after partial removal, after verified absence and before +terminal result CAS; reconcile the original ID without a second effect identity. +Prove peer roots, external Blob objects and neighboring prefixes survive, +unauthorized or drifted preparation has no effects, all target branches/history +are gone at success, and a held response body delays removal. Repeat against the +actual provider before claiming its destructive/recovery qualification. These +are evidence gates, not completed results. + Reuse B1–B6 for publication/history cost, mixed serving load, deployment pause and candidate residency, maintenance interference, failure settlement, and slow/wide output respectively. B1 varies schema source/IR bytes independently of history; @@ -1118,8 +1168,8 @@ existing-cluster graph-format conversion remain separate gates. | B | Owned writes, read/stream accounting, drain and shared shutdown | [Owned server operations](2026-09-30-owned-server-operations.md) qualifies the task/body foundation. [Engine settlement and resource bounds](2026-10-01-engine-settlement-and-resource-bounds.md) is accepted and partially implements query-child ownership and named aggregate write limits. Native settlement, completion reserves and runtime reuse remain unqualified under its T6/T10 gates. | | C | Remaining initialization/native-control completion, owned maintenance and bounded transient startup retry | Atomic schema publication is landed; T4/T6/T7/T11 retain same-process progress and protected reclamation | | D | Aggregate budgets and feed progress; embedding diagnostics implemented | T10–T11 and workload qualification, including the remaining T11.embed evidence above | -| E0 | Durable v2 execution, graph creation and exact recovery | PR #849 established schema/query execution; the sole v2 executor now also owns bootstrap and prepared graph creation. Qualification remains limited to recorded local/backend evidence. | -| E1 | Server-owned schema/query activation and graph additions | Implemented with [same-engine transition evidence](#same-engine-e1-evidence-and-its-limits), T8.live/T9 and durable ledger/crash owners. Generic reuse and broader native bounds still require full B; Azure separately gated. | +| E0 | Durable v2 execution, graph creation/deletion and exact recovery | PR #849 established schema/query execution; the sole v2 executor now also owns bootstrap and prepared graph creation. Qualification remains limited to recorded local/backend evidence. | +| E1 | Server-owned schema/query activation and graph inventory changes | Implemented with [same-engine transition evidence](#same-engine-e1-evidence-and-its-limits), T8.live/T9 and durable ledger/crash owners. Generic reuse and broader native bounds still require full B; Azure separately gated. | A and focused C/D repairs may proceed alongside B. PR #849 extends the prepared schema interface with strict intent version 2, neutral settlement fencing and @@ -1158,6 +1208,24 @@ Before enabling an affected increment, its owners must implement and qualify: ## Decision log +- 2026-10-06: The maintainer requested physical graph deletion through ordinary + `cluster apply`, with no deletion flag or unregister mode. This replaces the + Scope and Deployment-scope sentences that refused graph removal, the blanket + no-reclamation wording in Admission, and the E0/E1 graph-addition-only boundary. + Desired removal records exact-root/contract intent in the existing ledger, + drains affected serving work, resumes interrupted deletion by original ID, and + removes applied/runtime inventory only after durable completion. Adoption, + missing-root recreation and generic native settlement remain outside scope; + deletion qualification is tracked separately from prior activation evidence. + +- 2026-10-05: Simplify the deployment contract: remove deprecated transport + aliases and lifecycle/repair overrides; make plan observational and derive its + migration preview from shared preparation; keep activation evidence only in + the atomic process runtime snapshot. Ordinary apply supports graph creation + and configuration changes. Graph removal refuses pending a qualified physical + deletion contract. Preserve exact durable outcomes and explicit no-reset + conversion of obsolete completed-result fields. + - 2026-10-05: Restore declarative policy/provider/Blob lifecycle and explicit graph removal, adoption, recreation and catalog repair under the sole v2 protocol. Share exact read-only preparation with plan, limit graph opens to diff --git a/docs/user/blobs.md b/docs/user/blobs.md index a982b7abf..657cf05f9 100644 --- a/docs/user/blobs.md +++ b/docs/user/blobs.md @@ -165,8 +165,7 @@ and retry. The HTTP load request body is also capped at 32 MiB. That cap counts the encoded request, so one request carries about 24 MiB of decoded `base64:` -data. Every HTTP request other than a load (`/load`, `/load/ndjson`, -`/ingest`) is bounded by the default 1 MiB request body limit, so a `base64:` +data. Every HTTP request other than a load (`/load` and `/load/ndjson`) is bounded by the default 1 MiB request body limit, so a `base64:` literal in an HTTP mutation hits that limit first. Values larger than these limits stay readable. The CLI and the HTTP server diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index 8596c00b9..d84c85716 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -107,7 +107,7 @@ line in the source applies after `--set`, so the file wins. the same validation and the same absence of effect. - `load --set` and `ingest --set`: a direct run (`--store`) applies them to the staged write (`--set stage_write_concurrency=16`); a served run refuses any - `--set` before sending, since the served load and ingest routes carry no + `--set` before sending, since the served load routes carry no `settings` field. ```bash @@ -192,10 +192,11 @@ omnigraph cluster apply --config ./company-brain --as act-alice ``` They manage graph definitions, schemas, stored queries, policies, embedding -providers and external Blob rules. Use `--lifecycle FILE` on plan and apply for -explicit removal, adoption, recreation, catalog repair or schema-contract -correction. Use the same actor and lifecycle file for both commands. See -[Operating a cluster](../clusters/index.md). +providers and external Blob rules. Plan is always an observation and never takes +the writer lock. Removing a graph declaration deletes its managed storage and +history. Apply refuses unmanaged existing roots, missing graphs that remain +declared, and schema drift; it has no lifecycle or repair override. See +[deployment boundaries](../clusters/index.md#deployment-boundaries). For a managed cluster, log in to its Intent API and select the cluster for your config directory: diff --git a/docs/user/cli/managed-data.md b/docs/user/cli/managed-data.md index a4bf43ec3..fb675c72b 100644 --- a/docs/user/cli/managed-data.md +++ b/docs/user/cli/managed-data.md @@ -34,7 +34,7 @@ configuration workflow; editing a source file alone has no effect. Changes use t [cluster configuration workflow](../clusters/index.md#deploy-without-restarting); identity credentials do not bypass its ownership or permission checks. -Normal issuance takes neither `--graph` nor `--actions`; choose a graph on the +Issuance takes no `--graph`; choose a graph on the operation that needs it. `--ttl` accepts seconds or an `s`, `m`, `h`, or `d` suffix, defaults to one hour, and must be between 60 seconds and 24 hours. The service can shorten the requested lifetime. Issuer clock tolerance is @@ -60,7 +60,7 @@ omnigraph graphs list --server prod --discovery --json Without `--discovery`, an explicit server keeps the existing graph-metadata listing and requires `graph_list` policy permission. The CLI does not guess the credential type from a token's appearance. A server that lacks discovery -support, or a static or legacy credential, refuses this route without falling +support, or a static credential, refuses this route without falling back to another inventory. ## Cached access and routing @@ -94,7 +94,7 @@ parents and actor attribution are evidence to reconcile; a timeout alone never proves that a mutation failed to commit. Missing or expired identity credentials are acquired before a graph request; -malformed caches and expired restricted credentials refuse without fallback. +malformed or unsupported caches refuse without fallback. The server decides policy permissions. An explicit `--server`, `--profile`, `--store`, or `--cluster` selects ordinary addressing and follows that command's existing support @@ -123,43 +123,11 @@ continues to select ordinary addressing and credentials, including when the context is malformed. Existing `cluster --direct` remains valid. Without managed context, existing data commands retain their behavior. -## Legacy restricted credentials - -For an issuer that still supports the older restricted profile, request it -explicitly by supplying both a graph and -the exact action ceiling: - -```bash -omnigraph cluster token --graph knowledge --actions read,change,invoke_query --ttl 1h -``` - -The provider-authenticated service uses version-2 identity issuance only and -rejects this legacy request. The CLI retains the syntax for compatible older -issuers; it never converts requested action restrictions into identity-only -authority. - -Accepted actions are `read`, `export`, `change`, `branch_create`, -`branch_delete`, `branch_merge`, `invoke_query`, and `graph_list`. Duplicate -actions, wildcards, `admin`, `config_manage`, and `schema_apply` refuse. Both -the signed ceiling and applied Cedar policy must allow an operation. The CLI -rejects operations outside the cached ceiling before a request. Managed -`graphs list` requires an identity credential; it never upgrades a restricted -credential to gain discovery access. The legacy HTTP metadata catalog retains -its graph filtering. - -Existing restricted caches keep their version and exact grants. An explicit -`--actions` request is never ignored or silently changed to an identity -credential. An unsupported issuance profile refuses and preserves the previous -cache. Running normal `cluster token` is an explicit request to replace it -with the identity profile, subject to the issuer's admission check. Older CLI -versions that only understand restricted credentials cannot use that cache; -they must be upgraded or use explicit restricted issuance. - ## Clear a credential `cluster token --clear [--config DIR]` forgets that cluster's local data entry, independently of the control-plane session. Do not combine `--clear` -with `--graph`, `--actions`, or `--ttl`. Clearing is not server revocation: +with `--graph` or `--ttl`. Clearing is not server revocation: copies remain usable until expiry or signing-key retirement. `logout --api` clears local login credentials and requests provider-session revocation; `provider_revocation_confirmed` reports whether that request succeeded. @@ -180,9 +148,7 @@ omnigraph load --graph knowledge --data batch-02.jsonl --mode append --branch re Every load requires `change` for that graph. `--from` additionally requires `branch_create` when creating the target branch. Applied Cedar policy owns target change permission and the base-to-target creation permission. -Identity credentials add no action ceiling. The legacy restricted profile -additionally requires `change` and, whenever `--from` is present, -`branch_create` in the same graph grant. `invoke_query` is not required for loading. +Identity credentials add no action ceiling. `invoke_query` is not required for loading. Review and merge use the existing branch operations and their separate permissions; a load does not merge its target branch. diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 8e72db6e7..08eb71c7c 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -330,7 +330,7 @@ After login and cluster selection, use `graphs list` to discover graphs, then `query`, `mutate`, `load`, or commit reads with `--graph` from the managed folder. Missing or expired identity credentials are acquired before the operation; applied Cedar policy decides permissions. See [managed data access](managed-data.md) -for offline behavior, identity binding, explicit restricted credentials, +for offline behavior, identity binding, discovery and credential clearing. ## Confirmation rules @@ -339,12 +339,3 @@ discovery and credential clearing. against non-local storage also require interactive confirmation or `--yes`; in non-interactive and JSON modes they fail closed. The same non-local consent rule applies to overwrite loads and branch deletion, verb or statement. - -## Compatibility aliases - -| Old name | Canonical name | -|---|---| -| `read` | `query` | -| `change` | `mutate` | -| `check` and `query lint` | `lint` | -| `ingest` | `load` | diff --git a/docs/user/clusters/config.md b/docs/user/clusters/config.md index 6e2ffd15f..a8edf5129 100644 --- a/docs/user/clusters/config.md +++ b/docs/user/clusters/config.md @@ -214,9 +214,9 @@ Prefer relative paths; they are what keep a bundle portable and hermetic. All execution uses ledger v2 and requires `state.lock: true`. With `--server`, apply submits to the existing writer and activates without restart. Direct apply owns exclusive admission and requires an explicit handoff before serving. -Existing roots and storage formats stay fixed. Policy/provider/Blob bindings can -change; explicit graph removal retains storage for confirmed adoption. See -[lifecycle and repair](index.md#explicit-lifecycle-and-repair). +Retained graph roots and storage formats stay fixed. Policy/provider/Blob bindings +can change; removing a graph declaration deletes its managed storage and history. See +[deployment boundaries](index.md#deployment-boundaries). Apply does not load rows or start servers. Root-addressed status, reconciliation and conversion do not use `cluster.yaml`; see [the deployment workflow](index.md). diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index 29e402e48..d329d6953 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -42,8 +42,10 @@ of the deployment ledger version. The [configuration reference](config.md) covers storage roots, embedding providers, external Blob policy and limits. For server-owned deployment, the applied policy must grant the operator -`config_manage` at cluster scope. Schema changes additionally require `read` and -`schema_apply` on the affected graphs. Deployment status reveals management +`config_manage` at cluster scope. Schema changes and graph deletions additionally +require `read` and `schema_apply` on the affected graphs. Direct deletion also +enforces an installed graph policy and requires an authorized `--as` actor. +Deployment status reveals management metadata under `config_manage`; it does not require data access on unrelated graphs. New graphs need suitable declared policies too. The server derives the actor from its bearer token; `--as` is for direct access. @@ -96,7 +98,8 @@ listener and writer ownership. It briefly closes admission on affected graphs, finishes their admitted requests, publishes schema changes, and activates matching schemas, queries and runtime permissions together. Unaffected graphs keep serving. -Graph additions become available through the same deployment. +Graph additions and removals use the same deployment; see +[deletion semantics](#deployment-boundaries) before removing a declaration. The response separates the durable deployment result from `active`, which means that result is currently serving in this process. A successful response requires @@ -112,12 +115,11 @@ vectors. Roots, format and credential/trust configuration stay fixed. A refusal before effects restores unchanged serving views, including after a drain timeout. There is one deployment protocol and no legacy execution fallback. -Upgrade the CLI, server and cluster tools together. Before installing the build -that adds `--lifecycle`, finish outstanding deployments with the build that -accepted them: captured input now requires `options`, so the new build cannot -resume older bundles. Completed results remain readable through deployment -status; recapturing an old deployment with the new CLI changes its input digest -and can refuse resubmission of that ID. Graph storage format is unchanged. +Upgrade the CLI, server and cluster tools together. Finish outstanding deployments +with the build that accepted them before upgrading: captured inputs are exact +and are not translated into a different request. Use the stopped-ledger upgrade +command to remove obsolete completed-result runtime fields; graph data and exact +achieved receipts are preserved. The graph storage format is unchanged. ## Direct deployments and conversion @@ -126,7 +128,8 @@ the serving owner to have stopped and handed off the lock. Start the server afte settlement to activate the applied revision. Direct apply never takes over a live server or writes around its lock. -An existing v1 ledger requires explicit conversion before ordinary operation: +Use explicit stopped-ledger conversion for a v1 ledger or a v2 ledger whose +completed receipts still contain obsolete runtime activation fields: ```bash omnigraph --cluster file:///srv/company-brain \ @@ -137,7 +140,10 @@ Stop serving, writers and maintenance and establish prior graph/control I/O quiescence first. Conversion preserves rows, graph identities, branches, history and applied resources. It does not reset graphs, replay old work or convert graph storage formats. There is no automatic migration or v1 execution fallback. -Unsupported formats and unresolved legacy work refuse conversion. +For v2, conversion removes only those obsolete runtime fields, preserving the +ledger identity, next deployment sequence and exact achieved receipts. Outstanding +deployments and unsupported receipt shapes refuse; finish accepted work with +its originating build before upgrading. A completed read-only preflight refusal releases a newly acquired direct lock. Accepted work, cancellation and uncertain effects retain it for reconciliation. @@ -154,8 +160,8 @@ OMNIGRAPH_BEARER_TOKEN='secret' omnigraph cluster status \ ``` Status is read-only. `active` is false for an older result after a newer revision -activates, and a recorded witness from another server process does not prove -current activation. Repeating apply with the same ID and identical captured input +activates. Only the current runtime can prove activation; a durable completion +receipt alone cannot. Repeating apply with the same ID and identical captured input returns its recorded outcome; it never executes again. Different input refuses. If the server stopped with an outstanding deployment, inspect the storage root: @@ -187,63 +193,42 @@ lock, so perform ownership transfer before starting another owner. Never allocate a new ID to retry an unknown outcome. Result eviction reports acceptance and outcome as unknown; it never authorizes replay. See [limits](config.md#limits). -## Correct deliberate schema drift - -Every affected graph is checked against its achieved schema identity, even when -source text changes. A recreated graph or out-of-band schema change refuses with -`applied_schema_drift`. To accept a reviewed observed contract, save the exact -graph-to-contract JSON map from that refusal and submit: - -```bash -omnigraph cluster apply --config ./company-brain --as act-alice \ - --schema-correction correction.json --json -``` - -The same option is available with `--server`. It must match the observed source -hash, accepted IR hash, identity domain and version, and requires schema-apply -permission. Unknown, stale or unnecessary entries refuse. Correction accepts the -identified current graph; it does not restore missing data or history. An -original-ID resubmission must retain the same correction input. - -## Explicit lifecycle and repair - -Use the same bounded JSON file with `cluster plan --lifecycle FILE` and -`cluster apply --lifecycle FILE` (also accepted with `apply --server`). Plan -refusals name the exact observed confirmations to inspect and copy. Apply checks -those observations again; a stale confirmation fails before effects. - -| JSON field | Meaning | -|---|---| -| `delete_graphs` | Map graph IDs to `{ "contract": , "graph_manifest_version": }`; remove those graphs from the desired configuration | -| `adopt_graphs` | Same confirmation shape; declare an existing graph at its derived root with its exact current schema | -| `recreate_graphs` | Map missing graph IDs to their previous achieved schema contracts; the entire root must be absent | -| `repair_catalog` | Array of exact policy or stored-query resource addresses to repair from verified source bytes | -| `schema_corrections` | The exact correction map described above; cannot combine this file with `--schema-correction` | - -Removal makes the graph unavailable through the cluster but **retains its -storage, rows, branches and history**. Reuse requires explicit adoption. The -running server retains removed graph owners for safe readoption; active and -retained roots together are limited to 2,048 per process. Restart releases -retained owners. Recreation refuses while that process still owns the old -engine; a graph blocked at startup because its root is missing can be recreated -online. -Recreation creates an empty graph with a new identity; it never restores lost -data or overwrites partial storage. Adoption preserves existing identity/history; -apply schema changes in a subsequent deployment. - -For example, to restore one corrupt stored-query payload: - -```json -{"repair_catalog": ["query.knowledge.people"]} -``` - -Use the actual resource address from plan/observe. Repairing an unreadable -applied policy requires direct storage ownership and source bytes matching its -recorded digest; a remote caller cannot grant itself authority from new files. -Plan runs effect-free engine and ledger checks and reports unavailable affected graphs, -schema drift and migration restrictions as errors. An unavailable unrelated -graph does not block an independent change. Live apply additionally validates -provider secrets and serving settings on the server. +## Deployment boundaries + +`cluster apply` creates graphs, deletes removed graphs, and updates schemas, +stored queries, policies, providers and Blob rules. Removing a graph declaration +and applying is destructive: it deletes that graph's managed storage, including +all branches, retained history and managed Blob bytes. Review the deletion in +`cluster plan` and keep any required backup before applying. There is no separate +delete flag or unregister mode. External Blob source objects are not deleted. +On object stores, deletion removes the active namespace; provider version history +and retention policies may keep older objects. This is not secure erasure. + +Live apply closes the affected graph to new requests and waits for its admitted +work and response bodies before deletion. Other graphs continue serving. If +deletion is interrupted, reconcile the original deployment ID; the recorded +operation resumes removal of the exact root. Editing the desired configuration +does not cancel accepted deletion, and absence during recovery does not create a +replacement graph. + +Importing an existing root, recreating a missing managed graph, accepting schema +drift and repairing catalog payloads are not apply modes. Restore missing or +damaged authoritative data from a verified backup before deploying changes to +that graph. + +Every present affected root is checked against its achieved schema identity, +even when source text changes. Removing an already-absent graph still records a +completed deletion; it never recreates the root. An out-of-band replacement or schema change refuses with +`applied_schema_drift`; matching text cannot authorize a different graph identity. + +Plan always observes without taking the cluster writer lock. It reports the +existing owner and runs the same effect-free preparation used by apply, with +the intended actor. Its migration steps come from that preparation, not a second +schema-file read. Apply prepares again under writer admission because a preview +reserves nothing. Unavailable affected graphs, schema drift and migration +restrictions are errors, except that deletion accepts an already-absent root. An unavailable unrelated graph does not block an +independent change. Live apply also validates provider secrets and serving +settings on the server. ## Operational boundaries diff --git a/docs/user/deployment.md b/docs/user/deployment.md index bb33d8f66..de1a1111e 100644 --- a/docs/user/deployment.md +++ b/docs/user/deployment.md @@ -57,8 +57,10 @@ returns one `graphs` list, including blocked entries, with each graph's `state` Blocked entries include a sanitized `failure`: `invalid_configuration`, `invalid_policy`, `invalid_external_blob_policy`, `open_failed` or `invalid_stored_queries`. Details remain in server logs. -`apply_correction_or_restart` means apply an explicit graph correction or correct the -storage problem and restart; `wait_for_restart` describes shutdown. +`apply_correction_or_restart` means fix a query, policy or provider configuration +through deployment, or restore damaged graph storage from a verified backup and +restart. It does not authorize adopting a different graph identity. +`wait_for_restart` describes shutdown. `wait_for_startup` means the graph's one startup attempt has not completed admission. It promises no retry time and does not trigger another open. `wait_for_transition` means that graph's admissions are closed while its owner @@ -68,6 +70,14 @@ continue serving and transitioning. Server-owned deployments activate schema and stored-query changes through this transition; a proved pre-effect refusal restores the unchanged views. See [live deployment](clusters/index.md#deploy-without-restarting). Ready entries use `none`. There is no separate `quarantined` list or automatic startup retry. + +`GET /cluster/deployments/{id}` separates the durable achieved result from its +current `active` status. Active means this process has installed that exact +deployment and all its graph bindings are ready. Loading, blocked graphs, +partial convergence and shutdown keep it false. A normal restart verifies its +own bindings before reporting the retained current deployment active; successful +apply alone is not activation. Activation does not rewrite the durable receipt. + Known loading, blocked or transitioning graphs return 503 (`graph_unavailable`) to callers authorized to read `main` or list the management inventory; other callers cannot use this response to discover them. Unknown graphs return 404. A 503 does not authorize replaying a write. @@ -135,7 +145,7 @@ configure both byte limits when setting an instance's input budget. Body timeouts above 86400 seconds also warn and use the 30-second default. Ingress reserves the route's maximum before reading, then reduces the reservation to the actual body size. Body collection limits remain 1 MiB for ordinary JSON -requests, 32 MiB for bulk load/ingest and 64 KiB for MCP. Cluster deployment POSTs +requests, 32 MiB for bulk load and 64 KiB for MCP. Cluster deployment POSTs allow 16 MiB plus 1 KiB for the request envelope; the captured bundle itself is limited to 16 MiB. MCP uses the read-body lane after authentication. Registered bulk routes alone diff --git a/docs/user/operations/maintenance.md b/docs/user/operations/maintenance.md index 55a73f166..e37634a2a 100644 --- a/docs/user/operations/maintenance.md +++ b/docs/user/operations/maintenance.md @@ -4,7 +4,7 @@ OmniGraph provides four direct-storage maintenance commands: - `optimize` compacts data and reconciles declared indexes. - `rebuild-full-text-indexes` replaces full-text indexes on one branch. -- `repair` classifies storage drift and can publish an approved repair. +- `repair` diagnoses foreign storage drift without adopting it. - `cleanup` permanently removes unretained table versions and unused table forks left by branches created before storage format v11. @@ -138,17 +138,13 @@ directory; no read or write of the graph resolves the linear HEAD, so foreign drift changes no query result and blocks no writer. For a `foreign_drift` table `repair` prints the last linear version, the HEAD and the number of foreign versions in `operations`, takes no action (`no_op`) -and exits 0. It never adopts the foreign commit, with or without -`--force --confirm`. `cleanup` leaves foreign versions and their files in +and exits 0. It never adopts the foreign commit. `cleanup` leaves foreign versions and their files in place and lists them per table under `foreign_versions`. A HEAD below the recorded last linear version is reported as an internal manifest error. Each `repair --json` row carries `type_key`, `published_dataset_version`, `lance_head_version`, `classification`, `action`, `operations` and `error`. -`--confirm` and `--force --confirm` remain accepted and publish nothing on a -v11 graph: the classes they used to publish (`verified_maintenance`, -`suspicious`, `unverifiable`) described a table whose registration named its -linear HEAD, which no v11 registration does. To discard foreign commits, +The removed `--confirm` and `--force` options are rejected. To discard foreign commits, export the graph and load it into a new one; see [Troubleshooting](troubleshooting.md#foreign-drift). diff --git a/docs/user/operations/policy.md b/docs/user/operations/policy.md index b52eab8c8..ad4fbca31 100644 --- a/docs/user/operations/policy.md +++ b/docs/user/operations/policy.md @@ -133,8 +133,7 @@ For HTTP requests, the server maps the bearer token to an actor. Headers, query parameters, and request bodies cannot override that identity. Signed credentials use `principal:`; groups and permissions come from applied policy. An identity credential contains no -graph/action grants. Legacy restricted credentials retain an additional -ceiling; they cannot override a policy denial. +graph/action grants. Version-1 restricted credentials are not accepted. For direct CLI writes, actor resolution is: diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index dfa805334..9349b3129 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -31,8 +31,8 @@ Missing or unapplied state, or a nonempty cluster whose graphs all fail, refuses startup. Authentication, policy and data-token root checks still apply. Use `cluster apply --server URL --config DIR` for schema, query, policy, -provider and Blob-rule changes without restart. Explicit graph lifecycle changes -use the same deployment. Direct apply requires stopped serving and a subsequent +provider and Blob-rule changes without restart. Graph additions and removals +use the same deployment; removal deletes the graph's managed storage and history. Direct apply requires stopped serving and a subsequent start; see [cluster deployments](../clusters/index.md). An unapplied resource edit does not activate it, although changing or breaking the directory's config can change where boot looks for applied state. @@ -106,22 +106,15 @@ defines the machine-written file. Signed credentials use the actor `principal:`. A caller cannot change its actor through request headers or JSON. The server accepts -two explicit profiles: - -- **Identity credentials (version 2)** bind the principal to the cluster and - contain no permissions. Applied Cedar policy decides graph operations; - missing policy or an unknown policy actor denies protected access. -- **Legacy restricted credentials (version 1)** additionally limit access to - their exact graph/action grants. Both the grant and applied policy must - allow the request. These credentials cannot grant `schema_apply`, - `config_manage`, or `admin`. +version-2 identity credentials, which bind the principal to the cluster and +contain no permissions. Applied Cedar policy decides graph operations; +missing policy or an unknown policy actor denies protected access. Every valid identity credential can call `GET /graphs/discovery` for applied graph IDs and names (currently identical), including blocked graphs. It requires no policy membership and returns no locations, availability, schema, -queries or data. Static/restricted credentials cannot use it. `GET /graphs` -requires `graph_list` permission and, for restricted credentials, a signed -`graph_list` grant. Discovery grants no graph access or reachability. +queries or data. Static credentials cannot use it. `GET /graphs` +requires `graph_list` permission. Discovery grants no graph access or reachability. See [managed data access](../cli/managed-data.md) for issuance and CLI discovery. @@ -234,12 +227,7 @@ Each of `/query`, `/mutate`, `/mutate/if-graph-commit` and `/branches/merge` takes an optional `settings` field, and the two GET change routes a `set=` parameter; see [Session settings](../queries/index.md#session-settings). -`/read`, `/change`, and `/ingest` are deprecated compatibility routes. New -clients should use `/query`, `/mutate`, and `/load`. - -`POST /graphs/{id}/schema/apply` remains in the wire surface for compatibility, -but a cluster-only server rejects it with `409`. Change a managed graph's -schema through `cluster apply --server URL --config DIR`; see +Change a managed graph's schema through `cluster apply --server URL --config DIR`; see [cluster deployments](../clusters/index.md#deploy-without-restarting). ## Run an inline query @@ -261,9 +249,6 @@ When the read snapshot has an effective graph head, the canonical `/query` response includes its `graph_commit_id`, pinned with the returned rows. Inline writes go to `/mutate` and may select a target `branch`. -The deprecated `/read` compatibility response does not include -`graph_commit_id`; clients that need a read position must use `/query`. - ## Invoke a stored query ```bash @@ -293,7 +278,7 @@ curl -sS http://localhost:8080/graphs/knowledge/mutate/if-graph-commit \ ``` Stored mutations use `POST /graphs/{id}/queries/{name}/if-graph-commit` with -that header. Ordinary `/mutate`, deprecated `/change`, and `/queries/{name}` +that header. Ordinary `/mutate` and `/queries/{name}` reject it rather than ignore the condition. Never fall back to an unconditional route after a conditional request fails. diff --git a/docs/user/operations/troubleshooting.md b/docs/user/operations/troubleshooting.md index 609829582..1c14fe26a 100644 --- a/docs/user/operations/troubleshooting.md +++ b/docs/user/operations/troubleshooting.md @@ -118,8 +118,9 @@ between graph roots. ## Cluster failures - Run `cluster validate` before `plan` or `apply`. -- Graph removal, adoption and missing-root recreation require exact confirmations - through `--lifecycle FILE`; see [lifecycle and repair](../clusters/index.md#explicit-lifecycle-and-repair). +- Apply refuses existing unmanaged roots and missing managed graphs that remain + declared. Restore authoritative data to keep a missing graph, or remove its + declaration to delete it; see [deployment boundaries](../clusters/index.md#deployment-boundaries). - A retained lock requires prior-owner and accepted-I/O quiescence, exclusion of other admissions/unlocks, and its exact ID; follow [ownership transfer](../deployment.md#writer-topology). @@ -171,8 +172,7 @@ condition is per table. - Queries, mutations, loads, merges, index builds, schema apply, optimize and cleanup are unaffected: none of them resolves the linear HEAD. - `repair` prints the last linear version, the HEAD and the count of foreign - versions, takes no action and exits 0. `--confirm` and `--force --confirm` - never adopt the foreign commit. + versions, takes no action and exits 0. The removed confirmation flags are rejected; repair never adopts the foreign commit. - `cleanup` never deletes a foreign version or its files; the table's result row lists them under `foreign_versions`. diff --git a/docs/user/operations/upgrade.md b/docs/user/operations/upgrade.md index 36856dc5b..7e0fe77c0 100644 --- a/docs/user/operations/upgrade.md +++ b/docs/user/operations/upgrade.md @@ -333,8 +333,8 @@ these cutovers: `knowledge_next` with the desired schema, query, provider, and policy bindings. Validate, plan, and submit `cluster apply --server` so the running owner creates and activates its derived root. Load through that server, verify - the new ID, and move clients to it. Remove the old declaration only with an - exact lifecycle confirmation; removal retains its storage for recovery. + the new ID, back up what you need, and move clients. Removing the old declaration + and applying deletes its managed storage and all retained history. - **Same graph ID in a parallel cluster root.** Copy the source bundle, set a new `storage` root, and keep the original cluster untouched. Validate, plan, and apply the new bundle; load the export into its derived graph root; then diff --git a/docs/user/queries/explain.md b/docs/user/queries/explain.md index 138f7465a..462d569f6 100644 --- a/docs/user/queries/explain.md +++ b/docs/user/queries/explain.md @@ -153,8 +153,8 @@ regeneration instruction, including plans without traversal nodes. An `explain` statement is served by `omnigraph query` and `POST /query`. It takes the same `--branch`/`--snapshot` target and `--params` as the query -itself and needs the same `read` policy decision. `mutate` and the deprecated -routes refuse it, and so is a mutation declaration under `explain`. +itself and needs the same `read` policy decision. `mutate` refuses it, as does a +mutation declaration under `explain`. Every read executes on engine v2, the one value of the session setting `engine`, so explain describes the route the query runs. See diff --git a/docs/user/queries/settings.md b/docs/user/queries/settings.md index 9fb0b9cf6..717f587c9 100644 --- a/docs/user/queries/settings.md +++ b/docs/user/queries/settings.md @@ -52,9 +52,7 @@ A setting reaches the engine through one of three doors: - HTTP body: the `settings` object on `POST /query`, `POST /mutate`, `POST /mutate/if-graph-commit` and `POST /branches/merge`, one key per `request` setting (`{"settings": {"merge_lineage": "verify"}}`); an unknown key - is refused. The deprecated `/read` and `/change` run under the process - defaults: neither takes a `settings` field, and each refuses a `set` or - `reset` prefix in the source it carries with that same refusal. + is refused. - HTTP query string: `set==`, repeatable, on `GET /changes` and `GET /commits/{id}/changes`. diff --git a/docs/user/search/embeddings.md b/docs/user/search/embeddings.md index d5976e385..e58fce1b4 100644 --- a/docs/user/search/embeddings.md +++ b/docs/user/search/embeddings.md @@ -82,7 +82,6 @@ Successful HTTP and CLI JSON load results include `embedding_generation`: `"unsupported"` means the load touched a node type with an `@embed` property; `null` means it did not. This describes the load capability, including when every vector was supplied. Human CLI output prints the corresponding guidance. -The same diagnostic applies to the deprecated `ingest` command and endpoint. ## Offline file pipeline diff --git a/openapi.json b/openapi.json index e1ecd9072..3502ec3c6 100644 --- a/openapi.json +++ b/openapi.json @@ -2586,914 +2586,14 @@ ] } }, - "/graphs/{graph_id}/change": { - "post": { - "tags": [ - "mutations" - ], - "summary": "**Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead.", - "description": "Apply a GQ mutation to a branch. The deprecated route retains its request\nand execution semantics, while its response uses the current canonical\nvocabulary; a `settings` field is refused, since the route runs under the\nprocess defaults alone. New integrations should target `POST /mutate`.\nResponses include `Deprecation: true` and\n`Link: ; rel=\"successor-version\"` headers per RFC 9745 / RFC 8288\nso SDKs and proxies can surface the signal.", - "operationId": "cluster_change", - "parameters": [ - { - "name": "graph_id", - "in": "path", - "description": "Graph id to route the request to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "omnigraph-http-api", - "in": "header", - "description": "exactly one omnigraph-http-api header with value 0.12 is required", - "required": true, - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeOutput" - } - } - } - }, - "400": { - "description": "Bad request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "401": { - "description": "Unauthorized", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "403": { - "description": "Forbidden", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "408": { - "description": "Request body deadline exceeded before operation admission", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "409": { - "description": "Write-authority conflict", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "413": { - "description": "Keyed write exceeds the per-commit entity or byte ceiling; Request body exceeds its route limit before operation admission", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "424": { - "description": "An allowed external Blob source could not be probed or read", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "429": { - "description": "Per-actor admission cap exceeded; honor `Retry-After` header; Server observer, ingress or operation admission capacity exhausted; honor Retry-After", - "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "503": { - "description": "An overlapping durable recovery intent must be resolved before retry; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - } - }, - "deprecated": true, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/graphs/{graph_id}/changes": { - "get": { - "tags": [ - "changes" - ], - "summary": "Poll the change feed of one branch.", - "description": "At-least-once: retrying a cursor may replay the complete next commit, so\nconsumers apply blocks idempotently by `graph_commit_id` and persist the\nterminal cursor together with its blocks. The server holds no consumer\nstate. `set=` values are validated and select nothing in this release.", - "operationId": "cluster_pollChanges", - "parameters": [ - { - "name": "graph_id", - "in": "path", - "description": "Graph id to route the request to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "branch", - "in": "query", - "description": "Branch whose first-parent history is polled. Defaults to `main`.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "cursor", - "in": "query", - "description": "Durable cursor from a prior terminal page. Mutually exclusive with\n`start` and `page_token`.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "start", - "in": "query", - "description": "Explicit start mode: `now` (default) | `beginning` |\n`after:`. Mutually exclusive with `cursor` and `page_token`.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "page_token", - "in": "query", - "description": "Continuation of one bounded poll (keeps its captured cut). Mutually\nexclusive with `cursor` and `start`.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0 - } - }, - { - "name": "kind", - "in": "query", - "required": false, - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EntityKindOutput" - } - } - }, - { - "name": "type", - "in": "query", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "name": "op", - "in": "query", - "required": false, - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ChangeOpOutput" - } - } - }, - { - "name": "set", - "in": "query", - "description": "Repeatable session setting, `name=value` in GQ spelling\n(`set=merge_lineage=off`); only `request`-scope settings are accepted.\nValues are validated as session settings; `engine` does not change\nchange-feed execution.", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "name": "omnigraph-http-api", - "in": "header", - "description": "exactly one omnigraph-http-api header with value 0.12 is required", - "required": true, - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - } - } - ], - "responses": { - "200": { - "description": "Change blocks in first-parent order. The durable cursor appears only on a terminal page, advanced only over complete commits; a mid-block page carries only next_page_token", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeFeedOutput" - } - } - } - }, - "400": { - "description": "Invalid start/filter combination, or a rejected cursor or page token; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "401": { - "description": "Unauthorized", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "403": { - "description": "Forbidden", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "404": { - "description": "Branch not found", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "409": { - "description": "The feed crossed an unprovable schema boundary; see change_diff_refusal", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "410": { - "description": "Feed gap: required history was reclaimed; reset via the baseline handshake", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "413": { - "description": "Requested limit exceeds the public change ceiling", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "429": { - "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", - "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "500": { - "description": "Internal failure while reading changes", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "503": { - "description": "Recovery required before changes can be read; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - } - }, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/graphs/{graph_id}/changes/baseline": { - "post": { - "tags": [ - "changes" - ], - "summary": "Capture a change-feed baseline: one exact entity snapshot plus the cursor\nthat resumes the feed immediately after it.", - "description": "A baseline is a full data export, so it requires the export action. The\nsnapshot honors the scope's kind and type dimensions; `op` binds only the\nresume cursor's feed scope.", - "operationId": "cluster_captureChangeBaseline", - "parameters": [ - { - "name": "graph_id", - "in": "path", - "description": "Graph id to route the request to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "omnigraph-http-api", - "in": "header", - "description": "exactly one omnigraph-http-api header with value 0.12 is required", - "required": true, - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeBaselineRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "NDJSON entity snapshot pinned at one captured commit. Every preceding record is one type-keyed entity record (the load/export NDJSON shape); the FINAL record is the ChangeBaselineRecord envelope — an interrupted stream has no terminal record and therefore no usable cursor. Install the snapshot durably before the cursor.", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/x-ndjson": { - "schema": { - "$ref": "#/components/schemas/ChangeBaselineRecord" - } - } - } - }, - "400": { - "description": "Invalid scope; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "401": { - "description": "Unauthorized", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "403": { - "description": "Forbidden", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "404": { - "description": "Branch not found", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "408": { - "description": "Request body deadline exceeded before operation admission", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "413": { - "description": "Baseline cut or transport capacity exhausted; Request body exceeds its route limit before operation admission", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "429": { - "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", - "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "500": { - "description": "Internal failure while capturing the baseline", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - }, - "503": { - "description": "Recovery required; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeErrorOutput" - } - } - } - } - }, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/graphs/{graph_id}/commits": { + "/graphs/{graph_id}/changes": { "get": { "tags": [ - "commits" + "changes" ], - "summary": "List commits, most recent first.", - "description": "`branch` selects which history to list: a named branch returns the history\nreachable from that branch's head (the main commits inherited up to the\nfork plus the branch-authored commits); omitting it returns `main`'s\nhistory. There is no cross-branch listing. Ordering is part of the\ncontract — newest first by (graph-manifest version, created-at, commit id) — and\na future `cursor`/`limit` pagination will be keyset-based on that same\norder. Read-only.", - "operationId": "cluster_listCommits", + "summary": "Poll the change feed of one branch.", + "description": "At-least-once: retrying a cursor may replay the complete next commit, so\nconsumers apply blocks idempotently by `graph_commit_id` and persist the\nterminal cursor together with its blocks. The server holds no consumer\nstate. `set=` values are validated and select nothing in this release.", + "operationId": "cluster_pollChanges", "parameters": [ { "name": "graph_id", @@ -3507,12 +2607,91 @@ { "name": "branch", "in": "query", + "description": "Branch whose first-parent history is polled. Defaults to `main`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "description": "Durable cursor from a prior terminal page. Mutually exclusive with\n`start` and `page_token`.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "start", + "in": "query", + "description": "Explicit start mode: `now` (default) | `beginning` |\n`after:`. Mutually exclusive with `cursor` and `page_token`.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page_token", + "in": "query", + "description": "Continuation of one bounded poll (keeps its captured cut). Mutually\nexclusive with `cursor` and `start`.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityKindOutput" + } + } + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "op", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChangeOpOutput" + } + } + }, + { + "name": "set", + "in": "query", + "description": "Repeatable session setting, `name=value` in GQ spelling\n(`set=merge_lineage=off`); only `request`-scope settings are accepted.\nValues are validated as session settings; `engine` does not change\nchange-feed execution.", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } } }, { @@ -3530,7 +2709,7 @@ ], "responses": { "200": { - "description": "List of commits", + "description": "Change blocks in first-parent order. The durable cursor appears only on a terminal page, advanced only over complete commits; a mid-block page carries only next_page_token", "headers": { "omnigraph-http-api": { "schema": { @@ -3545,13 +2724,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CommitListOutput" + "$ref": "#/components/schemas/ChangeFeedOutput" } } } }, "400": { - "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Invalid start/filter combination, or a rejected cursor or page token; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -3566,7 +2745,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } @@ -3587,131 +2766,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "403": { - "description": "Forbidden", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "429": { - "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", - "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "503": { - "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - } - }, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/graphs/{graph_id}/commits/{commit_id}": { - "get": { - "tags": [ - "commits" - ], - "summary": "Get a single commit.", - "description": "Returns the commit's graph-manifest version, parent commit(s), and creation\nmetadata. Read-only.", - "operationId": "cluster_getCommit", - "parameters": [ - { - "name": "graph_id", - "in": "path", - "description": "Graph id to route the request to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "commit_id", - "in": "path", - "description": "Commit identifier", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "omnigraph-http-api", - "in": "header", - "description": "exactly one omnigraph-http-api header with value 0.12 is required", - "required": true, - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - } - } - ], - "responses": { - "200": { - "description": "Commit details", + "$ref": "#/components/schemas/ChangeErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", "headers": { "omnigraph-http-api": { "schema": { @@ -3726,13 +2787,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CommitOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "400": { - "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "404": { + "description": "Branch not found", "headers": { "omnigraph-http-api": { "schema": { @@ -3747,13 +2808,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "401": { - "description": "Unauthorized", + "409": { + "description": "The feed crossed an unprovable schema boundary; see change_diff_refusal", "headers": { "omnigraph-http-api": { "schema": { @@ -3768,13 +2829,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "403": { - "description": "Forbidden", + "410": { + "description": "Feed gap: required history was reclaimed; reset via the baseline handshake", "headers": { "omnigraph-http-api": { "schema": { @@ -3789,13 +2850,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "404": { - "description": "Commit not found", + "413": { + "description": "Requested limit exceeds the public change ceiling", "headers": { "omnigraph-http-api": { "schema": { @@ -3810,7 +2871,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } @@ -3837,13 +2898,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" + } + } + } + }, + "500": { + "description": "Internal failure while reading changes", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, "503": { - "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "description": "Recovery required before changes can be read; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -3858,7 +2940,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } @@ -3871,14 +2953,14 @@ ] } }, - "/graphs/{graph_id}/commits/{commit_id}/changes": { - "get": { + "/graphs/{graph_id}/changes/baseline": { + "post": { "tags": [ "changes" ], - "summary": "Entity changes one commit made relative to its first parent.", - "description": "Read-only, in graph vocabulary with exact before/after images. Bounded:\na large commit continues via the opaque `page_token`. `set=` values are\nvalidated and select nothing in this release.", - "operationId": "cluster_getCommitChanges", + "summary": "Capture a change-feed baseline: one exact entity snapshot plus the cursor\nthat resumes the feed immediately after it.", + "description": "A baseline is a full data export, so it requires the export action. The\nsnapshot honors the scope's kind and type dimensions; `op` binds only the\nresume cursor's feed scope.", + "operationId": "cluster_captureChangeBaseline", "parameters": [ { "name": "graph_id", @@ -3889,82 +2971,6 @@ "type": "string" } }, - { - "name": "commit_id", - "in": "path", - "description": "Commit identifier", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "page_token", - "in": "query", - "description": "Opaque continuation from the preceding page of this response.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "description": "Maximum changes per page. Server default applies when absent; above\nthe public ceiling the request fails with 413.", - "required": false, - "schema": { - "type": "integer", - "minimum": 0 - } - }, - { - "name": "kind", - "in": "query", - "description": "Repeatable filter: node | edge.", - "required": false, - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EntityKindOutput" - } - } - }, - { - "name": "type", - "in": "query", - "description": "Repeatable filter: accepted-schema type name.", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "name": "op", - "in": "query", - "description": "Repeatable filter: insert | update | delete.", - "required": false, - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ChangeOpOutput" - } - } - }, - { - "name": "set", - "in": "query", - "description": "Repeatable session setting, `name=value` in GQ spelling\n(`set=merge_lineage=off`); only `request`-scope settings are accepted.\nValues are validated as session settings; `engine` does not change\nchange-feed execution.", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, { "name": "omnigraph-http-api", "in": "header", @@ -3978,9 +2984,19 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeBaselineRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Entity changes this commit made relative to its first parent, in frozen (kind, type, id, op) order with the cause stated once", + "description": "NDJSON entity snapshot pinned at one captured commit. Every preceding record is one type-keyed entity record (the load/export NDJSON shape); the FINAL record is the ChangeBaselineRecord envelope — an interrupted stream has no terminal record and therefore no usable cursor. Install the snapshot durably before the cursor.", "headers": { "omnigraph-http-api": { "schema": { @@ -3993,15 +3009,15 @@ } }, "content": { - "application/json": { + "application/x-ndjson": { "schema": { - "$ref": "#/components/schemas/CommitChangesOutput" + "$ref": "#/components/schemas/ChangeBaselineRecord" } } } }, "400": { - "description": "Invalid filter or limit, or a rejected page token; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Invalid scope; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -4042,8 +3058,8 @@ } } }, - "404": { - "description": "Commit not found, or the actor cannot read the commit's branch", + "403": { + "description": "Forbidden", "headers": { "omnigraph-http-api": { "schema": { @@ -4063,8 +3079,8 @@ } } }, - "409": { - "description": "Commit cannot be entity-diffed (parentless commit or schema boundary); see change_diff_refusal", + "404": { + "description": "Branch not found", "headers": { "omnigraph-http-api": { "schema": { @@ -4084,8 +3100,8 @@ } } }, - "410": { - "description": "Required retained history is no longer readable; see change_feed_gap and capture a new baseline", + "408": { + "description": "Request body deadline exceeded before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -4106,7 +3122,7 @@ } }, "413": { - "description": "Requested limit exceeds the public change ceiling", + "description": "Baseline cut or transport capacity exhausted; Request body exceeds its route limit before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -4154,7 +3170,7 @@ } }, "500": { - "description": "Internal failure while reading changes", + "description": "Internal failure while capturing the baseline", "headers": { "omnigraph-http-api": { "schema": { @@ -4175,7 +3191,7 @@ } }, "503": { - "description": "Recovery required before changes can be read; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "description": "Recovery required; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -4203,14 +3219,14 @@ ] } }, - "/graphs/{graph_id}/export": { - "post": { + "/graphs/{graph_id}/commits": { + "get": { "tags": [ - "queries" + "commits" ], - "summary": "Stream the contents of a branch as NDJSON.", - "description": "Emits one JSON object per line (`application/x-ndjson`). Filter with\n`type_names` (node/edge type names); an empty list streams the entire branch.\nSuitable for large exports — the response is streamed, not buffered.\nRead-only.", - "operationId": "cluster_export", + "summary": "List commits, most recent first.", + "description": "`branch` selects which history to list: a named branch returns the history\nreachable from that branch's head (the main commits inherited up to the\nfork plus the branch-authored commits); omitting it returns `main`'s\nhistory. There is no cross-branch listing. Ordering is part of the\ncontract — newest first by (graph-manifest version, created-at, commit id) — and\na future `cursor`/`limit` pagination will be keyset-based on that same\norder. Read-only.", + "operationId": "cluster_listCommits", "parameters": [ { "name": "graph_id", @@ -4221,6 +3237,17 @@ "type": "string" } }, + { + "name": "branch", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, { "name": "omnigraph-http-api", "in": "header", @@ -4234,19 +3261,51 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExportRequest" + "responses": { + "200": { + "description": "List of commits", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommitListOutput" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "Exported data as NDJSON", + "400": { + "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "401": { + "description": "Unauthorized", "headers": { "omnigraph-http-api": { "schema": { @@ -4259,11 +3318,15 @@ } }, "content": { - "application/x-ndjson": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } } }, - "400": { - "description": "Bad request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "403": { + "description": "Forbidden", "headers": { "omnigraph-http-api": { "schema": { @@ -4283,9 +3346,15 @@ } } }, - "401": { - "description": "Unauthorized", + "429": { + "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." + }, "omnigraph-http-api": { "schema": { "type": "string", @@ -4304,8 +3373,8 @@ } } }, - "403": { - "description": "Forbidden", + "503": { + "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -4324,9 +3393,58 @@ } } } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, + "/graphs/{graph_id}/commits/{commit_id}": { + "get": { + "tags": [ + "commits" + ], + "summary": "Get a single commit.", + "description": "Returns the commit's graph-manifest version, parent commit(s), and creation\nmetadata. Read-only.", + "operationId": "cluster_getCommit", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } }, - "404": { - "description": "Branch not found", + { + "name": "commit_id", + "in": "path", + "description": "Commit identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "omnigraph-http-api", + "in": "header", + "description": "exactly one omnigraph-http-api header with value 0.12 is required", + "required": true, + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + } + } + ], + "responses": { + "200": { + "description": "Commit details", "headers": { "omnigraph-http-api": { "schema": { @@ -4341,13 +3459,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/CommitOutput" } } } }, - "408": { - "description": "Request body deadline exceeded before operation admission", + "400": { + "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -4367,8 +3485,8 @@ } } }, - "409": { - "description": "Export authority conflict", + "401": { + "description": "Unauthorized", "headers": { "omnigraph-http-api": { "schema": { @@ -4388,8 +3506,8 @@ } } }, - "413": { - "description": "Export cut or transport capacity exhausted; Request body exceeds its route limit before operation admission", + "403": { + "description": "Forbidden", "headers": { "omnigraph-http-api": { "schema": { @@ -4409,8 +3527,8 @@ } } }, - "415": { - "description": "Request body must use application/json", + "404": { + "description": "Commit not found", "headers": { "omnigraph-http-api": { "schema": { @@ -4458,7 +3576,7 @@ } }, "503": { - "description": "Recovery required; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -4486,14 +3604,14 @@ ] } }, - "/graphs/{graph_id}/ingest": { - "post": { + "/graphs/{graph_id}/commits/{commit_id}/changes": { + "get": { "tags": [ - "mutations" + "changes" ], - "summary": "**Deprecated** — use [`POST /load`](#tag/mutations/operation/load) instead.", - "description": "Bulk-load NDJSON data into a branch. The deprecated route retains its\nparser and branch defaults, but its response uses the current canonical\nvocabulary. New integrations should target `POST /load`. Responses\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", + "summary": "Entity changes one commit made relative to its first parent.", + "description": "Read-only, in graph vocabulary with exact before/after images. Bounded:\na large commit continues via the opaque `page_token`. `set=` values are\nvalidated and select nothing in this release.", + "operationId": "cluster_getCommitChanges", "parameters": [ { "name": "graph_id", @@ -4504,6 +3622,82 @@ "type": "string" } }, + { + "name": "commit_id", + "in": "path", + "description": "Commit identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page_token", + "in": "query", + "description": "Opaque continuation from the preceding page of this response.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum changes per page. Server default applies when absent; above\nthe public ceiling the request fails with 413.", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "kind", + "in": "query", + "description": "Repeatable filter: node | edge.", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityKindOutput" + } + } + }, + { + "name": "type", + "in": "query", + "description": "Repeatable filter: accepted-schema type name.", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "op", + "in": "query", + "description": "Repeatable filter: insert | update | delete.", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChangeOpOutput" + } + } + }, + { + "name": "set", + "in": "query", + "description": "Repeatable session setting, `name=value` in GQ spelling\n(`set=merge_lineage=off`); only `request`-scope settings are accepted.\nValues are validated as session settings; `engine` does not change\nchange-feed execution.", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, { "name": "omnigraph-http-api", "in": "header", @@ -4517,19 +3711,9 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IngestRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Load results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", + "description": "Entity changes this commit made relative to its first parent, in frozen (kind, type, id, op) order with the cause stated once", "headers": { "omnigraph-http-api": { "schema": { @@ -4544,13 +3728,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IngestOutput" + "$ref": "#/components/schemas/CommitChangesOutput" } } } }, "400": { - "description": "Bad request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Invalid filter or limit, or a rejected page token; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -4565,7 +3749,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } @@ -4586,13 +3770,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Commit not found, or the actor cannot read the commit's branch", "headers": { "omnigraph-http-api": { "schema": { @@ -4607,13 +3791,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "408": { - "description": "Request body deadline exceeded before operation admission", + "409": { + "description": "Commit cannot be entity-diffed (parentless commit or schema boundary); see change_diff_refusal", "headers": { "omnigraph-http-api": { "schema": { @@ -4628,13 +3812,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "409": { - "description": "Prepared load authority changed before effects", + "410": { + "description": "Required retained history is no longer readable; see change_feed_gap and capture a new baseline", "headers": { "omnigraph-http-api": { "schema": { @@ -4649,13 +3833,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, "413": { - "description": "Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling; Request body exceeds its route limit before operation admission", + "description": "Requested limit exceeds the public change ceiling", "headers": { "omnigraph-http-api": { "schema": { @@ -4670,14 +3854,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "424": { - "description": "An allowed external Blob source could not be probed or read", + "429": { + "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." + }, "omnigraph-http-api": { "schema": { "type": "string", @@ -4691,20 +3881,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, - "429": { - "description": "Per-actor admission cap exceeded; honor `Retry-After` header; Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "500": { + "description": "Internal failure while reading changes", "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, "omnigraph-http-api": { "schema": { "type": "string", @@ -4718,13 +3902,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } }, "503": { - "description": "An overlapping durable recovery intent must be resolved before retry; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "description": "Recovery required before changes can be read; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -4739,13 +3923,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ChangeErrorOutput" } } } } }, - "deprecated": true, "security": [ { "bearer_token": [] @@ -4753,14 +3936,14 @@ ] } }, - "/graphs/{graph_id}/load": { + "/graphs/{graph_id}/export": { "post": { "tags": [ - "mutations" + "queries" ], - "summary": "Compatibility-load NDJSON data through a JSON envelope.", - "description": "`data` is NDJSON with one record per line. `mode` controls behavior on\nexisting entities: `merge` upserts by id (default), `append` strictly inserts\nabsent ids, and `overwrite` replaces type data. Branch creation is opt-in by\npresence of `from`: with `from` set, a missing `branch` is created from\nit; without `from`, `branch` must already exist — a missing branch is a\n404, never an implicit fork. **Destructive** when `mode` is `overwrite`\nor when the load produces conflicting writes.\n\nThe legacy `POST /ingest` route has identical semantics and is kept as a\ndeprecated alias.", - "operationId": "cluster_load", + "summary": "Stream the contents of a branch as NDJSON.", + "description": "Emits one JSON object per line (`application/x-ndjson`). Filter with\n`type_names` (node/edge type names); an empty list streams the entire branch.\nSuitable for large exports — the response is streamed, not buffered.\nRead-only.", + "operationId": "cluster_export", "parameters": [ { "name": "graph_id", @@ -4788,7 +3971,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IngestRequest" + "$ref": "#/components/schemas/ExportRequest" } } }, @@ -4796,7 +3979,7 @@ }, "responses": { "200": { - "description": "Load results", + "description": "Exported data as NDJSON", "headers": { "omnigraph-http-api": { "schema": { @@ -4809,11 +3992,7 @@ } }, "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IngestOutput" - } - } + "application/x-ndjson": {} } }, "400": { @@ -4879,6 +4058,27 @@ } } }, + "404": { + "description": "Branch not found", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, "408": { "description": "Request body deadline exceeded before operation admission", "headers": { @@ -4901,7 +4101,7 @@ } }, "409": { - "description": "Prepared load authority changed before effects", + "description": "Export authority conflict", "headers": { "omnigraph-http-api": { "schema": { @@ -4922,7 +4122,7 @@ } }, "413": { - "description": "Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling; Request body exceeds its route limit before operation admission", + "description": "Export cut or transport capacity exhausted; Request body exceeds its route limit before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -4942,8 +4142,8 @@ } } }, - "424": { - "description": "An allowed external Blob source could not be probed or read", + "415": { + "description": "Request body must use application/json", "headers": { "omnigraph-http-api": { "schema": { @@ -4964,7 +4164,7 @@ } }, "429": { - "description": "Per-actor admission cap exceeded; honor `Retry-After` header; Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", "headers": { "Retry-After": { "schema": { @@ -4991,7 +4191,7 @@ } }, "503": { - "description": "An overlapping durable recovery intent must be resolved before retry; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "description": "Recovery required; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -5019,14 +4219,14 @@ ] } }, - "/graphs/{graph_id}/load/ndjson": { + "/graphs/{graph_id}/load": { "post": { "tags": [ "mutations" ], - "summary": "Load one strict, bounded graph-level NDJSON batch.", - "description": "Bearer authentication runs in middleware. This handler completes both\nbranch authorization checks before polling the raw body. A successful\nresponse describes logical schema declarations only and is returned after\nthe ordinary graph commit is visible.", - "operationId": "cluster_loadNdjson", + "summary": "Compatibility-load NDJSON data through a JSON envelope.", + "description": "`data` is NDJSON with one record per line. `mode` controls behavior on\nexisting entities: `merge` upserts by id (default), `append` strictly inserts\nabsent ids, and `overwrite` replaces type data. Branch creation is opt-in by\npresence of `from`: with `from` set, a missing `branch` is created from\nit; without `from`, `branch` must already exist — a missing branch is a\n404, never an implicit fork. **Destructive** when `mode` is `overwrite`\nor when the load produces conflicting writes.", + "operationId": "cluster_load", "parameters": [ { "name": "graph_id", @@ -5037,46 +4237,6 @@ "type": "string" } }, - { - "name": "branch", - "in": "query", - "description": "Target branch. Defaults to `main`. Without `from`, it must exist.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "from", - "in": "query", - "description": "Parent branch used to create a missing target branch.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "mode", - "in": "query", - "description": "How existing entities are handled. Defaults to `merge`.", - "required": false, - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/LoadMode" - } - ] - } - }, { "name": "omnigraph-http-api", "in": "header", @@ -5091,11 +4251,10 @@ } ], "requestBody": { - "description": "Strict raw graph-level NDJSON. Each nonblank line is exactly one node envelope {\"type\":\"\",\"id\":\"\",\"data\":{...}} or edge envelope {\"edge\":\"\",\"id\":\"\",\"from\":\"\",\"to\":\"\",\"data\":{...}}. `data` defaults to {} and holds user properties; the optional top-level `id` follows ordinary ID semantics. Legacy-vintage graphs also accept `data.id` as identity when top-level `id` is absent and refuse both placements together. Duplicate, unknown, reserved physical, and noncanonical supplied entity-ID members are refused.", "content": { - "application/x-ndjson": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/IngestRequest" } } }, @@ -5103,7 +4262,7 @@ }, "responses": { "200": { - "description": "One committed graph-batch result", + "description": "Load results", "headers": { "omnigraph-http-api": { "schema": { @@ -5118,13 +4277,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GraphBatchLoadOutput" + "$ref": "#/components/schemas/IngestOutput" } } } }, "400": { - "description": "Malformed query or graph batch; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Bad request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -5186,27 +4345,6 @@ } } }, - "404": { - "description": "Target branch missing without `from`", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, "408": { "description": "Request body deadline exceeded before operation admission", "headers": { @@ -5250,28 +4388,7 @@ } }, "413": { - "description": "Request, load, or external Blob admission exceeds a bounded ceiling; Request body exceeds its route limit before operation admission", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "415": { - "description": "Content-Type must be application/x-ndjson", + "description": "Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling; Request body exceeds its route limit before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -5368,14 +4485,14 @@ ] } }, - "/graphs/{graph_id}/mutate": { + "/graphs/{graph_id}/load/ndjson": { "post": { "tags": [ "mutations" ], - "summary": "Apply a GQ mutation to a branch (canonical mutation endpoint).", - "description": "Writes to the named `branch` (defaults to `main`). Mutations are atomic\nper call and produce a new commit. Returns counts of nodes and edges\naffected. **Destructive**: on success the branch is updated; rejected\nmutations may still acquire locks briefly. Returns 409 when the prepared\nwrite authority changes before effects.\n\nConditional callers use `POST /mutate/if-graph-commit`, which requires and\nvalidates `Omnigraph-If-Graph-Commit`. This endpoint rejects that header.\n\nPairs with `POST /query` (read-only). The legacy `POST /change` route\nhas identical semantics and is kept as a deprecated alias.\n\nThe GQ statements `branch create`, `branch delete`, and `branch merge`\n(grammar: `BranchStmt` in `omnigraph-compiler`) are also served\nhere, with no `branch`, `name`, or `params`: each runs the handler body\nof its `/branches` route (same Cedar action, admission check, and errors,\nincluding the 409 of a conflicting merge) and answers a `ChangeOutput`\nwhose `outcome` names the effect. `branch list` is rejected with 400;\nsend it to `POST /query`.", - "operationId": "cluster_mutate", + "summary": "Load one strict, bounded graph-level NDJSON batch.", + "description": "Bearer authentication runs in middleware. This handler completes both\nbranch authorization checks before polling the raw body. A successful\nresponse describes logical schema declarations only and is returned after\nthe ordinary graph commit is visible.", + "operationId": "cluster_loadNdjson", "parameters": [ { "name": "graph_id", @@ -5386,6 +4503,46 @@ "type": "string" } }, + { + "name": "branch", + "in": "query", + "description": "Target branch. Defaults to `main`. Without `from`, it must exist.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "from", + "in": "query", + "description": "Parent branch used to create a missing target branch.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "mode", + "in": "query", + "description": "How existing entities are handled. Defaults to `merge`.", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LoadMode" + } + ] + } + }, { "name": "omnigraph-http-api", "in": "header", @@ -5400,10 +4557,11 @@ } ], "requestBody": { + "description": "Strict raw graph-level NDJSON. Each nonblank line is exactly one node envelope {\"type\":\"\",\"id\":\"\",\"data\":{...}} or edge envelope {\"edge\":\"\",\"id\":\"\",\"from\":\"\",\"to\":\"\",\"data\":{...}}. `data` defaults to {} and holds user properties; the optional top-level `id` follows ordinary ID semantics. Legacy-vintage graphs also accept `data.id` as identity when top-level `id` is absent and refuse both placements together. Duplicate, unknown, reserved physical, and noncanonical supplied entity-ID members are refused.", "content": { - "application/json": { + "application/x-ndjson": { "schema": { - "$ref": "#/components/schemas/ChangeRequest" + "type": "string" } } }, @@ -5411,7 +4569,7 @@ }, "responses": { "200": { - "description": "Mutation results", + "description": "One committed graph-batch result", "headers": { "omnigraph-http-api": { "schema": { @@ -5426,13 +4584,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangeOutput" + "$ref": "#/components/schemas/GraphBatchLoadOutput" } } } }, "400": { - "description": "Bad request - also returned when `branch list` arrives here instead of POST /query, when a request target accompanies a branch statement, when a name or parameters accompany a branch statement, and when a commit precondition accompanies a branch statement; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Malformed query or graph batch; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -5495,7 +4653,7 @@ } }, "404": { - "description": "`branch delete` of a branch that does not exist, as DELETE /branches/{branch} answers", + "description": "Target branch missing without `from`", "headers": { "omnigraph-http-api": { "schema": { @@ -5537,7 +4695,7 @@ } }, "409": { - "description": "Write-authority conflict; also `branch create` of a branch that already exists, and a conflicting `branch merge`, whose body carries `merge_conflicts`", + "description": "Prepared load authority changed before effects", "headers": { "omnigraph-http-api": { "schema": { @@ -5558,7 +4716,28 @@ } }, "413": { - "description": "Keyed write exceeds the per-commit entity or byte ceiling; Request body exceeds its route limit before operation admission", + "description": "Request, load, or external Blob admission exceeds a bounded ceiling; Request body exceeds its route limit before operation admission", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "415": { + "description": "Content-Type must be application/x-ndjson", "headers": { "omnigraph-http-api": { "schema": { @@ -5655,14 +4834,14 @@ ] } }, - "/graphs/{graph_id}/mutate/if-graph-commit": { + "/graphs/{graph_id}/mutate": { "post": { "tags": [ "mutations" ], - "summary": "Apply a mutation only while the branch still has the required graph head.", - "description": "This explicit conditional route requires `Omnigraph-If-Graph-Commit` and\nvalidates its precondition before mutation effects. `/mutate` rejects the\nprecondition header; conditional callers must use this route.", - "operationId": "cluster_mutate_if_graph_commit", + "summary": "Apply a GQ mutation to a branch (canonical mutation endpoint).", + "description": "Writes to the named `branch` (defaults to `main`). Mutations are atomic\nper call and produce a new commit. Returns counts of nodes and edges\naffected. **Destructive**: on success the branch is updated; rejected\nmutations may still acquire locks briefly. Returns 409 when the prepared\nwrite authority changes before effects.\n\nConditional callers use `POST /mutate/if-graph-commit`, which requires and\nvalidates `Omnigraph-If-Graph-Commit`. This endpoint rejects that header.\n\nPairs with `POST /query` (read-only).\n\nThe GQ statements `branch create`, `branch delete`, and `branch merge`\n(grammar: `BranchStmt` in `omnigraph-compiler`) are also served\nhere, with no `branch`, `name`, or `params`: each runs the handler body\nof its `/branches` route (same Cedar action, admission check, and errors,\nincluding the 409 of a conflicting merge) and answers a `ChangeOutput`\nwhose `outcome` names the effect. `branch list` is rejected with 400;\nsend it to `POST /query`.", + "operationId": "cluster_mutate", "parameters": [ { "name": "graph_id", @@ -5673,15 +4852,6 @@ "type": "string" } }, - { - "name": "Omnigraph-If-Graph-Commit", - "in": "header", - "description": "Required raw graph-head commit id. The mutation runs only while the branch's effective head still equals it.", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "omnigraph-http-api", "in": "header", @@ -5707,7 +4877,7 @@ }, "responses": { "200": { - "description": "Conditional mutation results", + "description": "Mutation results", "headers": { "omnigraph-http-api": { "schema": { @@ -5728,7 +4898,7 @@ } }, "400": { - "description": "Missing, duplicate, malformed, or invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Bad request - also returned when `branch list` arrives here instead of POST /query, when a request target accompanies a branch statement, when a name or parameters accompany a branch statement, and when a commit precondition accompanies a branch statement; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -5790,8 +4960,8 @@ } } }, - "408": { - "description": "Request body deadline exceeded before operation admission", + "404": { + "description": "`branch delete` of a branch that does not exist, as DELETE /branches/{branch} answers", "headers": { "omnigraph-http-api": { "schema": { @@ -5811,8 +4981,8 @@ } } }, - "409": { - "description": "Write-authority conflict", + "408": { + "description": "Request body deadline exceeded before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -5832,8 +5002,8 @@ } } }, - "412": { - "description": "Graph-commit precondition failed; the write had no effect", + "409": { + "description": "Write-authority conflict; also `branch create` of a branch that already exists, and a conflicting `branch merge`, whose body carries `merge_conflicts`", "headers": { "omnigraph-http-api": { "schema": { @@ -5951,14 +5121,14 @@ ] } }, - "/graphs/{graph_id}/queries": { - "get": { + "/graphs/{graph_id}/mutate/if-graph-commit": { + "post": { "tags": [ - "queries" + "mutations" ], - "summary": "List the graph's exposed stored queries as a typed tool catalog.", - "description": "Returns every stored query in the `queries:` registry, each\nwith its MCP tool name, read/mutate flag, description/instruction, and\ntyped parameters — enough for a client to register them as tools without\nfetching `.gq` source. Cluster-served graphs have no per-query expose flag,\nso the catalog lists them all. Read-gated; the catalog is graph-wide (branch\nindependent — `read` is authorized against `main`). **Not** Cedar-filtered\nper query yet, so it can list a query whose `invoke_query` the caller\nlacks (a known gap until per-query authorization lands).", - "operationId": "cluster_list_queries", + "summary": "Apply a mutation only while the branch still has the required graph head.", + "description": "This explicit conditional route requires `Omnigraph-If-Graph-Commit` and\nvalidates its precondition before mutation effects. `/mutate` rejects the\nprecondition header; conditional callers must use this route.", + "operationId": "cluster_mutate_if_graph_commit", "parameters": [ { "name": "graph_id", @@ -5969,6 +5139,15 @@ "type": "string" } }, + { + "name": "Omnigraph-If-Graph-Commit", + "in": "header", + "description": "Required raw graph-head commit id. The mutation runs only while the branch's effective head still equals it.", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "omnigraph-http-api", "in": "header", @@ -5982,9 +5161,19 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Stored-query catalog (every stored query, with typed params)", + "description": "Conditional mutation results", "headers": { "omnigraph-http-api": { "schema": { @@ -5999,13 +5188,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueriesCatalogOutput" + "$ref": "#/components/schemas/ChangeOutput" } } } }, "400": { - "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Missing, duplicate, malformed, or invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -6067,15 +5256,9 @@ } } }, - "429": { - "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "408": { + "description": "Request body deadline exceeded before operation admission", "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, "omnigraph-http-api": { "schema": { "type": "string", @@ -6094,8 +5277,8 @@ } } }, - "503": { - "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "409": { + "description": "Write-authority conflict", "headers": { "omnigraph-http-api": { "schema": { @@ -6114,74 +5297,9 @@ } } } - } - }, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/graphs/{graph_id}/queries/{name}": { - "post": { - "tags": [ - "queries" - ], - "summary": "Invoke a curated, server-side stored query by name.", - "description": "The query source comes from the graph's `queries:` registry, not the\nrequest body — callers send only runtime inputs (`params`, `branch`,\n`snapshot`). Gated by the `invoke_query` Cedar action at the boundary;\na stored *mutation* additionally passes the engine's `change` gate\n(double-gated). An actor **without** `invoke_query` cannot tell a denied\nquery from a missing one — both return the same 404, so the catalog\ncan't be probed without the grant. Once `invoke_query` is held, the\ninner `read`/`change` gate may surface a 403 for an existing query the\nactor can't run (the intended double-gate signal).", - "operationId": "cluster_invoke_query", - "parameters": [ - { - "name": "graph_id", - "in": "path", - "description": "Graph id to route the request to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "name", - "in": "path", - "description": "Stored query name (the registry key)", - "required": true, - "schema": { - "type": "string" - } }, - { - "name": "omnigraph-http-api", - "in": "header", - "description": "exactly one omnigraph-http-api header with value 0.12 is required", - "required": true, - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/InvokeStoredQueryRequest" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Read envelope (ReadOutput) or mutation envelope (ChangeOutput), serialized untagged", + "412": { + "description": "Graph-commit precondition failed; the write had no effect", "headers": { "omnigraph-http-api": { "schema": { @@ -6196,13 +5314,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvokeStoredQueryResponse" + "$ref": "#/components/schemas/ErrorOutput" } } } }, - "400": { - "description": "Bad request (param type error; snapshot on a stored mutation); api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "413": { + "description": "Keyed write exceeds the per-commit entity or byte ceiling; Request body exceeds its route limit before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -6222,8 +5340,8 @@ } } }, - "401": { - "description": "Unauthorized", + "424": { + "description": "An allowed external Blob source could not be probed or read", "headers": { "omnigraph-http-api": { "schema": { @@ -6243,9 +5361,15 @@ } } }, - "403": { - "description": "Forbidden (the inner `change` gate for a stored mutation)", + "429": { + "description": "Per-actor admission cap exceeded; honor `Retry-After` header; Server observer, ingress or operation admission capacity exhausted; honor Retry-After", "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." + }, "omnigraph-http-api": { "schema": { "type": "string", @@ -6264,8 +5388,8 @@ } } }, - "404": { - "description": "Unknown stored query, or `invoke_query` denied — indistinguishable to a caller without the grant", + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -6284,9 +5408,49 @@ } } } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, + "/graphs/{graph_id}/queries": { + "get": { + "tags": [ + "queries" + ], + "summary": "List the graph's exposed stored queries as a typed tool catalog.", + "description": "Returns every stored query in the `queries:` registry, each\nwith its MCP tool name, read/mutate flag, description/instruction, and\ntyped parameters — enough for a client to register them as tools without\nfetching `.gq` source. Cluster-served graphs have no per-query expose flag,\nso the catalog lists them all. Read-gated; the catalog is graph-wide (branch\nindependent — `read` is authorized against `main`). **Not** Cedar-filtered\nper query yet, so it can list a query whose `invoke_query` the caller\nlacks (a known gap until per-query authorization lands).", + "operationId": "cluster_list_queries", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } }, - "408": { - "description": "Request body deadline exceeded before operation admission", + { + "name": "omnigraph-http-api", + "in": "header", + "description": "exactly one omnigraph-http-api header with value 0.12 is required", + "required": true, + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + } + } + ], + "responses": { + "200": { + "description": "Stored-query catalog (every stored query, with typed params)", "headers": { "omnigraph-http-api": { "schema": { @@ -6301,13 +5465,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/QueriesCatalogOutput" } } } }, - "409": { - "description": "Stored mutation write-authority conflict, or a full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying", + "400": { + "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -6327,8 +5491,8 @@ } } }, - "413": { - "description": "Stored keyed mutation exceeds the per-commit entity or byte ceiling; Request body exceeds its route limit before operation admission", + "401": { + "description": "Unauthorized", "headers": { "omnigraph-http-api": { "schema": { @@ -6348,8 +5512,8 @@ } } }, - "424": { - "description": "A stored mutation could not probe or read an allowed external Blob source", + "403": { + "description": "Forbidden", "headers": { "omnigraph-http-api": { "schema": { @@ -6370,7 +5534,7 @@ } }, "429": { - "description": "Per-actor admission cap exceeded; honor `Retry-After` header; Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", "headers": { "Retry-After": { "schema": { @@ -6396,29 +5560,8 @@ } } }, - "500": { - "description": "Policy evaluation error (a denial is reported as 404, not 500)", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, "503": { - "description": "A stored mutation is blocked by a durable recovery intent; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -6446,14 +5589,14 @@ ] } }, - "/graphs/{graph_id}/queries/{name}/if-graph-commit": { + "/graphs/{graph_id}/queries/{name}": { "post": { "tags": [ "queries" ], - "summary": "Invoke one stored mutation with a required graph-head precondition.", - "description": "A distinct path makes support observable before any mutation runs; older\nservers return 404 instead of ignoring an unknown conditional header.", - "operationId": "cluster_invoke_query_if_graph_commit", + "summary": "Invoke a curated, server-side stored query by name.", + "description": "The query source comes from the graph's `queries:` registry, not the\nrequest body — callers send only runtime inputs (`params`, `branch`,\n`snapshot`). Gated by the `invoke_query` Cedar action at the boundary;\na stored *mutation* additionally passes the engine's `change` gate\n(double-gated). An actor **without** `invoke_query` cannot tell a denied\nquery from a missing one — both return the same 404, so the catalog\ncan't be probed without the grant. Once `invoke_query` is held, the\ninner `read`/`change` gate may surface a 403 for an existing query the\nactor can't run (the intended double-gate signal).", + "operationId": "cluster_invoke_query", "parameters": [ { "name": "graph_id", @@ -6467,16 +5610,7 @@ { "name": "name", "in": "path", - "description": "Stored mutation name (the registry key)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "Omnigraph-If-Graph-Commit", - "in": "header", - "description": "Required raw graph-head commit id. The stored mutation runs only while the branch's effective head still equals it.", + "description": "Stored query name (the registry key)", "required": true, "schema": { "type": "string" @@ -6513,7 +5647,7 @@ }, "responses": { "200": { - "description": "Stored conditional mutation result", + "description": "Read envelope (ReadOutput) or mutation envelope (ChangeOutput), serialized untagged", "headers": { "omnigraph-http-api": { "schema": { @@ -6528,13 +5662,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangeOutput" + "$ref": "#/components/schemas/InvokeStoredQueryResponse" } } } }, "400": { - "description": "Missing, duplicate, malformed, read-only, or invalid invocation; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Bad request (param type error; snapshot on a stored mutation); api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -6576,7 +5710,7 @@ } }, "403": { - "description": "Forbidden (the inner `change` gate)", + "description": "Forbidden (the inner `change` gate for a stored mutation)", "headers": { "omnigraph-http-api": { "schema": { @@ -6597,7 +5731,7 @@ } }, "404": { - "description": "Unknown stored mutation, or `invoke_query` denied", + "description": "Unknown stored query, or `invoke_query` denied — indistinguishable to a caller without the grant", "headers": { "omnigraph-http-api": { "schema": { @@ -6639,28 +5773,7 @@ } }, "409": { - "description": "Stored mutation write-authority conflict", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "412": { - "description": "Stored mutation graph-commit precondition failed; the write had no effect", + "description": "Stored mutation write-authority conflict, or a full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -6799,14 +5912,14 @@ ] } }, - "/graphs/{graph_id}/query": { + "/graphs/{graph_id}/queries/{name}/if-graph-commit": { "post": { "tags": [ "queries" ], - "summary": "Execute an inline read query (friendlier-named alternative to `POST /read`).", - "description": "Designed for ad-hoc exploration and AI-agent tool-use: short field\nnames (`query`, `name`) match the CLI `-e` flag and the GQ `query`\nkeyword. Mutations (`insert`/`update`/`delete`) are rejected with 400\n-- use `POST /mutate` (or its deprecated alias `POST /change`) for\nwrite queries. It shares `POST /read` target semantics (branch xor\nsnapshot) and the same Cedar action (Read), while its canonical response\nadditionally carries the pinned graph-commit token.\n\nThe GQ statement `branch list` is also served here, with no `branch`,\n`snapshot`, `name`, or `params`: it answers one result per branch (field\n`name`, byte order) under the same `read` check as `GET /branches`.\n`branch create`, `branch delete`, and `branch merge` are rejected with\n400; send them to `POST /mutate`.\n\nThe GQ statement `explain query …` is served here too: the query is not\nrun, and the result describes its v2 logical, physical and available\nDataFusion trees, one result per node (fields `tree`, `depth`, `node`, `detail`), followed by\n`plan` entries for the passes and the document's other fields, under the\nrequest's target and `params`.", - "operationId": "cluster_query", + "summary": "Invoke one stored mutation with a required graph-head precondition.", + "description": "A distinct path makes support observable before any mutation runs; older\nservers return 404 instead of ignoring an unknown conditional header.", + "operationId": "cluster_invoke_query_if_graph_commit", "parameters": [ { "name": "graph_id", @@ -6817,6 +5930,24 @@ "type": "string" } }, + { + "name": "name", + "in": "path", + "description": "Stored mutation name (the registry key)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Omnigraph-If-Graph-Commit", + "in": "header", + "description": "Required raw graph-head commit id. The stored mutation runs only while the branch's effective head still equals it.", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "omnigraph-http-api", "in": "header", @@ -6834,15 +5965,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryRequest" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/InvokeStoredQueryRequest" + } + ] } } - }, - "required": true + } }, "responses": { "200": { - "description": "Query results", + "description": "Stored conditional mutation result", "headers": { "omnigraph-http-api": { "schema": { @@ -6857,13 +5994,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadOutput" + "$ref": "#/components/schemas/ChangeOutput" } } } }, "400": { - "description": "Bad request - also returned when the query body contains mutations (use POST /mutate, or its deprecated alias POST /change, for write queries), when a control write statement (`branch create`, `branch delete`, `branch merge`) arrives here instead of POST /mutate, when a request target accompanies a branch statement, and when a name or parameters accompany a branch statement; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Missing, duplicate, malformed, read-only, or invalid invocation; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -6905,7 +6042,7 @@ } }, "403": { - "description": "Forbidden", + "description": "Forbidden (the inner `change` gate)", "headers": { "omnigraph-http-api": { "schema": { @@ -6925,8 +6062,8 @@ } } }, - "408": { - "description": "Request body deadline exceeded before operation admission", + "404": { + "description": "Unknown stored mutation, or `invoke_query` denied", "headers": { "omnigraph-http-api": { "schema": { @@ -6946,8 +6083,8 @@ } } }, - "409": { - "description": "Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying", + "408": { + "description": "Request body deadline exceeded before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -6967,8 +6104,8 @@ } } }, - "413": { - "description": "Request body exceeds its route limit before operation admission", + "409": { + "description": "Stored mutation write-authority conflict", "headers": { "omnigraph-http-api": { "schema": { @@ -6988,15 +6125,9 @@ } } }, - "429": { - "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "412": { + "description": "Stored mutation graph-commit precondition failed; the write had no effect", "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, "omnigraph-http-api": { "schema": { "type": "string", @@ -7015,8 +6146,8 @@ } } }, - "503": { - "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "413": { + "description": "Stored keyed mutation exceeds the per-commit entity or byte ceiling; Request body exceeds its route limit before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -7035,59 +6166,9 @@ } } } - } - }, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/graphs/{graph_id}/read": { - "post": { - "tags": [ - "queries" - ], - "summary": "**Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead.", - "description": "Execute a GQ read query. The route is kept indefinitely with a byte-stable\nenvelope; cell spelling follows the JSON writer. A `settings` field, and\na `set` or `reset` prefix in the source, are refused: the route runs under\nthe process defaults alone. 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": [ - { - "name": "graph_id", - "in": "path", - "description": "Graph id to route the request to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "omnigraph-http-api", - "in": "header", - "description": "exactly one omnigraph-http-api header with value 0.12 is required", - "required": true, - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadRequest" - } - } }, - "required": true - }, - "responses": { - "200": { - "description": "Legacy token-free query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", + "424": { + "description": "A stored mutation could not probe or read an allowed external Blob source", "headers": { "omnigraph-http-api": { "schema": { @@ -7102,35 +6183,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LegacyReadOutput" + "$ref": "#/components/schemas/ErrorOutput" } } } }, - "400": { - "description": "Bad request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "429": { + "description": "Per-actor admission cap exceeded; honor `Retry-After` header; Server observer, ingress or operation admission capacity exhausted; honor Retry-After", "headers": { - "omnigraph-http-api": { + "Retry-After": { "schema": { - "type": "string", - "enum": [ - "0.12" - ] + "type": "string" }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "401": { - "description": "Unauthorized", - "headers": { + "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." + }, "omnigraph-http-api": { "schema": { "type": "string", @@ -7149,8 +6215,8 @@ } } }, - "403": { - "description": "Forbidden", + "500": { + "description": "Policy evaluation error (a denial is reported as 404, not 500)", "headers": { "omnigraph-http-api": { "schema": { @@ -7170,8 +6236,8 @@ } } }, - "408": { - "description": "Request body deadline exceeded before operation admission", + "503": { + "description": "A stored mutation is blocked by a durable recovery intent; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -7190,30 +6256,59 @@ } } } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, + "/graphs/{graph_id}/query": { + "post": { + "tags": [ + "queries" + ], + "summary": "Execute an inline read query.", + "description": "Designed for ad-hoc exploration and AI-agent tool-use: short field\nnames (`query`, `name`) match the CLI `-e` flag and the GQ `query`\nkeyword. Mutations (`insert`/`update`/`delete`) are rejected with 400\n-- use `POST /mutate` for writes. Select a branch or snapshot and obtain\nthe pinned graph-commit token with the response. Cedar authorizes Read.\n\nThe GQ statement `branch list` is also served here, with no `branch`,\n`snapshot`, `name`, or `params`: it answers one result per branch (field\n`name`, byte order) under the same `read` check as `GET /branches`.\n`branch create`, `branch delete`, and `branch merge` are rejected with\n400; send them to `POST /mutate`.\n\nThe GQ statement `explain query …` is served here too: the query is not\nrun, and the result describes its v2 logical, physical and available\nDataFusion trees, one result per node (fields `tree`, `depth`, `node`, `detail`), followed by\n`plan` entries for the passes and the document's other fields, under the\nrequest's target and `params`.", + "operationId": "cluster_query", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } }, - "409": { - "description": "Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } + { + "name": "omnigraph-http-api", + "in": "header", + "description": "exactly one omnigraph-http-api header with value 0.12 is required", + "required": true, + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" } } }, - "413": { - "description": "Request body exceeds its route limit before operation admission", + "required": true + }, + "responses": { + "200": { + "description": "Query results", "headers": { "omnigraph-http-api": { "schema": { @@ -7228,20 +6323,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutput" + "$ref": "#/components/schemas/ReadOutput" } } } }, - "429": { - "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "400": { + "description": "Bad request - also returned when the query body contains mutations (use POST /mutate, for write queries), when a control write statement (`branch create`, `branch delete`, `branch merge`) arrives here instead of POST /mutate, when a request target accompanies a branch statement, and when a name or parameters accompany a branch statement; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { - "Retry-After": { - "schema": { - "type": "string" - }, - "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." - }, "omnigraph-http-api": { "schema": { "type": "string", @@ -7260,8 +6349,8 @@ } } }, - "503": { - "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "401": { + "description": "Unauthorized", "headers": { "omnigraph-http-api": { "schema": { @@ -7280,50 +6369,9 @@ } } } - } - }, - "deprecated": true, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/graphs/{graph_id}/schema": { - "get": { - "tags": [ - "schema" - ], - "summary": "Read the current schema source.", - "description": "Returns the project's schema as a single string in `.pg` source form.\nUseful for clients that want to introspect available types and properties\nbefore constructing GQ queries. Read-only.", - "operationId": "cluster_getSchema", - "parameters": [ - { - "name": "graph_id", - "in": "path", - "description": "Graph id to route the request to.", - "required": true, - "schema": { - "type": "string" - } }, - { - "name": "omnigraph-http-api", - "in": "header", - "description": "exactly one omnigraph-http-api header with value 0.12 is required", - "required": true, - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - } - } - ], - "responses": { - "200": { - "description": "Current schema source", + "403": { + "description": "Forbidden", "headers": { "omnigraph-http-api": { "schema": { @@ -7338,13 +6386,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchemaOutput" + "$ref": "#/components/schemas/ErrorOutput" } } } }, - "400": { - "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "408": { + "description": "Request body deadline exceeded before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -7364,8 +6412,8 @@ } } }, - "401": { - "description": "Unauthorized", + "409": { + "description": "Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -7385,8 +6433,8 @@ } } }, - "403": { - "description": "Forbidden", + "413": { + "description": "Request body exceeds its route limit before operation admission", "headers": { "omnigraph-http-api": { "schema": { @@ -7462,14 +6510,14 @@ ] } }, - "/graphs/{graph_id}/schema/apply": { - "post": { + "/graphs/{graph_id}/schema": { + "get": { "tags": [ - "mutations" + "schema" ], - "summary": "Apply a schema migration.", - "description": "Cluster-backed servers reject this route with `409 Conflict`; operators\nsubmit schema changes with `omnigraph cluster apply --server --config `.\n\nDiffs `schema_source` against the current schema and applies the resulting\nmigration steps (add/drop type, add/drop property, etc.). **Destructive**:\na drop removes data from the branch head; older commits keep reading it\nuntil `omnigraph cleanup` stops retaining them. Returns the list of steps\napplied; if `applied` is false the diff was unsupported and no changes\nwere made.", - "operationId": "cluster_applySchema", + "summary": "Read the current schema source.", + "description": "Returns the project's schema as a single string in `.pg` source form.\nUseful for clients that want to introspect available types and properties\nbefore constructing GQ queries. Read-only.", + "operationId": "cluster_getSchema", "parameters": [ { "name": "graph_id", @@ -7493,19 +6541,9 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SchemaApplyRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Schema apply results", + "description": "Current schema source", "headers": { "omnigraph-http-api": { "schema": { @@ -7520,13 +6558,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchemaApplyOutput" + "$ref": "#/components/schemas/SchemaOutput" } } } }, "400": { - "description": "Bad request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "description": "Invalid request; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { "omnigraph-http-api": { "schema": { @@ -7588,71 +6626,8 @@ } } }, - "408": { - "description": "Request body deadline exceeded before operation admission", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "409": { - "description": "Use `omnigraph cluster apply --server --config ` for live schema deployment", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, - "413": { - "description": "Request body exceeds its route limit before operation admission", - "headers": { - "omnigraph-http-api": { - "schema": { - "type": "string", - "enum": [ - "0.12" - ] - }, - "description": "Supported OmniGraph HTTP API contract." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorOutput" - } - } - } - }, "429": { - "description": "Per-actor admission cap exceeded; honor `Retry-After` header; Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", "headers": { "Retry-After": { "schema": { @@ -8709,14 +7684,14 @@ "string", "null" ], - "description": "Name of the mutation to run when `query` declares multiple.\n\nAccepts the legacy field name `query_name` as a deserialization alias." + "description": "Name of the mutation to run when `query` declares multiple." }, "params": { "description": "JSON object whose keys match the mutation's declared parameters." }, "query": { "type": "string", - "description": "GQ mutation source containing `insert`, `update`, or `delete` statements.\nMay declare multiple named mutations; pick one with `name`. May instead\nbe one branch statement (grammar: `BranchStmt` in `omnigraph-compiler`),\nsent with no `name`, `params`, or `branch`.\n\nAccepts the legacy field name `query_source` as a deserialization alias.", + "description": "GQ mutation source containing `insert`, `update`, or `delete` statements.\nMay declare multiple named mutations; pick one with `name`. May instead\nbe one branch statement (grammar: `BranchStmt` in `omnigraph-compiler`),\nsent with no `name`, `params`, or `branch`.", "example": "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}" }, "settings": { @@ -8730,7 +7705,8 @@ } ] } - } + }, + "additionalProperties": false }, "ChangeTypeOutput": { "type": "object", @@ -9642,35 +8618,6 @@ } } }, - "LegacyReadOutput": { - "type": "object", - "description": "Indefinitely byte-stable envelope of the deprecated `POST /read` route; cell\nspelling follows the JSON writer. The canonical [`ReadOutput`] may grow\nadditive fields; this legacy envelope deliberately cannot carry them.", - "required": [ - "query_name", - "target", - "row_count", - "rows" - ], - "properties": { - "columns": { - "type": "array", - "items": { - "type": "string" - } - }, - "query_name": { - "type": "string" - }, - "row_count": { - "type": "integer", - "minimum": 0 - }, - "rows": {}, - "target": { - "$ref": "#/components/schemas/ReadTargetOutput" - } - } - }, "LoadEmbeddingGeneration": { "type": "string", "description": "Load capability for a batch touching a node type with declared `@embed`.", @@ -9916,7 +8863,7 @@ }, "QueryRequest": { "type": "object", - "description": "Inline read-query request for `POST /query`.\n\nFriendlier-named alternative to [`ReadRequest`] for ad-hoc reads and\nAI-agent integration. Mutations are rejected with 400 — use `POST\n/mutate` (or its deprecated alias `POST /change`) for write queries.\nField names are deliberately short (`query`, `name`) to match the GQ\nkeyword and the CLI `-e` flag.", + "description": "Inline read-query request for `POST /query`.\n\nMutations are rejected with 400 — use `POST /mutate` for write queries.\nField names are deliberately short (`query`, `name`) to match the GQ\nkeyword and the CLI `-e` flag.", "required": [ "query" ], @@ -9940,7 +8887,7 @@ }, "query": { "type": "string", - "description": "GQ read-query source. May declare one or more named queries; pick one\nwith `name` when more than one is declared. Mutations\n(`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its\ndeprecated alias `POST /change`) instead. May instead be the branch\nstatement `branch list`, sent with no `name`, `params`, `branch`, or\n`snapshot`; or one `explain` statement (`explain query …`), which\nanswers the v2 plan instead of\nrunning it, as one result per plan node (fields `tree`, `depth`, `node`,\n`detail`: the logical, physical and available DataFusion trees, then `plan` entries for\nthe passes and the document's other fields), under the same `params`,\n`branch`, or `snapshot` as the query itself.", + "description": "GQ read-query source. May declare one or more named queries; pick one\nwith `name` when more than one is declared. Mutations\n(`insert`/`update`/`delete`) get 400 — use `POST /mutate` instead. May be the branch\nstatement `branch list`, sent with no `name`, `params`, `branch`, or\n`snapshot`; or one `explain` statement (`explain query …`), which\nanswers the v2 plan instead of\nrunning it, as one result per plan node (fields `tree`, `depth`, `node`,\n`detail`: the logical, physical and available DataFusion trees, then `plan` entries for\nthe passes and the document's other fields), under the same `params`,\n`branch`, or `snapshot` as the query itself.", "example": "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}" }, "settings": { @@ -9961,7 +8908,8 @@ ], "description": "Snapshot id to read from. Mutually exclusive with `branch`." } - } + }, + "additionalProperties": false }, "ReadOutput": { "type": "object", @@ -9998,46 +8946,6 @@ } } }, - "ReadRequest": { - "type": "object", - "required": [ - "query_source" - ], - "properties": { - "branch": { - "type": [ - "string", - "null" - ], - "description": "Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`." - }, - "params": { - "description": "JSON object whose keys match the query's declared parameters." - }, - "query_name": { - "type": [ - "string", - "null" - ], - "description": "Name of the query to run when `query_source` declares multiple. Optional\nwhen only one query is declared." - }, - "query_source": { - "type": "string", - "description": "GQ query source. May declare one or more named queries; pick one with\n`query_name` if there is more than one.", - "example": "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}" - }, - "settings": { - "description": "Refused when present: the deprecated route runs under the process\ndefaults. Typed as raw JSON so the refusal names the field instead of\na deserialization error." - }, - "snapshot": { - "type": [ - "string", - "null" - ], - "description": "Snapshot id to read from. Mutually exclusive with `branch`." - } - } - }, "ReadSetConflictOutput": { "type": "object", "description": "Structured authority mismatch for a prepared write. Values are\nstrings because members include optional graph commit ids and future\nauthority tokens, not only numeric published dataset versions.", @@ -10184,54 +9092,6 @@ } } }, - "SchemaApplyOutput": { - "type": "object", - "required": [ - "uri", - "supported", - "applied", - "step_count", - "graph_manifest_version", - "steps" - ], - "properties": { - "applied": { - "type": "boolean" - }, - "graph_manifest_version": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "step_count": { - "type": "integer", - "minimum": 0 - }, - "steps": { - "type": "array", - "items": {} - }, - "supported": { - "type": "boolean" - }, - "uri": { - "type": "string" - } - } - }, - "SchemaApplyRequest": { - "type": "object", - "required": [ - "schema_source" - ], - "properties": { - "schema_source": { - "type": "string", - "description": "Project schema in `.pg` source form. The diff against the current\nschema produces the migration steps that will be applied.", - "example": "node Person {\n name: String @key\n age: I32?\n}\n\nedge Knows: Person -> Person" - } - } - }, "SchemaOutput": { "type": "object", "required": [ From b61c2e868489250573b599fe63ea18ffc53f83e4 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 6 Oct 2026 13:55:16 +0300 Subject: [PATCH 06/12] docs: clarify deployment scope and deletion authorization --- .../rfcs/2026-09-29-server-runtime-and-online-deployment.md | 6 ++++-- docs/user/operations/server.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md index 1b7339d23..517fbae6e 100644 --- a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md +++ b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md @@ -598,8 +598,10 @@ or corrupt authoritative payloads must be restored; candidate sources cannot substitute for current permissions. Removing a graph declaration is destructive. Plan reports the delete effect; -apply authorizes it through the currently applied `ConfigManage` policy and -existing exclusive root admission. The intent binds the canonical derived graph +apply requires existing exclusive root admission. Authenticated callers need +currently applied `ConfigManage` permission plus graph `Read` and `SchemaApply`. +Storage-owner callers also need an actor with those graph permissions when a +graph policy is installed. The intent binds the canonical derived graph root and its applied schema contract before effects. A present root must match that contract; an already-absent root can complete deletion. The same outstanding ledger record persists `Started` before the first removal, then records diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index 9349b3129..43673b744 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -205,7 +205,7 @@ keep their existing routes and do not expose MCP. | `GET /openapi.json` | Runtime copy of the OpenAPI document | | `GET /graphs` | Graph metadata catalog; requires `graph_list` policy | | `GET /graphs/discovery` | Graph IDs and display names only; requires an identity credential | -| `POST /cluster/deployments` | Submit an exact-ID schema/query deployment or graph addition to the serving owner | +| `POST /cluster/deployments` | Submit an exact-ID deployment: graph creation/deletion or schema, query, policy, provider and Blob-binding changes | | `GET /cluster/deployments`, `GET /cluster/deployments/{id}` | Authorized deployment status and current-process activation observation | | `GET /.well-known/oauth-protected-resource` | Public OIDC resource metadata; only when OIDC trust is configured | | `/mcp` | Stored reads and discovery over MCP; only when OIDC trust is configured | From fed5b10dfcf6a760fe4f117ae4f8becee6f8534b Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 6 Oct 2026 18:49:40 +0300 Subject: [PATCH 07/12] fix(cluster): retain deployment completion and recover partial deletion --- .../cluster-lifecycle-completion.added.md | 2 +- ...rver-deployment-simplification.breaking.md | 6 +- crates/omnigraph-cli/tests/cli_cluster_e2e.rs | 7 +- .../src/deployment/execution.rs | 107 +++++---- .../omnigraph-cluster/src/deployment/tests.rs | 86 ++++++- crates/omnigraph-cluster/src/store.rs | 62 ++++- crates/omnigraph-cluster/src/tests.rs | 21 +- crates/omnigraph-cluster/tests/failpoints.rs | 129 ++++++++++- crates/omnigraph-server/src/deployment.rs | 76 ++++-- crates/omnigraph-server/src/registry.rs | 110 ++++++++- crates/omnigraph-server/src/serving.rs | 10 +- .../omnigraph-server/tests/boot_settings.rs | 217 +++++++++++++++++- docs/dev/control-plane.md | 41 ++-- docs/dev/testing.md | 6 +- ...29-server-runtime-and-online-deployment.md | 71 ++++-- docs/user/clusters/index.md | 20 +- 16 files changed, 835 insertions(+), 136 deletions(-) diff --git a/changelog.d/cluster-lifecycle-completion.added.md b/changelog.d/cluster-lifecycle-completion.added.md index 057c16030..c7590bc7a 100644 --- a/changelog.d/cluster-lifecycle-completion.added.md +++ b/changelog.d/cluster-lifecycle-completion.added.md @@ -1,3 +1,3 @@ -- Cluster apply supports policy grants/revocations and rebinding, embedding-provider changes, and external-Blob rules on existing graphs without restarting the server. Plan performs the same effect-free checks as apply without taking writer admission, and unrelated unavailable graphs do not block independent changes. Removing a graph declaration uses the same deployment and physically deletes its managed root and retained history after affected work drains. Adoption, missing-root recreation and repair overrides are unsupported. See [cluster operations][cluster-lifecycle-completion]. +- Cluster apply supports policy grants/revocations and rebinding, embedding-provider changes, and external-Blob rules on existing graphs without restarting the server. Plan performs the same effect-free checks as apply without taking writer admission, and unrelated unavailable graphs do not block independent changes. Removing a graph declaration uses the same deployment and physically deletes its managed root and retained history after affected work drains. Deletion recovery accepts older manifests from the same graph lifetime while refusing replacements. A fresh creation can reuse verified empty local directories left by abandoned creation cleanup. Adoption, missing-root recreation and repair overrides are unsupported. See [cluster operations][cluster-lifecycle-completion]. [cluster-lifecycle-completion]: ../docs/user/clusters/index.md diff --git a/changelog.d/server-deployment-simplification.breaking.md b/changelog.d/server-deployment-simplification.breaking.md index 0c9041dd2..df4d1d842 100644 --- a/changelog.d/server-deployment-simplification.breaking.md +++ b/changelog.d/server-deployment-simplification.breaking.md @@ -12,7 +12,11 @@ `activation` or `restart_required`. Finish outstanding deployments with their originating build and run the stopped-ledger upgrade before starting the new build on a ledger containing those completed-result fields. Conversion preserves - graph data and exact achieved receipts. + graph data and exact achieved receipts. Normal reads identify qualified old + receipts with `ledger_upgrade_required` and the stopped-upgrade command. + Unrelated blocked graphs no longer invalidate activation of an independent + deployment. The drain timeout bounds admitted-request drainage; owned deployment + preparation and completion continue under the process shutdown boundary. - The CLI also rejects the obsolete `repair --confirm` and `repair --force` controls. Repair continues diagnosing foreign linear table commits without diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs index a12829104..5abe03b1e 100644 --- a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -362,9 +362,10 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root .send().unwrap() }; assert_eq!(read_peer().status(), 503); - // Change only the tools query; the unavailable peer is unchanged. + // Change only the tools query. Unrelated unavailable graphs must not + // turn a converged, activated deployment into a CLI failure. fs::write(&tools, format!(" {}", fs::read_to_string(&tools).unwrap())).unwrap(); - let unrelated = parse_stdout_json(&output_failure( + let unrelated = parse_stdout_json(&output_success( cli() .env("OMNIGRAPH_BEARER_TOKEN", "live-deployment-token") .args([ @@ -382,7 +383,7 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root unrelated["deployment"]["result"]["converged"], true, "{unrelated}" ); - assert_eq!(unrelated["active"], false, "{unrelated}"); + assert_eq!(unrelated["active"], true, "{unrelated}"); assert_eq!(read_peer().status(), 503); // Drift and missing storage cannot be repaired by accepting new identity. for (file, expected) in [ diff --git a/crates/omnigraph-cluster/src/deployment/execution.rs b/crates/omnigraph-cluster/src/deployment/execution.rs index f9ebc39a5..a73ae97f8 100644 --- a/crates/omnigraph-cluster/src/deployment/execution.rs +++ b/crates/omnigraph-cluster/src/deployment/execution.rs @@ -1224,8 +1224,8 @@ fn verify_recorded_input( Ok(()) } -/// Runtime preview derived from one captured ledger and one preparation. -/// It is descriptive only; execution prepares again after the affected requests drain. +/// Runtime preview derived from captured control metadata, without graph opens +/// or schema-gate acquisition. Full engine preparation follows request drainage. pub struct DeploymentPreview { pub affected_graphs: Vec, pub serving: ServingSnapshot, @@ -1247,35 +1247,21 @@ pub async fn prepare_deployment_preview( let store = ClusterStore::for_storage_root(admission.canonical_root())?; let (state, cas) = read_existing(&store).await?; require_v2(&state)?; - let id = format!( - "{}:{}:{}", - state.ledger_id.as_ref().unwrap(), - state.next_sequence.unwrap(), - Ulid::new() - ); - let input_digest = sha256_hex( - &serde_json::to_vec(bundle) - .map_err(|error| refusal("deployment_encode", error.to_string()))?, - ); - let prepared = prepare_deployment( - &store, - bundle, - caller, - (state.clone(), cas.clone()), - &BTreeMap::new(), - &id, - &input_digest, - ) - .await?; - let affected_graphs: Vec<_> = prepared - .state - .outstanding - .as_ref() - .unwrap() - .graphs - .keys() - .cloned() + let policy = deployment_policies(&store, &state, bundle, caller).await?; + if state.outstanding.is_some() { + return Err(refusal( + "cluster_deployment_outstanding", + "original deployment remains outstanding", + )); + } + authorization::refuse_pending_recovery(&store).await?; + let effects = preview_deployment_scope(&state, bundle)?; + let affected_graphs: Vec<_> = affected_graphs(&state, bundle, &effects) + .into_iter() .collect(); + for graph in &affected_graphs { + authorize_graph_change(&state, bundle, caller, &policy, graph)?; + } let serving = serve::preview_snapshot_with_store(&store, bundle, &affected_graphs, state, cas) .await .map_err(|mut diagnostics| diagnostics.remove(0))?; @@ -1285,6 +1271,35 @@ pub async fn prepare_deployment_preview( }) } +/// Authorize from applied control metadata before any graph gate or open. +/// The executor repeats the same check at its fresh accepted-base capture. +fn authorize_graph_change( + state: &ClusterState, + bundle: &DeploymentBundle, + caller: &DeploymentCaller, + policy: &AppliedPolicies, + graph: &str, +) -> Result<(), Diagnostic> { + if !state + .applied_revision + .resources + .contains_key(&graph_address(graph)) + { + return Ok(()); + } + if !bundle.resources.contains_key(&graph_address(graph)) { + return authorize_graph_deletion(policy, caller, graph); + } + let schema = schema_address(graph); + if state.applied_revision.resources[&schema].digest != bundle.resources[&schema].digest + && let DeploymentCaller::AuthenticatedIdentity(identity) = caller + { + policy.check_graph(identity.actor(), graph, PolicyAction::Read)?; + policy.check_graph(identity.actor(), graph, PolicyAction::SchemaApply)?; + } + Ok(()) +} + /// Submit a frozen deployment under direct writer admission. `report_id` runs before acceptance and /// graph effects, so a severed caller can later look up the original identity. /// Resubmission with an existing ID only observes; it never executes again. @@ -1755,6 +1770,7 @@ async fn prepare_graphs( let mut graphs = BTreeMap::new(); let mut migrations = BTreeMap::new(); for graph in affected { + authorize_graph_change(state, bundle, caller, policy, &graph)?; let existing = state .applied_revision .resources @@ -1786,6 +1802,9 @@ async fn prepare_graphs( .graph_root_exists(&uri) .await .map_err(|error| refusal("graph_unavailable", error.to_string()))? + && !store + .graph_root_is_empty_local_directory(&uri) + .map_err(|error| refusal("graph_unavailable", error.to_string()))? { return Err(refusal( "graph_root_exists", @@ -1808,7 +1827,6 @@ async fn prepare_graphs( continue; } if !bundle.resources.contains_key(&graph_address(&graph)) { - authorize_graph_deletion(policy, caller, &graph)?; let root = store.canonical_managed_graph_root(&graph)?; let contract = state.applied_revision.schema_contracts.as_ref().unwrap()[&graph].clone(); @@ -1868,10 +1886,6 @@ async fn prepare_graphs( )); } if schema_changed { - if let DeploymentCaller::AuthenticatedIdentity(identity) = caller { - policy.check_graph(identity.actor(), &graph, PolicyAction::Read)?; - policy.check_graph(identity.actor(), &graph, PolicyAction::SchemaApply)?; - } let (intent, migration) = db .prepare_schema_apply_with_plan_as( source_for(bundle, &schema_address)?, @@ -2352,11 +2366,24 @@ pub async fn reconcile_deployment( .map_err(|error| refusal("graph_unavailable", error.to_string())) }; match opened { - Ok(db) if db.schema_contract_digest() != delete.contract => { - return Err(refusal( - "deployment_outcome_unknown", - "graph deletion root contains a foreign schema identity", - )); + Ok(db) => { + let observed = db.schema_contract_digest(); + let matches_authority = + if matches!(entry.state, GraphDeploymentState::NotStarted) { + observed == delete.contract + } else { + // A partial purge may remove newer manifests before old + // ones. An older schema of this lifetime is still owned + // by the accepted deletion; a replacement lifetime is not. + observed.schema_identity_domain + == delete.contract.schema_identity_domain + }; + if !matches_authority { + return Err(refusal( + "deployment_outcome_unknown", + "graph deletion root contains a foreign schema identity", + )); + } } Err(error) if matches!(entry.state, GraphDeploymentState::NotStarted) => { return Err(error); diff --git a/crates/omnigraph-cluster/src/deployment/tests.rs b/crates/omnigraph-cluster/src/deployment/tests.rs index 6bed5c877..abfea655a 100644 --- a/crates/omnigraph-cluster/src/deployment/tests.rs +++ b/crates/omnigraph-cluster/src/deployment/tests.rs @@ -182,9 +182,15 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { ) .unwrap(); let bundle = capture_deployment(dir.path()).unwrap(); - let preview = prepare_deployment_preview(&bundle, &admission, &owner()) - .await - .unwrap(); + // The serving preview reads control metadata only. Full engine + // preparation runs after the server closes and drains affected work; + // reopening here would queue an exclusive schema gate behind writers. + let graph_root = dir.path().join("graphs/knowledge.omni"); + let parked_root = dir.path().join("parked-knowledge.omni"); + fs::rename(&graph_root, &parked_root).unwrap(); + let preview = prepare_deployment_preview(&bundle, &admission, &owner()).await; + fs::rename(&parked_root, &graph_root).unwrap(); + let preview = preview.expect("serving preview must not open an affected graph"); assert_eq!(preview.affected_graphs, ["knowledge"]); let mut started = false; let applied = apply_captured_deployment( @@ -354,6 +360,80 @@ async fn graph_creation_refuses_foreign_root_without_holding_new_admission() { unlock(root).await; add_second_graph(dir.path()); let foreign = dir.path().join("graphs/second.omni"); + let ledger = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + for residue in ["root_file", "nested_file", "directory_bound"] { + match residue { + "root_file" => fs::write(&foreign, b"unmanaged marker").unwrap(), + "nested_file" => { + fs::create_dir_all(foreign.join("nested")).unwrap(); + fs::write(foreign.join("nested/keep"), b"unmanaged data").unwrap(); + } + _ => { + fs::create_dir(&foreign).unwrap(); + for index in 0..64 { + fs::create_dir(foreign.join(index.to_string())).unwrap(); + } + } + } + let error = apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) + .await + .unwrap_err(); + assert_eq!(error.code, "graph_root_exists", "{residue}: {error:?}"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger + ); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + if residue == "root_file" { + assert_eq!(fs::read(&foreign).unwrap(), b"unmanaged marker"); + fs::remove_file(&foreign).unwrap(); + } else { + if residue == "nested_file" { + assert_eq!( + fs::read(foreign.join("nested/keep")).unwrap(), + b"unmanaged data" + ); + } + fs::remove_dir_all(&foreign).unwrap(); + } + } + #[cfg(unix)] + for broken in [false, true] { + let external = tempfile::tempdir().unwrap(); + let target = if broken { + external.path().join("missing") + } else { + external.path().to_path_buf() + }; + fs::create_dir(&foreign).unwrap(); + std::os::unix::fs::symlink(&target, foreign.join("__manifest")).unwrap(); + let error = apply_deployment(dir.path(), None, &owner(), |_, _, _| {}) + .await + .unwrap_err(); + assert_eq!(error.code, "graph_root_exists"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger + ); + assert!(!dir.path().join(CLUSTER_LOCK_FILE).exists()); + assert!(fs::read_dir(external.path()).unwrap().next().is_none()); + assert!( + fs::symlink_metadata(foreign.join("__manifest")) + .unwrap() + .file_type() + .is_symlink() + ); + fs::remove_dir_all(&foreign).unwrap(); + } + // Existing cloud prefixes (including exact marker objects) never use the + // local directory exception; their normal existence refusal stays intact. + let store = ClusterStore::for_storage_root(root).unwrap(); + for uri in [ + "s3://test/graphs/second.omni", + "az://test/graphs/second.omni", + ] { + assert!(!store.graph_root_is_empty_local_directory(uri).unwrap()); + } Omnigraph::init(foreign.to_str().unwrap(), crate::tests::SCHEMA) .await .unwrap(); diff --git a/crates/omnigraph-cluster/src/store.rs b/crates/omnigraph-cluster/src/store.rs index bed97a038..c82500dbd 100644 --- a/crates/omnigraph-cluster/src/store.rs +++ b/crates/omnigraph-cluster/src/store.rs @@ -53,16 +53,26 @@ pub(crate) struct StateSnapshot { fn decode_ledger(text: &str, upgrade: bool) -> Result<(ClusterState, bool), Diagnostic> { let invalid = |message: String| Diagnostic::error("invalid_state_json", CLUSTER_STATE_FILE, message); - let strict = serde_json::from_str::(text); - if !upgrade || strict.is_ok() { - return strict - .map(|state| (state, false)) - .map_err(|error| invalid(error.to_string())); + let strict_error = match serde_json::from_str::(text) { + Ok(state) => return Ok((state, false)), + Err(error) => error, + }; + if !upgrade { + // Reuse the bounded converter only to recognize a qualified prior + // receipt shape. Ordinary reads never consume the converted state. + if matches!(decode_ledger(text, true), Ok((_, true))) { + return Err(Diagnostic::error( + "ledger_upgrade_required", + CLUSTER_STATE_FILE, + "completed receipts contain obsolete runtime fields; stop serving, writers and maintenance, establish prior I/O quiescence, then run `omnigraph --cluster cluster upgrade-ledger --writers-stopped`", + )); + } + return Err(invalid(strict_error.to_string())); } let mut value = omnigraph::loader::parse_unique_json(text).map_err(|error| invalid(error.to_string()))?; if value.get("version").and_then(serde_json::Value::as_u64) != Some(2) { - return Err(invalid(strict.unwrap_err().to_string())); + return Err(invalid(strict_error.to_string())); } if value .get("outstanding") @@ -396,6 +406,46 @@ impl ClusterStore { out } + /// Exact birth cleanup can leave directory skeletons on local filesystems. + /// Admit only a bounded tree of real directories: object-store listings + /// follow symlinks and hide broken ones, so their zero-object result alone + /// cannot prove this local exception safe. Engine preparation still checks + /// the target before minting creation authority. Cloud markers never qualify. + pub(crate) fn graph_root_is_empty_local_directory( + &self, + graph_uri: &str, + ) -> omnigraph_storage::Result { + if storage_kind_for_uri(graph_uri)? != StorageKind::Local { + return Ok(false); + } + let root = Path::new(graph_uri.trim_start_matches("file://")); + if !std::fs::symlink_metadata(root)?.file_type().is_dir() { + return Ok(false); + } + let mut pending = vec![root.to_path_buf()]; + let mut directories = 1usize; + let mut path_bytes = root.as_os_str().len(); + while let Some(directory) = pending.pop() { + if path_bytes > 64 * 1024 { + return Ok(false); + } + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + directories += 1; + if directories > 64 || !entry.file_type()?.is_dir() { + return Ok(false); + } + let path = entry.path(); + path_bytes = path_bytes.saturating_add(path.as_os_str().len()); + if path_bytes > 64 * 1024 { + return Ok(false); + } + pending.push(path); + } + } + Ok(true) + } + /// Existence probe before graph creation or read-only observation. A bare local /// path or any URI works — resolved through the same adapter machinery /// the engine uses. diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index e0d5126e9..614fc0f2b 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -1953,13 +1953,12 @@ async fn offline_deployment_upgrade_preserves_data_history_and_applied_facts() { } let previous_bytes = serde_json::to_vec(&previous).unwrap(); fs::write(&legacy_path, &previous_bytes).unwrap(); - assert_eq!( - deployment_status(root, None, &caller) - .await - .unwrap_err() - .code, - "invalid_state_json" - ); + let needs_upgrade = deployment_status(root, None, &caller).await.unwrap_err(); + assert_eq!(needs_upgrade.code, "ledger_upgrade_required"); + assert!(needs_upgrade.message.contains( + "omnigraph --cluster cluster upgrade-ledger --writers-stopped" + )); + assert_eq!(fs::read(&legacy_path).unwrap(), previous_bytes); assert_eq!( upgrade_deployment_ledger(root, false, &caller) .await @@ -1987,6 +1986,14 @@ async fn offline_deployment_upgrade_preserves_data_history_and_applied_facts() { } let corrupt_bytes = serde_json::to_vec(&corrupt).unwrap(); fs::write(&legacy_path, &corrupt_bytes).unwrap(); + assert_eq!( + deployment_status(root, None, &caller) + .await + .unwrap_err() + .code, + "invalid_state_json", + "{invalid} must not be diagnosed as a qualified ledger conversion", + ); let refused = upgrade_deployment_ledger(root, true, &caller) .await .unwrap_err(); diff --git a/crates/omnigraph-cluster/tests/failpoints.rs b/crates/omnigraph-cluster/tests/failpoints.rs index 0cfdd7136..4b7b1ed65 100644 --- a/crates/omnigraph-cluster/tests/failpoints.rs +++ b/crates/omnigraph-cluster/tests/failpoints.rs @@ -285,6 +285,14 @@ async fn offline_deployment_failure_windows_preserve_original_identity() { &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_STARTED, "foreign", ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_STARTED, + "older_schema", + ), + ( + &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_ACCEPTANCE, + "older_schema_before_started", + ), ( &omnigraph_cluster::seams::catalog::DEPLOYMENT_AFTER_SCHEMA, "none", @@ -301,6 +309,18 @@ async fn offline_deployment_failure_windows_preserve_original_identity() { let dir = offline_fixture().await; let root = dir.path().to_str().unwrap(); let graph = dir.path().join("graphs/knowledge.omni"); + if damage.starts_with("older_schema") { + // Commit a new schema before deleting. A partial + // prefix purge may remove its newest manifest while older versions + // still expose this same graph lifetime's previous schema. + let evolved = apply_deployment(dir.path(), None, &deployment_owner(), |_, _, _| {}) + .await + .unwrap(); + assert!( + matches!(evolved, DeploymentLookup::Complete { ref result } if result.converged) + ); + unlock_offline(dir.path()).await; + } let contract = Omnigraph::open_read_only(graph.to_str().unwrap()) .await .unwrap() @@ -341,6 +361,64 @@ async fn offline_deployment_failure_windows_preserve_original_identity() { unlock_offline(dir.path()).await; } match damage { + "older_schema" | "older_schema_before_started" => { + let db = Omnigraph::open_read_only(graph.to_str().unwrap()) + .await + .unwrap(); + let version = db + .graph_manifest_version_of(omnigraph::db::ReadTarget::branch("main")) + .await + .unwrap(); + drop(db); + // Lance 11 V2 manifests invert the version for lexical ordering. + let latest = graph + .join("__manifest/_versions") + .join(format!("{:020}.manifest", u64::MAX - version)); + assert!(latest.exists()); + // Model an interruption after the sequential lexicographic + // object-store purge has removed every file through this key, + // including __history and __manifest/_transactions. + fn files(dir: &Path, out: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + files(&path, out); + } else { + out.push(path); + } + } + } + let mut inventory = Vec::new(); + files(&graph, &mut inventory); + inventory.sort(); + let prefix = inventory + .into_iter() + .take_while(|path| path <= &latest) + .collect::>(); + assert!(prefix.contains(&latest)); + assert!( + prefix + .iter() + .any(|path| path.starts_with(graph.join("__history"))) + ); + assert!( + prefix + .iter() + .any(|path| path.starts_with(graph.join("__manifest/_transactions"))) + ); + for path in prefix { + fs::remove_file(path).unwrap(); + } + let older = Omnigraph::open_read_only(graph.to_str().unwrap()) + .await + .unwrap(); + assert_eq!(older.schema_source().as_str(), SCHEMA); + assert_eq!( + older.schema_contract_digest().schema_identity_domain, + contract.schema_identity_domain + ); + assert_ne!(older.schema_contract_digest(), contract); + } "partial" => fs::remove_dir_all(graph.join("__manifest")).unwrap(), "foreign" => { fs::remove_dir_all(&graph).unwrap(); @@ -353,7 +431,7 @@ async fn offline_deployment_failure_windows_preserve_original_identity() { fs::remove_file(dir.path().join("cluster.yaml")).unwrap(); fs::remove_file(dir.path().join("people.pg")).unwrap(); let recovered = reconcile_deployment(root, &id, true, &deployment_owner()).await; - if damage == "foreign" { + if matches!(damage, "foreign" | "older_schema_before_started") { assert_eq!(recovered.unwrap_err().code, "deployment_outcome_unknown"); assert!(graph.exists()); assert_eq!( @@ -900,6 +978,11 @@ async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { (&seams::DEPLOYMENT_AFTER_STARTED, false, false), (&seams::DEPLOYMENT_AFTER_SCHEMA, true, false), (&seams::DEPLOYMENT_AFTER_SCHEMA, true, true), + ( + &omnigraph::seams::catalog::INIT_TABLE_CREATE_POST_NATIVE, + false, + false, + ), ] { let dir = fixture(); let root = dir.path().to_str().unwrap(); @@ -924,7 +1007,14 @@ async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { }) .await .unwrap_err(); - assert_eq!(error.code, "injected_failpoint"); + assert_eq!( + error.code, + if seam.name() == "init.table_create_post_native" { + "deployment_outcome_unknown" + } else { + "injected_failpoint" + } + ); } unlock_offline(dir.path()).await; if replace { @@ -959,6 +1049,41 @@ async fn graph_creation_crash_reconciles_exact_genesis_without_replay() { | GraphDeploymentResult::Refused { .. } | GraphDeploymentResult::NotAttempted )); + if seam.name() == "init.table_create_post_native" { + // Exact unpublished cleanup has completed. A new + // invocation should be able to create the declared graph. + let graph = dir.path().join(format!("graphs/{target}.omni")); + assert!(graph.is_dir()); + let mut dirs = vec![graph.clone()]; + let mut files = Vec::new(); + while let Some(dir) = dirs.pop() { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + dirs.push(path); + } else { + files.push(path); + } + } + } + assert!(files.is_empty(), "cleanup left files: {files:?}"); + Omnigraph::prepare_graph_create(graph.to_str().unwrap(), SCHEMA) + .await + .unwrap(); + unlock_offline(dir.path()).await; + fs::write( + &path, + source.replace("graphs:\n", "graphs:\n second:\n schema: ./people.pg\n"), + ) + .unwrap(); + fs::write(dir.path().join("people.pg"), SCHEMA).unwrap(); + let retry = + apply_deployment(dir.path(), None, &deployment_owner(), |_, _, _| {}).await; + assert!( + matches!(retry, Ok(DeploymentLookup::Complete { ref result }) if result.converged), + "cleaned abandoned creation must permit fresh apply: {retry:?}" + ); + } } } } diff --git a/crates/omnigraph-server/src/deployment.rs b/crates/omnigraph-server/src/deployment.rs index 67af96e41..10f5db6f8 100644 --- a/crates/omnigraph-server/src/deployment.rs +++ b/crates/omnigraph-server/src/deployment.rs @@ -2,7 +2,7 @@ //! Input and graph outcomes belong to the cluster ledger; this controller owns //! only request lifetime, graph admission and activation of serving bindings. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use axum::{ @@ -26,9 +26,9 @@ use crate::{ }; pub(crate) const REQUEST_BYTES: usize = omnigraph_cluster::MAX_BUNDLE_BYTES + 1024; -// One absolute transition deadline, including drain and activation. Expiry after -// effects never reopens old bindings. The process shutdown deadline remains authoritative. -const TRANSITION_TIMEOUT: Duration = Duration::from_secs(300); +// Bound only the pre-effect drain. The owned executor retains completion and +// activation after drainage; the process shutdown deadline remains authoritative. +const DRAIN_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Default)] pub(crate) struct DeploymentRuntime { @@ -111,6 +111,9 @@ pub(crate) struct ActiveDeployment { input_digest: String, result_revision: u64, config_digest: String, + /// Installed scope: true requires a ready binding, false requires absence. + /// Boot expands the ready set to its complete captured inventory. + graphs: HashMap, } impl ActiveDeployment { @@ -126,6 +129,16 @@ impl ActiveDeployment { input_digest: result.input_digest.clone(), result_revision: result.result_revision, config_digest: result.config_digest.clone()?, + graphs: result + .graphs + .iter() + .map(|(id, outcome)| { + Some(( + GraphKey::cluster(GraphId::try_from(id.as_str()).ok()?), + !matches!(outcome, GraphDeploymentResult::Deleted { .. }), + )) + }) + .collect::>()?, }) } } @@ -139,6 +152,19 @@ pub(crate) fn initialize_boot_activation(state: &AppState) { .filter(|result| result.config_digest == state.witness.booted_serving_digest) .and_then(|result| ActiveDeployment::new(owner, result)) }); + let activation = activation.map(|mut activation| { + activation.graphs.extend( + state + .routing + .registry + .snapshot_ref() + .graphs + .keys() + .cloned() + .map(|key| (key, true)), + ); + activation + }); state.routing.registry.initialize_deployment(activation); } @@ -153,18 +179,27 @@ fn active_result( let Some(DeploymentLookup::Complete { result }) = lookup else { return false; }; - if result.result_revision != current_revision { + if !result.converged || result.result_revision != current_revision { return false; } state .operations .while_open(|| { let snapshot = state.routing.registry.snapshot_ref(); - let expected = ActiveDeployment::new(owner, result); - Ok::<_, ApiError>(expected.is_some() - && snapshot.deployment == expected - && snapshot.graphs.values().all(|entry| { - matches!(entry, crate::GraphEntry::Ready(view) if view.contract_is_current()) + Ok::<_, ApiError>(snapshot.deployment.as_ref().is_some_and(|active| { + active.canonical_root == owner.canonical_root() + && active.process_incarnation == owner.lock_id() + && active.id == result.id + && active.input_digest == result.input_digest + && active.result_revision == result.result_revision + && Some(&active.config_digest) == result.config_digest.as_ref() + && active.graphs.iter().all(|(key, present)| { + if *present { + matches!(snapshot.graphs.get(key), Some(crate::GraphEntry::Ready(view)) if view.contract_is_current()) + } else { + !snapshot.graphs.contains_key(key) + } + }) })) }) .unwrap_or(false) @@ -302,6 +337,7 @@ async fn execute( .map_err(refusal)?; let affected = preview.affected_graphs; validate_serving_candidate(&state, &owner, preview.serving)?; + let desired = request.deployment.graph_ids(); let mut keys = Vec::new(); for id in &affected { let key = GraphKey::cluster( @@ -312,7 +348,12 @@ async fn execute( RegistryLookup::Ready(_) | RegistryLookup::Transitioning(_) | RegistryLookup::Blocked(_) => keys.push(key), - RegistryLookup::Gone => {} // a new graph becomes visible only after achieved publication + RegistryLookup::Gone if desired.contains(id) => {} // new graph + RegistryLookup::Gone => { + return Err(ApiError::conflict(format!( + "graph {id} is absent from the serving registry; restart before deletion" + ))); + } _ => { return Err(ApiError::conflict(format!( "graph {id} is unavailable for deployment" @@ -323,11 +364,7 @@ async fn execute( let transition = state .routing .registry - .prepare_deployment_transition( - &state.operations, - &keys, - Instant::now() + TRANSITION_TIMEOUT, - ) + .prepare_deployment_transition(&state.operations, &keys, Instant::now() + DRAIN_TIMEOUT) .map_err(|error| ApiError::conflict(error.to_string()))? .close() .map_err(|error| ApiError::conflict(error.to_string()))?; @@ -360,6 +397,13 @@ async fn execute( .into_iter() .map(|(key, engine)| (key.graph_id.to_string(), engine)), ); + if let Err(error) = transition.retain_deployment_completion() { + return Err(abort_before_effects( + &state, + transition, + ApiError::conflict(error.to_string()), + )); + } let mut effects_started = false; let applied = omnigraph_cluster::apply_captured_deployment( &request.deployment, diff --git a/crates/omnigraph-server/src/registry.rs b/crates/omnigraph-server/src/registry.rs index 102182802..8e1decb7a 100644 --- a/crates/omnigraph-server/src/registry.rs +++ b/crates/omnigraph-server/src/registry.rs @@ -187,6 +187,7 @@ struct RegistryState { struct TransitionCandidate { record: Arc, closed: bool, + completion_owned: bool, } pub struct GraphRegistry { @@ -399,11 +400,9 @@ impl GraphRegistry { } // Expiry retires bookkeeping only. Closed graphs and every // admitted descendant remain retained by their registry entries. - if state - .candidate - .as_ref() - .is_some_and(|candidate| Instant::now() >= candidate.record.deadline) - { + if state.candidate.as_ref().is_some_and(|candidate| { + !candidate.completion_owned && Instant::now() >= candidate.record.deadline + }) { state.candidate = None; } if state.candidate.is_some() { @@ -459,6 +458,7 @@ impl GraphRegistry { state.candidate = Some(TransitionCandidate { record: Arc::clone(&record), closed: false, + completion_owned: false, }); Ok(PreparedTransition::new( Arc::clone(self), @@ -534,6 +534,19 @@ impl GraphRegistry { validate_closed(&locked(&self.state), record) } + /// Once affected requests drain, the owned executor retains this exact + /// candidate through preparation, durable effects and activation. The drain + /// deadline cannot expire completion authority; process shutdown still can. + pub(crate) fn retain_deployment_completion( + &self, + record: &Arc, + ) -> Result<(), ServingTransitionError> { + let mut state = locked(&self.state); + validate_closed(&state, record)?; + state.candidate.as_mut().unwrap().completion_owned = true; + Ok(()) + } + /// Reopen unchanged admission after the controller proves that no effect /// began. Deadline and drainage are deliberately not preconditions here: /// every older descendant remains in the successor's shared counter. @@ -752,9 +765,7 @@ impl GraphRegistry { if views.iter().any(|view| !view.contract_is_current()) { return Err(ServingTransitionError::SchemaChanged); } - if Instant::now() >= record.deadline { - return Err(ServingTransitionError::DeadlineElapsed); - } + validate_candidate(&state, record)?; state.snapshot = snapshot; state.candidate = None; Ok(epochs) @@ -800,7 +811,7 @@ fn validate_candidate( }) { return Err(ServingTransitionError::StaleAttempt); } - if Instant::now() >= record.deadline { + if !state.candidate.as_ref().unwrap().completion_owned && Instant::now() >= record.deadline { return Err(ServingTransitionError::DeadlineElapsed); } Ok(()) @@ -1860,6 +1871,87 @@ mod tests { assert_eq!(registry.list().len(), 1); } + #[tokio::test] + async fn drained_deployment_completion_outlives_deadline_but_not_process_closure() { + let dir = TempDir::new().unwrap(); + let alpha = build_handle("alpha", dir.path()).await; + let peer = build_handle("peer", dir.path()).await; + for outcome in ["activate", "shutdown", "abort"] { + let registry = Arc::new( + GraphRegistry::from_handles(vec![Arc::clone(&alpha), Arc::clone(&peer)]).unwrap(), + ); + let operations = OperationRuntime::new(); + let deadline = Instant::now() + std::time::Duration::from_secs(1); + let transition = registry + .prepare_deployment_transition( + &operations, + std::slice::from_ref(&alpha.key), + deadline, + ) + .unwrap() + .close() + .unwrap(); + transition.wait_requests().await.unwrap(); + transition.retain_deployment_completion().unwrap(); + let prior = alpha.engine.schema_contract_digest(); + if outcome != "abort" { + alpha + .engine + .apply_schema(&format!( + "// {outcome}\nnode Person {{ name: String @key nickname: String? }}\n" + )) + .await + .unwrap(); + assert_ne!(alpha.engine.schema_contract_digest(), prior); + } + tokio::time::sleep_until(deadline).await; + assert!( + matches!( + registry.prepare_same_view(&operations, &peer.key, transition_deadline()), + Err(ServingTransitionError::Busy) + ), + "drain expiry cannot supersede the completion owner" + ); + if outcome == "abort" { + transition.abort_before_effects().unwrap(); + assert!(matches!(registry.get(&alpha.key), RegistryLookup::Ready(_))); + assert_eq!(alpha.engine.schema_contract_digest(), prior); + continue; + } + let management = Arc::new(PolicyEngine::load_cluster_from_source( + "version: 1\ngroups:\n admins: [successor]\nrules:\n - id: admin\n allow:\n actors: {group: admins}\n actions: [config_manage]\n").unwrap()); + if outcome == "shutdown" { + operations.close(); + } + let result = transition.activate_deployment( + vec![activation_binding(&alpha, "nickname")], + Vec::new(), + Vec::new(), + Some(Arc::clone(&management)), + None, + ); + if outcome == "shutdown" { + assert_eq!(result, Err(ServingTransitionError::ProcessClosed)); + assert!(matches!( + registry.get(&alpha.key), + RegistryLookup::Transitioning(_) + )); + assert!(registry.snapshot_ref().server_policy.is_none()); + } else { + result.unwrap(); + let request = captured(®istry, &operations, &alpha.key); + assert_eq!( + request.schema_contract(), + &alpha.engine.schema_contract_digest() + ); + assert!(Arc::ptr_eq( + registry.snapshot_ref().server_policy.as_ref().unwrap(), + &management + )); + } + } + } + #[tokio::test] async fn activation_final_boundary_rechecks_contract_and_process_closure() { let dir = TempDir::new().unwrap(); diff --git a/crates/omnigraph-server/src/serving.rs b/crates/omnigraph-server/src/serving.rs index 8ae729c3c..d26e3de09 100644 --- a/crates/omnigraph-server/src/serving.rs +++ b/crates/omnigraph-server/src/serving.rs @@ -300,7 +300,8 @@ impl Drop for PreparedTransition { /// Closed predecessors. Dropping this ticket retires its scheduling record; /// the registry retains closed views and their resources until process shutdown. -/// Successful validated activation opens fresh epochs under the original deadline. +/// Standalone transitions keep their deadline. A drained deployment transfers +/// completion to its owned executor under the process shutdown boundary. pub struct GraphTransition { registry: Arc, record: Arc, @@ -339,6 +340,13 @@ impl GraphTransition { }) } + /// Transfer a drained deployment to its completion owner before entering + /// the shared executor. Pre-effect refusals still restore unchanged views. + pub(crate) fn retain_deployment_completion(&self) -> Result<(), ServingTransitionError> { + self.operations + .while_open(|| self.registry.retain_deployment_completion(&self.record)) + } + /// Publish graph lifecycle and authorization changes in one registry snapshot. pub(crate) fn activate_deployment( self, diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index 3621e7aea..1dcf698a8 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -2104,6 +2104,25 @@ mod owned_shutdown { /// listener survive schema/query replacement and graph addition. #[tokio::test(flavor = "multi_thread")] async fn live_deployment_retains_disconnected_owner_and_never_replays_original_id() { + live_deployment_fixture("journey").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn independent_live_deployment_activates_beside_a_blocked_graph() { + live_deployment_fixture("blocked_peer").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn deletion_of_missing_registry_entry_refuses_before_effects() { + live_deployment_fixture("missing_peer").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn live_query_deployment_closes_admission_before_waiting_for_a_merge() { + live_deployment_fixture("merge").await; +} + +async fn live_deployment_fixture(mode: &str) { use omnigraph_cluster::{CapturedDeployment, DeploymentStatus}; use omnigraph_server::{GraphId, GraphKey, RegistryLookup, ServerConfigMode}; use std::sync::Arc; @@ -2149,6 +2168,10 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i fs::write(temp.path().join("policy.yaml"), "version: 1\ngroups:\n admins: [operator]\n readers: [reader]\nrules:\n - id: admins\n allow:\n actors: { group: admins }\n actions: [schema_apply, read, invoke_query]\n - id: readers\n allow:\n actors: { group: readers }\n actions: [read]\n").unwrap(); fs::write(temp.path().join("cluster-policy.yaml"), "version: 1\ngroups:\n admins: [operator]\nrules:\n - id: manage\n allow:\n actors: { group: admins }\n actions: [config_manage]\n").unwrap(); fs::write(temp.path().join("cluster.yaml"), "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries:\n find_person:\n file: ./people.gq\n peer:\n schema: ./peer.pg\npolicies:\n access:\n file: ./policy.yaml\n applies_to: [knowledge, peer]\n management:\n file: ./cluster-policy.yaml\n applies_to: [cluster]\n").unwrap(); + if mode == "merge" { + let policy = fs::read_to_string(temp.path().join("policy.yaml")).unwrap(); + fs::write(temp.path().join("policy.yaml"), policy.replace("[schema_apply, read, invoke_query]", "[schema_apply, read, invoke_query, change, branch_create, branch_merge, branch_delete]")).unwrap(); + } apply_cluster_fixture(temp.path()).await; let mut settings = cluster_settings(temp.path()).await.unwrap(); settings @@ -2159,10 +2182,17 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i .await .unwrap(); let ServerConfigMode::Multi { - graphs, + mut graphs, config_path, server_policy, } = settings.mode; + if mode == "blocked_peer" { + fs::remove_dir_all(temp.path().join("graphs/peer.omni")).unwrap(); + } else if mode == "missing_peer" { + // An embedding supplied an incomplete registry: refuse deletion before + // touching the managed root rather than fail activation after purge. + graphs.retain(|graph| graph.graph_id != "peer"); + } let state = omnigraph_server::open_multi_graph_state( graphs, vec![ @@ -2205,6 +2235,141 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i assert_eq!(code, StatusCode::FORBIDDEN); let (initial_status, _) = status(&app, None).await; let id = initial_status.next_deployment_id(); + if mode == "merge" { + use omnigraph::seams::catalog::BRANCH_MERGE_POST_AUTHORITY_CAPTURE; + struct RequestHold { + thread: std::thread::ThreadId, + hold: Arc, + } + impl omnigraph::seams::Behavior for RequestHold { + fn uninstalling(&self) { + self.hold.release(); + } + } + impl omnigraph::seams::Decide for RequestHold { + fn decide(&self, name: &'static str) -> omnigraph::seams::Decision { + if std::thread::current().id() == self.thread { + omnigraph::seams::Decide::decide(self.hold.as_ref(), name) + } else { + omnigraph::seams::Decision::Pass + } + } + } + original_engine + .branch_create_as("feature", Some("operator")) + .await + .unwrap(); + omnigraph::Session::from_defaults(Arc::clone(&original_engine), Default::default()) + .mutate_as( + "feature", + "query add() { insert Person { name: \"Merged\" } }", + "add", + &Default::default(), + Some("operator"), + ) + .await + .unwrap(); + let merge_request = Request::post("/graphs/knowledge/branches/merge") + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .header("authorization", "Bearer operator-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"source":"feature","delete_branch":true}"#)) + .unwrap(); + let merger_app = app.clone(); + let (start, started) = std::sync::mpsc::channel(); + let merger = std::thread::spawn(move || { + started.recv().unwrap(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { json_response(&merger_app, merge_request).await }) + }); + let hold = Arc::new(omnigraph::seams::Hold::default()); + let guard = BRANCH_MERGE_POST_AUTHORITY_CAPTURE.install(Arc::new(RequestHold { + thread: merger.thread().id(), + hold: Arc::clone(&hold), + })); + start.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while !hold.reached() { + assert!( + !merger.is_finished(), + "merge must retain its shared schema gate" + ); + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + fs::write(temp.path().join("people.gq"), "// query-only deployment\nquery find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }\n").unwrap(); + let deployment = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); + let deploy_app = app.clone(); + let request = submit(&id, &deployment, "operator-token"); + let deployer = tokio::spawn(async move { json_response(&deploy_app, request).await }); + tokio::time::timeout(Duration::from_secs(5), async { + while !matches!( + state.routing().registry.get(&key), + RegistryLookup::Transitioning(_) + ) { + assert!( + !deployer.is_finished(), + "deployment must reach closure before the merge finishes" + ); + tokio::task::yield_now().await; + } + }) + .await + .expect("preview must not queue an exclusive graph open ahead of admission closure"); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + before + ); + assert!(!deployer.is_finished()); + assert_eq!( + json_response( + &app, + get_request("/graphs/knowledge/snapshot", "operator-token") + ) + .await + .0, + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!( + json_response(&app, get_request("/graphs/peer/snapshot", "operator-token")) + .await + .0, + StatusCode::OK + ); + hold.release(); + let (code, merge) = tokio::task::spawn_blocking(move || merger.join().unwrap()) + .await + .unwrap(); + assert_eq!(code, StatusCode::OK, "{merge}"); + assert!(!hold.timed_out()); + drop(guard); + let (code, applied) = deployer.await.unwrap(); + assert_eq!(code, StatusCode::OK, "{applied}"); + assert_eq!(applied["active"], true); + assert_eq!( + applied["deployment"]["result"]["graphs"]["knowledge"]["outcome"], + "query_only" + ); + assert_eq!(original_engine.schema_contract_digest(), original_contract); + let RegistryLookup::Ready(view) = state.routing().registry.get(&key) else { + panic!("ready"); + }; + assert!( + view.queries + .as_ref() + .unwrap() + .lookup("find_person") + .unwrap() + .source + .starts_with("// query-only deployment") + ); + return; + } fs::write( temp.path().join("people.pg"), "node Person { name: String @key bio: String? }\n", @@ -2219,6 +2384,56 @@ async fn live_deployment_retains_disconnected_owner_and_never_replays_original_i before ); + if mode == "blocked_peer" { + let (code, response) = + json_response(&app, submit(&id, &deployment, "operator-token")).await; + assert_eq!(code, StatusCode::OK, "{response}"); + assert_eq!(response["deployment"]["result"]["converged"], true); + assert!(matches!( + state.routing().registry.get(&key), + RegistryLookup::Ready(_) + )); + let peer_key = GraphKey::cluster(GraphId::try_from("peer").unwrap()); + assert!(matches!( + state.routing().registry.get(&peer_key), + RegistryLookup::Blocked(_) + )); + assert!(!state.operation_runtime().snapshot().closed); + assert_eq!( + response["active"], true, + "unrelated availability is not activation" + ); + assert!(status(&app, Some(&id)).await.1); + return; + } + if mode == "missing_peer" { + let config = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap(); + fs::write( + temp.path().join("cluster.yaml"), + config + .replace(" peer:\n schema: ./peer.pg\n", "") + .replace("[knowledge, peer]", "[knowledge]"), + ) + .unwrap(); + let removal = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); + let (code, response) = json_response(&app, submit(&id, &removal, "operator-token")).await; + assert_eq!(code, StatusCode::CONFLICT, "{response}"); + assert!( + response["error"] + .as_str() + .unwrap() + .contains("absent from the serving registry") + ); + assert!(temp.path().join("graphs/peer.omni").exists()); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + before + ); + assert!(!state.operation_runtime().snapshot().closed); + assert_eq!(original_engine.schema_contract_digest(), original_contract); + return; + } + // Runtime-only configuration is checked before creation or ledger acceptance. // A typo in a new graph's secret reference must not stop healthy service. let config = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap(); diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index 43045a46a..337e3dbe4 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -65,7 +65,9 @@ The same explicit `cluster upgrade-ledger --writers-stopped` path removes obsolete runtime fields from completed v2 receipts after validating their prior shape and consistency. It preserves ledger identity, deployment sequence, exact outcomes and applied resources with one conditional replacement. Normal decoding -stays strict; outstanding work must finish under its originating build. +uses that same validation to report `ledger_upgrade_required` for a recognized +prior receipt, never to accept converted state. Malformed or unknown state stays +invalid; outstanding work must finish under its originating build. Apply manages graphs, schemas, queries, policies and provider/Blob bindings. Runtime changes are validated before effects and activated with the achieved @@ -110,8 +112,11 @@ installed graph policy's `read` and `schema_apply` gates before effects or recov Accepted deletion cannot settle as refused or not attempted. An error leaves it outstanding; reconciliation resumes only that root under exclusive admission, including after its manifest is gone. Fresh deletion accepts an already-absent -root; a present root must match the applied contract. Recovery refuses a readable -foreign contract, while a partial root remains bound to its original intent. +root; a present root must match the applied contract before deletion starts. +After `Started`, recovery checks a readable manifest's graph lifetime rather than +its schema revision: partial removal can expose an older manifest of the same +graph. A replacement lifetime refuses, while an unreadable partial root remains +bound to its original intent. Completion requires verified root absence before a `Deleted` outcome removes the graph's applied resources and contract. A lost completion acknowledgement is reconciled by original ID. No deletion queue, side record or retained-owner pool @@ -126,6 +131,9 @@ engine genesis retains its existing actorless initialization contract. Later schema commits record the actor in their engine receipt. Foreign, committed or advanced roots are preserved and refused. The durable claim remains until cleanup completes; interrupted cleanup is resumable. +A later creation may reuse a bounded empty local directory tree left by cleanup, +with engine preparation still proving an empty target. Files, symlinks and cloud +markers refuse; this does not adopt existing graph contents. Terminal results advance only the achieved resources. Partial convergence is a valid base for a corrective successor under the same owner or after a qualified @@ -198,11 +206,14 @@ the graph permissions required by the shared executor. Graph-scoped data tokens cannot deploy. One process gate owns execution and activation after caller disconnect; no polling worker or second storage-writing CLI is involved. -The controller projects captured input through the existing serving validator, -resolves required provider secrets and validates new graph bindings before any -deployment effect. It reserves one batch transition, atomically closes affected graph -admission and drains request descendants, including held response bytes. Existing -engine handles remain owned through execution. A removal cannot delete storage +The controller validates captured input, current authorization and the serving +projection before closing admission, without opening affected graphs or acquiring +their schema gates. It resolves required provider secrets and validates runtime +bindings before effects. It reserves one batch transition, atomically closes affected graph +admission and drains request descendants, including held response bytes. Full +engine preparation then uses the retained live handles. The drain deadline does +not expire owned preparation, completion or activation; process shutdown remains +authoritative. A removal cannot delete storage before this boundary; a pre-effect refusal can resume the unchanged predecessor under a fresh epoch only when the existing abort proof permits it. It executes under the server's lifetime admission, loads the achieved bindings, @@ -216,14 +227,16 @@ Activation identity is installed in the same immutable registry snapshot as serving bindings: canonical root, process incarnation, original ID/input digest, achieved revision and config digest. The durable achieved receipt stays unchanged; there is no second ledger write after activation. `GET /cluster/deployments/{id}` -reports active only for that exact current identity while every graph is ready -under its installed schema contract and process admission remains open. Row -commits do not invalidate unchanged deployment bindings. Partial convergence, -blocked/loading/transitioning entries and shutdown report inactive. +reports active only for that exact current identity while its affected bindings +are ready under their installed schema contracts, deleted entries are absent, +and process admission remains open. An unrelated blocked graph does not invalidate +activation. Row commits do not invalidate unchanged deployment bindings. Partial convergence, an unavailable +affected binding and shutdown report inactive. A normal configured boot captures the latest matching converged receipt under -root admission and establishes fresh runtime evidence only as its verified graph -opens complete. Generic embedding constructors do not attest deployment bindings. +root admission and establishes fresh runtime evidence only as all configured graph +opens complete successfully. Generic embedding constructors do not attest +deployment bindings. Missing or expired receipt identity is not inferred from schema text or boot revision alone. Old-ID submission validates immutable input and returns the original record before graph closure; it cannot reinstall an old view. Boot diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 165fc7572..dfaa193ff 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -130,7 +130,8 @@ timeout without replaying a committed merge. Server suites are organized by public route: `auth_policy`, `data_routes`, `schema_routes`, `stored_queries`, `multi_graph`, `boot_settings`, object-store coverage in `s3`, and the generated contract in `openapi`. Per-graph serving transitions extend these owners: in-source `registry` tests -own capture/close ordering, deadlines, schema identity and candidate bounds; +own capture/close ordering, drain-only deployment deadlines, affected activation +scope, schema identity and candidate bounds; `operations`, `ingress` and `mcp` own detached execution and output lifetimes. `stored_queries` parks a request before engine capture, `data_routes` retains disconnected writes and stream bytes, and `boot_settings`/`mcp` check authorized @@ -155,7 +156,8 @@ exact applied schema identity after receipt eviction; `admission.rs` pins lifeti exclusion and exact reconciliation admission. Cluster `tests/failpoints.rs` owns interruption windows, killed-process recovery and corrective successors, including deletion before start, during partial removal, after root absence and before -terminal ledger acknowledgement; +terminal ledger acknowledgement, same-lifetime older manifest survivors, +replacement refusal and corrective creation in empty local settlement residue; `tests/identity_recovery.rs` owns current-actor authorization and adoption of a persisted settlement without replacing its author. CLI `tests/cli_cluster_e2e.rs` owns the root-only deployment round trip, and diff --git a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md index 517fbae6e..71c04b629 100644 --- a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md +++ b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md @@ -266,11 +266,14 @@ reachable path before E1 is qualified. Generic engine disposal/reuse still requires full B; retaining an engine alone proves neither contract. Activation rechecks attempt identity, predecessor epoch, content, ledger -reference, transition completion, budget ownership, stopping state and absolute -deadline under the final synchronous boundary. An old candidate cannot activate -against a later epoch. Check expiry even if its timer has not fired. Expired -candidates remain charged until disposal settles; successful candidates transfer -their reservations to serving ownership. +reference, transition completion, budget ownership and stopping state under the +final synchronous boundary. Standalone transitions retain their absolute +deadline. A deployment transfers the drained transition to its owned executor +before full preparation; the drain deadline cannot expire completion or +activation authority. Process shutdown still fences installation. An old +candidate cannot activate against a later epoch. Expired standalone candidates +remain charged until disposal settles; successful candidates transfer their +reservations to serving ownership. Safe resumption of the previous coherent view allocates a fresh epoch; a closed epoch never reopens, so an old callback cannot regain admission authority. @@ -475,7 +478,7 @@ stores every prepared intent/certificate and its reserved completion capacity in graph order; persist `Settled` with its own exact receipt or proved refusal before continuing. Uncertain effects stop later schema execution and keep the slot occupied. An uncertain original intent is never prepared again or replayed. -3. After a lost owner and prior-work quiescence, settle `NotStarted` graphs as +3. After a lost owner and prior-work quiescence, settle `NotStarted` schema intents as `not_attempted` without an engine invocation or fence. Reconcile unresolved `Started` intents against their protected exact candidate; record positive results without applying again and use the fence protocol below when needed. @@ -498,7 +501,10 @@ text at the same path never proves ownership. A partial initialization retains its exact claim. After prior-owner quiescence, settlement may remove only that claim's verified unpublished, empty birth artifacts; a committed, foreign or advanced graph is never reset. Interrupted settlement keeps the claim and is -repeatable. A later deployment can create a fresh graph after proved absence. +repeatable. A later deployment can create a fresh graph after proved absence, +including a bounded empty local directory tree left by settlement. Files, +symlinks and cloud markers refuse; engine preparation still proves the target +empty before minting new creation authority. A prepared schema intent uses **intent version 2**, binding canonical root, native main incarnation, numeric manifest base `M`, predecessor, exact accepted @@ -585,9 +591,11 @@ Graph-format conversion remains separately gated by the storage-upgrade decision The same explicit converter removes obsolete `activation` and `restart_required` fields from completed v2 receipts after validating their old shape. It preserves ledger identity, sequence and exact achieved outcomes and advances the ledger -CAS revision once. Ordinary readers remain strict; unknown fields, unsupported -receipt variants and outstanding work refuse conversion. Repeating a completed -conversion is a no-op. +CAS revision once. Ordinary readers use the same validation to recognize an old +completed receipt and return `ledger_upgrade_required` with the stopped-upgrade +command; they never consume converted state. Unknown fields, unsupported receipt +variants and outstanding work refuse conversion. Repeating a completed conversion +is a no-op. ### Deployment scope and preparation @@ -619,9 +627,12 @@ and admission; timeout does not prove a remote delete request has settled. Accepted deletion cannot become a not-attempted or refused terminal outcome. After supported owner handoff, reconciliation resumes the original ID and root even if the manifest is already -gone, verifies absence, and completes the result. A readable foreign contract -refuses recovery; an unreadable partial root remains owned by the original -intent under the stated quiescence boundary. Recovery does not create missing +gone, verifies absence, and completes the result. Before `Started`, a readable +root must still match the exact accepted contract. After `Started`, partial +removal may expose an older manifest; recovery accepts a readable contract from +the same graph lifetime and refuses a replacement identity. An unreadable partial +root remains owned by the original intent under the stated quiescence boundary. +Recovery does not create missing storage, adopt replacement contents or reinterpret changed configuration as a cancellation. A repeated completed-ID lookup never deletes again. @@ -665,15 +676,18 @@ validation to the v2 executor. One process gate owns the complete operation, including activation; competing POSTs refuse without queueing. The accepted future belongs to the server through completion even if its HTTP caller leaves. -Before any effect, validate captured input through the shared serving projection, -resolve required provider secrets and validate all changed runtime bindings. Close only -affected graph admissions in one batch. Drain their request descendants, then -execute prepared intents under the server's retained root admission, using -existing engines for retained graphs. Deletion keeps its predecessor owned until +Before closing admission, validate captured input, current authorization and the +shared serving projection without opening graphs or acquiring schema gates. +Resolve required provider secrets and validate changed runtime bindings before +effects. Close only affected graph admissions in one batch and drain their request +descendants under a bounded deadline. Then prepare and execute exact intents +under the server's retained root admission, using existing engines for retained +graphs. The owned executor retains preparation, completion and activation after +drain; the drain deadline does not cancel it. Deletion keeps its predecessor owned until the destructive operation settles; it never serves a partially deleted root. Unaffected graphs continue serving. Install exact achieved schema contracts, validated query registries, engine runtime bindings, management policy -and graph inventory in one registry snapshot under the shutdown/attempt/deadline +and graph inventory in one registry snapshot under the shutdown/attempt fence. Remove an inventory entry only for an exact durable `Deleted` outcome belonging to this closed transition; never retain it for re-adoption. Deterministic pre-effect @@ -685,9 +699,11 @@ installed bindings. It contains canonical root, admission incarnation, original ID/input digest, achieved revision and config digest. Keep the durable achieved receipt unchanged; there is no post-activation ledger CAS or mutable durable restart-needed flag. Status reports `active` only when this exact identity -matches the requested current receipt, every graph is ready under its installed -schema contract, and process admission remains open. Partial convergence, -loading, blocked or transitioning graphs and shutdown report inactive. Ordinary +matches the requested current receipt, its affected bindings are ready under +their installed schema contracts, deleted entries are absent, and process +admission remains open. Unrelated blocked graphs do not invalidate activation. +Partial convergence, unavailable +affected bindings and shutdown report inactive. Ordinary row commits do not invalidate unchanged deployment bindings. Boot revision/digest stays a boot fact. Original-ID resubmission is lookup-only before graph closure, including a @@ -839,7 +855,7 @@ Keep boot revision/digest as boot facts. Direct apply records achieved results without a runtime claim. Online activation installs exact identity in the registry snapshot alongside verified bindings. A normal configured boot captures the latest matching converged receipt under root admission, then establishes -fresh runtime evidence as its verified opens complete. Generic embedding +fresh runtime evidence when every configured graph opens successfully. Generic embedding constructors do not attest deployment bindings. Missing/expired receipt identity is not inferred from schema text or a successful apply. Status reads bounded snapshots outside blocked data lanes; it cannot open graphs, start completion or reconstruct history. Protect inventory @@ -1210,6 +1226,15 @@ Before enabling an affected increment, its owners must implement and qualify: ## Decision log +- 2026-10-06: Review corrections amend the serving-transition and Online + deployment sections: close and drain before full engine preparation, retain + owned completion beyond the drain deadline, and qualify activation against + affected bindings and deleted entries rather than unrelated graph health. + Deployment scope now distinguishes exact-contract deletion preparation from + same-lifetime recovery after `Started`; the graph-creation paragraph permits + verified empty local settlement residue. The CLI and conversion section now specifies + recognized old receipts without accepting them through normal reads. + - 2026-10-06: The maintainer requested physical graph deletion through ordinary `cluster apply`, with no deletion flag or unregister mode. This replaces the Scope and Deployment-scope sentences that refused graph removal, the blanket diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index d329d6953..bd101ac3d 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -94,7 +94,7 @@ storage paths refuse. The CLI reads only local source files and needs no local mount or storage credentials for the server’s root. The CLI prints the deployment ID before submission. The server keeps its PID, -listener and writer ownership. It briefly closes admission on affected graphs, +listener and writer ownership. It closes admission on affected graphs, finishes their admitted requests, publishes schema changes, and activates matching schemas, queries and runtime permissions together. Unaffected graphs keep serving. @@ -102,7 +102,8 @@ Graph additions and removals use the same deployment; see [deletion semantics](#deployment-boundaries) before removing a declaration. The response separates the durable deployment result from `active`, which means -that result is currently serving in this process. A successful response requires +that result's affected bindings are installed in this process. An unrelated +blocked graph does not invalidate that activation. A successful response requires both convergence and activation. Each graph publishes atomically; deployment across multiple graphs is not one transaction. Query-only changes create no graph commit and also work with multiple branches. Schema changes remain main-only @@ -113,7 +114,9 @@ change on existing graphs. Current permissions authorize the deployment; propose permissions cannot authorize themselves. Provider changes do not re-embed stored vectors. Roots, format and credential/trust configuration stay fixed. A refusal before effects restores unchanged serving views, including after a -drain timeout. There is one deployment protocol and no legacy execution fallback. +drain timeout. That timeout bounds draining admitted requests; once drained, +the server owns preparation, completion and activation through the existing +shutdown boundary. There is one deployment protocol and no legacy execution fallback. Upgrade the CLI, server and cluster tools together. Finish outstanding deployments with the build that accepted them before upgrading: captured inputs are exact @@ -144,6 +147,8 @@ For v2, conversion removes only those obsolete runtime fields, preserving the ledger identity, next deployment sequence and exact achieved receipts. Outstanding deployments and unsupported receipt shapes refuse; finish accepted work with its originating build before upgrading. +Normal reads report `ledger_upgrade_required` with the stopped-upgrade command +for a recognized prior receipt; malformed or unknown state remains an error. A completed read-only preflight refusal releases a newly acquired direct lock. Accepted work, cancellation and uncertain effects retain it for reconciliation. @@ -181,11 +186,12 @@ omnigraph --cluster file:///srv/company-brain \ ``` Reconciliation uses captured input and exact publication evidence; it never -replays an uncertain schema or graph-creation invocation. Work that never -started is recorded as not attempted. A partial graph birth can be abandoned -only when its exact unpublished, empty artifacts are proved to belong to that +replays an uncertain schema or graph-creation invocation. Unstarted schema or +graph-creation work can be recorded as not attempted. A partial graph birth can +be abandoned only when its exact unpublished, empty artifacts belong to that attempt. A foreign or committed graph is never reset. Unknown outcomes stay -outstanding and block new writes. +outstanding and block new writes. A later creation may reuse an empty local +directory tree left by that cleanup; files, symlinks and cloud markers still refuse. A settled partial result permits a corrective successor from achieved state; it does not roll back graphs that committed. Recovery retains a new admission From f259e582e9ecd7e35fe4168dd9905ee2af5fb9f6 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 6 Oct 2026 21:11:25 +0300 Subject: [PATCH 08/12] feat(cli): separate managed commands and observe served deployments --- changelog.d/explicit-managed-cli.breaking.md | 3 + .../served-deployment-observation.breaking.md | 3 + crates/omnigraph-cli/src/cli.rs | 219 ++++++---- crates/omnigraph-cli/src/cluster_remote.rs | 402 ++++++++++++++---- crates/omnigraph-cli/src/main.rs | 51 +-- crates/omnigraph-cli/src/managed.rs | 184 ++------ crates/omnigraph-cli/src/managed/data.rs | 6 +- .../omnigraph-cli/src/managed/data/tests.rs | 6 +- crates/omnigraph-cli/src/managed/lifecycle.rs | 41 +- .../src/managed/lifecycle/pending.rs | 2 +- crates/omnigraph-cli/src/managed/tests.rs | 54 ++- crates/omnigraph-cli/src/planes.rs | 16 +- crates/omnigraph-cli/tests/cli_cluster.rs | 331 ++++++++++++-- crates/omnigraph-cli/tests/cli_cluster_e2e.rs | 75 +++- crates/omnigraph-cli/tests/support/mod.rs | 2 +- crates/omnigraph-cluster/src/authorization.rs | 16 +- crates/omnigraph-cluster/src/deployment.rs | 11 +- .../src/deployment/execution.rs | 269 +++++++++++- .../omnigraph-cluster/src/deployment/tests.rs | 175 +++++++- crates/omnigraph-cluster/src/diff.rs | 110 ++--- crates/omnigraph-cluster/src/lib.rs | 48 +-- crates/omnigraph-cluster/src/types.rs | 7 + crates/omnigraph-cluster/tests/failpoints.rs | 19 +- crates/omnigraph-server/Cargo.toml | 2 +- crates/omnigraph-server/src/deployment.rs | 186 +++++++- crates/omnigraph-server/src/handlers.rs | 1 + crates/omnigraph-server/src/http_contract.rs | 7 +- crates/omnigraph-server/src/ingress.rs | 7 +- crates/omnigraph-server/src/lib.rs | 2 + .../omnigraph-server/tests/boot_settings.rs | 296 ++++++++++++- crates/omnigraph-server/tests/openapi.rs | 9 +- crates/omnigraph/src/db/omnigraph.rs | 33 +- .../src/db/omnigraph/schema_apply.rs | 19 + crates/omnigraph/tests/schema_apply.rs | 11 + docs/dev/control-plane.md | 17 + docs/dev/testing.md | 10 +- docs/rfcs/0052-managed-control-plane-cli.md | 57 +-- .../0053-offline-data-token-verification.md | 10 +- docs/rfcs/0061-managed-cluster-lifecycle.md | 27 +- ...identity-credentials-and-applied-policy.md | 12 +- ...29-server-runtime-and-online-deployment.md | 71 +++- docs/user/cli/index.md | 37 +- docs/user/cli/managed-data.md | 8 +- docs/user/cli/managed-lifecycle.md | 18 +- docs/user/cli/reference.md | 42 +- docs/user/clusters/index.md | 33 +- openapi.json | 262 +++++++++++- skills/omnigraph/SKILL.md | 30 +- skills/omnigraph/references/cluster.md | 99 +++-- skills/omnigraph/references/commands.md | 59 +-- 50 files changed, 2608 insertions(+), 807 deletions(-) create mode 100644 changelog.d/explicit-managed-cli.breaking.md create mode 100644 changelog.d/served-deployment-observation.breaking.md diff --git a/changelog.d/explicit-managed-cli.breaking.md b/changelog.d/explicit-managed-cli.breaking.md new file mode 100644 index 000000000..823e332bd --- /dev/null +++ b/changelog.d/explicit-managed-cli.breaking.md @@ -0,0 +1,3 @@ +- Managed service commands now use `omnigraph managed …`; self-hosted `cluster …` commands always ignore managed folder context. Use `managed operation ID` for service lifecycle observation and `managed status [RUN_ID]` for run status. The former managed `cluster` spellings are rejected; data-command context and credential behavior are unchanged. See the [CLI reference][explicit-managed-cli]. + +[explicit-managed-cli]: ../docs/user/cli/reference.md#managed-cluster-commands diff --git a/changelog.d/served-deployment-observation.breaking.md b/changelog.d/served-deployment-observation.breaking.md new file mode 100644 index 000000000..51d6081be --- /dev/null +++ b/changelog.d/served-deployment-observation.breaking.md @@ -0,0 +1,3 @@ +- Served cluster deployment now supports `cluster plan --server`, durable acceptance with `apply --no-wait`, and bounded waiting through `apply --timeout` or `status --deployment-id ID --wait`. Plans show graph-root deletion without closing serving admission. Caller timeout preserves the original ID and never cancels or replays server work. An authenticated initiator can observe its exact receipt after a management-policy handoff. Exact lookup now returns `{deployment, active, in_progress}`; aggregate status retains `{status, active, in_progress}`. Update API consumers with the server. See [cluster deployment][served-deployment-observation]. + +[served-deployment-observation]: ../docs/user/clusters/index.md diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 8ce9ad2d7..28713e0e2 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -20,9 +20,9 @@ served — require a server: graphs (registry scope).\n \ direct — direct storage access; reject --server (init, upgrade, optimize, rebuild-full-text-indexes, \ repair, cleanup, schema plan/apply, lint).\n \ control — manage or inspect a cluster (--config for bundles; explicit --cluster roots for \ -Core deployment/recovery; policy & queries via --cluster).\n \ +self-hosted deployment/recovery; policy & queries via --cluster).\n \ local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\ -MANAGED FOLDERS: cluster commands use .omnigraph/context; data commands acquire identity credentials automatically.\n\ +MANAGED FOLDERS: managed commands use .omnigraph/context; data commands acquire identity credentials automatically.\n\ Implicit query, mutate, load and commit list/show use folder context and require --graph.\n\ Explicit target selectors retain ordinary addressing; competing ambient targets refuse.\n\ --direct selects ordinary addressing, including operator profiles and defaults.\n\ @@ -71,14 +71,14 @@ pub(crate) struct Cli { #[arg(long, global = true, value_name = "URI")] pub(crate) store: Option, - /// Address a cluster. Core apply/status/force-unlock/upgrade-ledger accept + /// Address a cluster. Cluster apply/status/force-unlock/upgrade-ledger accept /// a literal file://, s3:// or az:// storage root independently of local /// bundle files. Root-addressed apply reconciles an exact --deployment-id. /// Maintenance also accepts a cluster directory or a `clusters:` name from /// ~/.omnigraph/config.yaml; pair it with --graph to select the graph. /// Azure writes still require the cluster-root external admission wrapper /// and remain a qualification preview. Exclusive with a positional URI, - /// --store or --server; Core root commands also reject explicit --config. + /// --store or --server; root-addressed cluster commands also reject explicit --config. #[arg(long, global = true, value_name = "DIR|URI")] pub(crate) cluster: Option, @@ -365,11 +365,16 @@ pub(crate) enum Command { }, // ── Control plane ── manage a cluster directory (--config ). - /// Manage cluster configuration or the folder's selected managed cluster. + /// Deploy and inspect a self-hosted cluster through its server or storage root. Cluster { #[command(subcommand)] command: ClusterCommand, }, + /// Manage the selected folder's cluster through its managed service API. + Managed { + #[command(subcommand)] + command: ManagedCommand, + }, /// Policy administration and diagnostics against a cluster's applied bundles Policy { @@ -516,13 +521,110 @@ pub(crate) enum BlobCommand { #[derive(Debug, Subcommand)] pub(crate) enum ClusterCommand { + /// Validate cluster.yaml and referenced schemas, queries, and policy files. + Validate { + /// Directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON. + #[arg(long)] + json: bool, + }, + /// Produce a read-only plan against applied state, locally or through --server. + Plan { + /// Directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON. + #[arg(long)] + json: bool, + }, + /// Apply a captured bundle. --server submits and waits for live activation; + /// direct storage apply requires stopped serving. With --cluster ROOT and + /// --deployment-id, reconcile the original deployment without source files. + Apply { + /// Directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON. + #[arg(long)] + json: bool, + /// Original durable deployment identity; never allocates a retry. + #[arg(long)] + deployment_id: Option, + /// Attest prior writers and accepted graph/control I/O are quiescent. + #[arg(long, requires = "deployment_id")] + writers_stopped: bool, + /// With --server, return after durable acceptance without waiting for completion. + #[arg(long)] + no_wait: bool, + /// With --server, bound the local wait (default 300s, maximum 3600s). + /// Expiry does not cancel accepted work. + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=3600))] + timeout: Option, + }, + /// Read deployment status without scanning graphs. --deployment-id selects + /// an exact durable receipt through --server or --cluster ROOT. + Status { + #[arg(long)] + deployment_id: Option, + /// With --server, wait for this deployment's outcome. + #[arg(long, requires = "deployment_id")] + wait: bool, + /// Bound the local wait (default 300s, maximum 3600s); never cancels work. + #[arg(long, requires = "wait", value_parser = clap::value_parser!(u64).range(1..=3600))] + timeout: Option, + /// Directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON. + #[arg(long)] + json: bool, + }, + /// Inspect declared graphs and catalog payloads without acquiring writer + /// admission. Reports observed state with the exact ledger CAS it read. + Observe { + /// Directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON. + #[arg(long)] + json: bool, + }, + /// Remove an exact persisted lock after stopping the owner and establishing + /// graph/control I/O quiescence, with admissions and other unlocks excluded. + ForceUnlock { + /// Exact lock id from cluster status or a state_lock_held diagnostic. + lock_id: String, + /// Directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON. + #[arg(long)] + json: bool, + }, + /// Convert the stopped cluster ledger without resetting graphs or history. + UpgradeLedger { + /// Attest prior writers and accepted graph/control I/O are quiescent. + #[arg(long, required = true)] + writers_stopped: bool, + /// Emit JSON. + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum ManagedCommand { /// Create an empty managed cluster and bind an unbound folder to its identity. Create { name: String, #[arg(long)] api: String, + /// Unbound configuration folder to bind after creation. #[arg(long, default_value = ".")] config: PathBuf, + /// Emit JSON instead of human text. #[arg(long)] json: bool, #[command(flatten)] @@ -534,8 +636,10 @@ pub(crate) enum ClusterCommand { expected_revision: String, #[arg(long)] message: String, + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, + /// Emit JSON instead of human text. #[arg(long)] json: bool, }, @@ -545,8 +649,10 @@ pub(crate) enum ClusterCommand { incarnation: String, #[arg(long, default_value_t = 86400, value_parser = clap::value_parser!(u32).range(0..=2592000))] retention_seconds: u32, + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, + /// Emit JSON instead of human text. #[arg(long)] json: bool, #[command(flatten)] @@ -558,8 +664,10 @@ pub(crate) enum ClusterCommand { incarnation: String, #[arg(long)] deletion_id: String, + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, + /// Emit JSON instead of human text. #[arg(long)] json: bool, #[command(flatten)] @@ -567,8 +675,10 @@ pub(crate) enum ClusterCommand { }, /// Cache an identity credential for this cluster, or forget it locally. Token { + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, + /// Emit JSON instead of human text. #[arg(long)] json: bool, /// Credential lifetime, 60 seconds to 24 hours (default 1h). @@ -578,74 +688,59 @@ pub(crate) enum ClusterCommand { #[arg(long)] clear: bool, }, - /// Validate cluster.yaml and referenced schemas, queries, and policy files. - Validate { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Produce a read-only plan by diffing cluster.yaml against __cluster/state.json. + /// Plan an exact pushed revision through the managed service. Plan { - /// Cluster config directory containing cluster.yaml. + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, /// Emit JSON instead of human text. #[arg(long)] json: bool, - /// Managed: select a pushed revision; omission uses the bound head. - #[arg(long = "rev", alias = "revision")] + /// Select a pushed revision; omission uses the bound head. + #[arg(long = "rev")] revision: Option, #[command(flatten)] managed: ManagedRunArgs, }, - /// Apply a captured bundle through the v2 deployment ledger. --server - /// updates the running server without restarting; direct storage apply - /// requires stopped serving. With --cluster ROOT and --deployment-id, - /// reconcile the original deployment without local source files. + /// Apply an exact saved plan run through the managed service. Apply { - /// Cluster config directory containing cluster.yaml. + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, /// Emit JSON instead of human text. #[arg(long)] json: bool, - /// Managed: apply this exact saved plan run. Required in managed mode. + /// Exact saved plan run to apply. #[arg(long)] - plan: Option, - /// Original durable Core deployment identity; never allocates a retry. - #[arg(long, conflicts_with = "plan")] - deployment_id: Option, - /// Attest prior writers and accepted graph/control I/O are quiescent. - #[arg(long, requires = "deployment_id")] - writers_stopped: bool, + plan: String, #[command(flatten)] managed: ManagedRunArgs, }, - /// Read the cluster ledger without scanning live graphs; --cluster ROOT - /// supports source-independent lookup of an exact --deployment-id. + /// Read managed cluster projections or inspect an exact run. Status { - /// Managed: inspect a run instead of the cluster projections. - #[arg(conflicts_with = "operation")] + /// Inspect this run instead of the cluster projections. run_id: Option, - /// Look up the exact durable Core deployment without opening graphs. - #[arg(long, conflicts_with_all = ["run_id", "operation", "api", "wait", "timeout"])] - deployment_id: Option, - /// Managed: inspect a service lifecycle operation instead of a run. + /// Configuration folder containing the managed context. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Inspect or wait for a managed service lifecycle operation. + Operation { + /// Exact service lifecycle operation. + operation_id: String, + /// API origin for recovery before a folder context exists. #[arg(long)] - operation: Option, - /// Managed operation recovery before a folder context exists. - #[arg(long, requires = "operation")] api: Option, - /// Poll a lifecycle operation to its canonical outcome. - #[arg(long, requires = "operation")] + /// Poll until the operation reaches its canonical outcome. + #[arg(long)] wait: bool, - /// Managed operation wait deadline (default 300, maximum 3600 seconds). + /// Local wait deadline in seconds (default 300, maximum 3600). #[arg(long, requires = "wait", value_parser = clap::value_parser!(u64).range(1..=3600))] timeout: Option, - /// Cluster config directory containing cluster.yaml. + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, /// Emit JSON instead of human text. @@ -654,8 +749,10 @@ pub(crate) enum ClusterCommand { }, /// Read managed run history with its provenance and outcomes. History { + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, + /// Emit JSON instead of human text. #[arg(long)] json: bool, #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))] @@ -667,41 +764,13 @@ pub(crate) enum ClusterCommand { /// Cancel a pending managed run, or abandon an unused saved plan. Cancel { run_id: String, - #[arg(long, default_value = ".")] - config: PathBuf, - #[arg(long)] - json: bool, - }, - /// Inspect declared graphs and catalog payloads without acquiring writer - /// admission. Reports observed state with the exact ledger CAS it read. - Observe { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Remove an exact persisted lock after stopping the owner and establishing - /// graph/control I/O quiescence, with admissions and other unlocks excluded. - ForceUnlock { - /// Exact lock id from cluster status or a state_lock_held diagnostic. - lock_id: String, - /// Cluster config directory containing cluster.yaml. + /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] config: PathBuf, /// Emit JSON instead of human text. #[arg(long)] json: bool, }, - /// Convert the stopped cluster ledger without resetting graphs or history. - UpgradeLedger { - /// Attest prior writers and accepted graph/control I/O are quiescent. - #[arg(long, required = true)] - writers_stopped: bool, - #[arg(long)] - json: bool, - }, } #[derive(Debug, Default, Args)] diff --git a/crates/omnigraph-cli/src/cluster_remote.rs b/crates/omnigraph-cli/src/cluster_remote.rs index 8723b030b..71274aafd 100644 --- a/crates/omnigraph-cli/src/cluster_remote.rs +++ b/crates/omnigraph-cli/src/cluster_remote.rs @@ -1,29 +1,44 @@ -//! Live cluster deployment over the versioned, non-retrying server protocol. +//! Live cluster deployment: submit once, then observe the original identity. use std::path::Path; use std::time::Duration; use color_eyre::eyre::{Result, bail}; -use omnigraph_cluster::{DeploymentLookup, DeploymentStatus}; -use reqwest::Method; +use omnigraph_cluster::{CapturedDeployment, DeploymentLookup, DeploymentStatus}; +use reqwest::{Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use tokio::time::Instant; use crate::cli::{Cli, ClusterCommand, Command}; -use crate::graph_http::GraphHttpClient; +use crate::graph_http::{ApiContractError, GraphHttpClient}; use crate::helpers::{ - apply_bearer_token, remote_response_json_bounded, remote_url, resolve_remote_bearer_token, - resolve_server_flag, + RemoteErrorCli, apply_bearer_token, remote_response_json_bounded, remote_url, + resolve_remote_bearer_token, resolve_server_flag, }; const RESPONSE_LIMIT: usize = 8 * 1024 * 1024; -const REQUEST_DEADLINE: Duration = Duration::from_secs(300); +const REQUEST_DEADLINE: Duration = Duration::from_secs(10); +const DEFAULT_WAIT: Duration = Duration::from_secs(300); +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// The caller's budget expired; it did not cancel the server's operation. +/// The response has already been emitted, so main supplies only exit status 5. +#[derive(Debug)] +pub(crate) struct WaitTimeout; +impl std::fmt::Display for WaitTimeout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("deployment observation timed out; inspect the original deployment ID") + } +} +impl std::error::Error for WaitTimeout {} #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct StatusResponse { status: DeploymentStatus, active: bool, + in_progress: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -31,6 +46,88 @@ struct StatusResponse { struct ApplyResponse { deployment: DeploymentLookup, active: bool, + in_progress: bool, +} + +impl ApplyResponse { + fn verify(&self, id: &str) -> Result<()> { + if matches!(&self.deployment, DeploymentLookup::Complete { result } if result.id != id) + || matches!(&self.deployment, DeploymentLookup::Outstanding { id: returned, .. } if returned != id) + || (self.active + && !matches!(&self.deployment, DeploymentLookup::Complete { result } if result.converged)) + { + bail!( + "invalid deployment receipt; inspect the original Deployment-ID; never replay unknown work" + ); + } + Ok(()) + } + + fn accepted(&self) -> bool { + matches!( + self.deployment, + DeploymentLookup::Outstanding { .. } | DeploymentLookup::Complete { .. } + ) + } + + fn verify_input(&self, expected: &str) -> Result<()> { + let recorded = match &self.deployment { + DeploymentLookup::Outstanding { input_digest, .. } => input_digest, + DeploymentLookup::Complete { result } => &result.input_digest, + _ => return Ok(()), + }; + if recorded != expected { + bail!( + "deployment receipt input digest differs from the submitted input; inspect the original Deployment-ID; never replay unknown work" + ); + } + Ok(()) + } + + fn pending(&self) -> bool { + self.in_progress + && !self.active + && matches!( + self.deployment, + DeploymentLookup::NotRecorded + | DeploymentLookup::Outstanding { .. } + | DeploymentLookup::Complete { .. } + ) + } + + fn finish(&self, acceptance_only: bool) -> Result<()> { + crate::print_json(self)?; + if self.active + || (acceptance_only + && self.in_progress + && (matches!(&self.deployment, DeploymentLookup::Outstanding { .. }) + || matches!(&self.deployment, DeploymentLookup::Complete { result } if result.converged))) + { + return Ok(()); + } + let message = match &self.deployment { + DeploymentLookup::Outstanding { .. } => { + "deployment requires recovery; observe and reconcile its original identity" + } + DeploymentLookup::Complete { result } if !result.converged => { + "deployment partially converged; inspect its achieved result before a corrective deployment" + } + DeploymentLookup::Complete { .. } => { + "deployment completed but is not active in this process; inspect the original receipt" + } + DeploymentLookup::NotRecorded => { + "deployment acceptance is not recorded; this does not authorize replay" + } + DeploymentLookup::ResultExpired { .. } => { + "deployment receipt expired; its outcome is unknown and must not be replayed" + } + DeploymentLookup::IdentityMismatch => { + "deployment identity differs from the recorded attempt" + } + DeploymentLookup::DifferentLedger => "deployment belongs to a different ledger", + }; + bail!("{message}") + } } struct RemoteCluster { @@ -56,34 +153,48 @@ impl RemoteCluster { method: Method, segments: &[&str], body: Option, + deadline: Instant, ) -> Result { let url = remote_url(&self.root, segments, &[])?; - let request = apply_bearer_token(self.client.request(method, url), self.token.as_deref()) - .timeout(REQUEST_DEADLINE); + let request = apply_bearer_token( + self.client.request(method.clone(), url), + self.token.as_deref(), + ); + // GETs are individually bounded. POST acceptance can wait for drain + // and preflight, but never beyond the caller's total observation budget. + let timeout = deadline.saturating_duration_since(Instant::now()); + let timeout = if method == Method::GET { + timeout.min(REQUEST_DEADLINE) + } else { + timeout + }; + let request = request.timeout(timeout); let request = match body { Some(body) => request.json(&body), None => request, }; - remote_response_json_bounded( - self.client.send(request).await?, - self.token.as_deref(), - Some(RESPONSE_LIMIT), - ) - .await + tokio::time::timeout_at(deadline, async { + remote_response_json_bounded( + self.client.send(request).await?, + self.token.as_deref(), + Some(RESPONSE_LIMIT), + ) + .await + }) + .await? } - async fn apply( + async fn capture( &self, config: &Path, - requested_id: Option<&str>, json_output: bool, - ) -> Result<()> { + deadline: Instant, + ) -> Result<(StatusResponse, CapturedDeployment)> { let status: StatusResponse = self - .request(Method::GET, &["cluster", "deployments"], None) + .request(Method::GET, &["cluster", "deployments"], None, deadline) .await?; - // Only source files belong to the caller's filesystem. Bind their - // captured bytes to the authenticated server's root without opening - // that storage root or resolving it on the client. + // Source files belong to the caller. The authenticated server owns + // storage resolution; the CLI never opens its storage root. let deployment = crate::core_deployment_result( omnigraph_cluster::capture_deployment_for_server(config, &status.status.canonical_root), json_output, @@ -93,6 +204,93 @@ impl RemoteCluster { "cluster config addresses a different storage root from the selected server; no deployment was sent" ); } + Ok((status, deployment)) + } + + async fn plan(&self, config: &Path, json_output: bool) -> Result<()> { + let deadline = Instant::now() + DEFAULT_WAIT; + let (_, deployment) = self.capture(config, json_output, deadline).await?; + let plan: Value = self + .request( + Method::POST, + &["cluster", "plan"], + Some(json!({"deployment": deployment})), + deadline, + ) + .await?; + crate::print_json(&plan)?; + if plan.get("ok").and_then(Value::as_bool) != Some(true) { + bail!("deployment plan refused; inspect its diagnostics"); + } + Ok(()) + } + + async fn observe(&self, id: &str, deadline: Instant) -> Result { + let response: ApplyResponse = self + .request(Method::GET, &["cluster", "deployments", id], None, deadline) + .await?; + response.verify(id)?; + Ok(response) + } + + async fn wait( + &self, + id: &str, + mut last: Option, + deadline: Instant, + acceptance_only: bool, + expected_input: Option<&str>, + ) -> Result<()> { + loop { + if let Some(response) = &last { + if let Some(expected) = expected_input { + response.verify_input(expected)?; + } + if (acceptance_only && response.accepted()) || !response.pending() { + return response.finish(acceptance_only); + } + } + if Instant::now() >= deadline { + return wait_timeout(Some(id), last.as_ref()); + } + tokio::time::sleep_until((Instant::now() + POLL_INTERVAL).min(deadline)).await; + if Instant::now() >= deadline { + return wait_timeout(Some(id), last.as_ref()); + } + match self.observe(id, deadline).await { + Ok(response) => last = Some(response), + Err(error) if retry_observation(&error) => { + if Instant::now() >= deadline { + return wait_timeout(Some(id), last.as_ref()); + } + } + Err(error) => { + // Preserve the caller's recovery identity even if access + // or the protocol changes while observing the operation. + eprintln!( + "Deployment-ID: {id}; observation failed; never resubmit unknown work" + ); + return Err(error); + } + } + } + } + + async fn apply( + &self, + config: &Path, + requested_id: Option<&str>, + json_output: bool, + no_wait: bool, + timeout: Option, + ) -> Result<()> { + let deadline = Instant::now() + timeout.map(Duration::from_secs).unwrap_or(DEFAULT_WAIT); + let (status, deployment) = match self.capture(config, json_output, deadline).await { + Ok(captured) => captured, + Err(error) if is_wait_timeout(&error, deadline) => return wait_timeout(None, None), + Err(error) => return Err(error), + }; + let input_digest = crate::core_deployment_result(deployment.input_digest(), json_output)?; if requested_id.is_none() && let Some(id) = &status.status.outstanding_id { @@ -106,44 +304,79 @@ impl RemoteCluster { if id.is_empty() || id.len() > 75 || id.chars().any(char::is_control) { bail!("invalid deployment identity; no deployment was sent"); } - // The original ID is recoverable even if the caller disappears while - // the server-owned operation continues or its receipt is lost. eprintln!("Deployment-ID: {id}"); - let result: ApplyResponse = match self - .request( + // Exactly one submission. A lost response only permits GET observation. + let response = self + .request::( Method::POST, &["cluster", "deployments"], Some(json!({"deployment_id": id, "deployment": deployment})), + deadline, ) - .await - { - Ok(result) => result, + .await; + match response { + Ok(response) => { + response.verify(&id)?; + self.wait(&id, Some(response), deadline, no_wait, Some(&input_digest)) + .await + } + Err(error) if is_wait_timeout(&error, deadline) => wait_timeout(Some(&id), None), + Err(error) if retry_observation(&error) => { + self.wait(&id, None, deadline, no_wait, Some(&input_digest)) + .await + } Err(error) => { eprintln!( - "Inspect cluster status --server --deployment-id {id}; a lost response does not authorize a new deployment or replay." + "Inspect cluster status --server --deployment-id {id}; a lost response does not authorize replay." ); - return Err(error); + Err(error) } - }; - if matches!(&result.deployment, DeploymentLookup::Complete { result } if result.id != id) - || matches!(&result.deployment, DeploymentLookup::Outstanding { id: returned, .. } if returned != &id) - { - bail!( - "server returned a different deployment identity; effects are unknown; inspect the original Deployment-ID" - ); } - crate::print_json(&result)?; - if !result.active - || !matches!(&result.deployment, DeploymentLookup::Complete { result } if result.converged) - { - bail!( - "deployment is not fully active; inspect the original identity, never replay unknown work" - ); - } - Ok(()) } } +fn is_wait_timeout(error: &color_eyre::Report, deadline: Instant) -> bool { + error.is::() || Instant::now() >= deadline +} + +fn retry_observation(error: &color_eyre::Report) -> bool { + error.is::() + || error.downcast_ref::().is_some_and(|error| { + error.is_timeout() || error.is_connect() || error.is_body() || error.is_request() + }) + || error.downcast_ref::().is_some_and(|error| { + matches!( + error.status, + StatusCode::TOO_MANY_REQUESTS + | StatusCode::BAD_GATEWAY + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::GATEWAY_TIMEOUT + ) + }) + || error + .downcast_ref::() + .is_some_and(|error| { + !error.request_dispatched + && (error.http_status.is_none() || matches!(error.http_status, Some(502..=504))) + }) +} + +fn wait_timeout(id: Option<&str>, last: Option<&ApplyResponse>) -> Result<()> { + crate::print_json(&json!({ + "deployment_id": id, + "outcome": "wait_timeout", + "last_observation": last, + }))?; + if id.is_some() { + eprintln!( + "Caller wait deadline reached; server work was not cancelled. Observe the original Deployment-ID before taking further action." + ); + } else { + eprintln!("Caller wait deadline reached before submission; no deployment was sent."); + } + Err(WaitTimeout.into()) +} + pub(crate) async fn dispatch(cli: &Cli) -> Result { let Some(server) = cli.server.as_deref() else { return Ok(false); @@ -153,7 +386,7 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { }; if !matches!( command, - ClusterCommand::Apply { .. } | ClusterCommand::Status { .. } + ClusterCommand::Plan { .. } | ClusterCommand::Apply { .. } | ClusterCommand::Status { .. } ) { return Ok(false); } @@ -166,62 +399,55 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { "--as cannot be used with --server; the server resolves the actor from the bearer token" ); } - match command { + if matches!( + command, ClusterCommand::Apply { - plan, - managed, - writers_stopped, - .. - } => { - if *writers_stopped { - bail!("--writers-stopped cannot be used with --server"); - } - if plan.is_some() - || managed.no_wait - || managed.timeout.is_some() - || managed.idempotency_key.is_some() - { - bail!("live Core apply does not accept managed run arguments"); - } - } - ClusterCommand::Status { - run_id, - operation, - api, - wait, - timeout, + writers_stopped: true, .. - } => { - if run_id.is_some() - || operation.is_some() - || api.is_some() - || *wait - || timeout.is_some() - { - bail!("live Core status does not accept managed run/operation arguments"); - } } - _ => unreachable!(), + ) { + bail!("--writers-stopped cannot be used with --server"); } let remote = RemoteCluster::new(server)?; match command { + ClusterCommand::Plan { config, json } => remote.plan(config, *json).await?, ClusterCommand::Apply { config, deployment_id, json, + no_wait, + timeout, .. } => { remote - .apply(config, deployment_id.as_deref(), *json) - .await?; + .apply(config, deployment_id.as_deref(), *json, *no_wait, *timeout) + .await? } - ClusterCommand::Status { deployment_id, .. } => { - let mut segments = vec!["cluster", "deployments"]; + ClusterCommand::Status { + deployment_id, + wait, + timeout, + .. + } => { + let deadline = + Instant::now() + timeout.map(Duration::from_secs).unwrap_or(DEFAULT_WAIT); if let Some(id) = deployment_id { - segments.push(id); + let response = match remote.observe(id, deadline).await { + Ok(response) => Some(response), + Err(error) if *wait && retry_observation(&error) => None, + Err(error) => return Err(error), + }; + if *wait { + remote.wait(id, response, deadline, false, None).await?; + } else { + crate::print_json(&response.unwrap())?; + } + } else { + let status: StatusResponse = remote + .request(Method::GET, &["cluster", "deployments"], None, deadline) + .await?; + crate::print_json(&status)?; } - let status: StatusResponse = remote.request(Method::GET, &segments, None).await?; - crate::print_json(&status)?; } _ => unreachable!(), } diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 1896a1e2d..92b418676 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -194,6 +194,13 @@ async fn main() -> Result<()> { }; match result { Err(error) => { + if error + .downcast_ref::() + .is_some() + { + std::io::stdout().flush()?; + std::process::exit(5); + } if let Some(evidence) = evidence { let failure = command_outcome::Failure::classify(error, evidence); match machine { @@ -256,6 +263,22 @@ fn validate_core_root_arguments( let Command::Cluster { command } = &cli.command else { return Ok(()); }; + if cli.server.is_none() + && matches!( + command, + ClusterCommand::Apply { no_wait: true, .. } + | ClusterCommand::Apply { + timeout: Some(_), + .. + } + | ClusterCommand::Status { wait: true, .. } + ) + { + return Err(Cli::command().error( + clap::error::ErrorKind::ArgumentConflict, + "deployment wait options require --server ", + )); + } if cli.cluster.is_some() && command_matches .try_get_one::("config") @@ -286,7 +309,7 @@ fn validate_core_root_arguments( { return Err(Cli::command().error( clap::error::ErrorKind::MissingRequiredArgument, - "this Core deployment operation requires --cluster ", + "this deployment operation requires --cluster ", )); } Ok(()) @@ -1929,23 +1952,9 @@ async fn run(cli: Cli) -> Result<()> { config, json, deployment_id, - run_id, - operation, - api, - wait, - timeout, + .. } => { if let Some(root) = cli.cluster.as_deref() { - if run_id.is_some() - || operation.is_some() - || api.is_some() - || wait - || timeout.is_some() - { - bail!( - "root-addressed Core status does not accept managed run/operation flags" - ); - } let caller = omnigraph_cluster::DeploymentCaller::storage_owner(None); let status = core_deployment_result( omnigraph_cluster::deployment_status( @@ -1978,16 +1987,8 @@ async fn run(cli: Cli) -> Result<()> { finish_cluster_force_unlock(&output, json)?; } } - ClusterCommand::History { .. } - | ClusterCommand::Cancel { .. } - | ClusterCommand::Token { .. } - | ClusterCommand::Create { .. } - | ClusterCommand::Delete { .. } - | ClusterCommand::UndoDelete { .. } - | ClusterCommand::Push { .. } => { - unreachable!("managed dispatch refuses managed-only verbs without context") - } }, + Command::Managed { .. } => unreachable!("managed commands dispatch before Core"), Command::Graphs { command } => match command { GraphsCommand::List { json, discovery } => { let (client, discovery) = if let Some(client) = managed_data { diff --git a/crates/omnigraph-cli/src/managed.rs b/crates/omnigraph-cli/src/managed.rs index 46aca0ac6..df7610a53 100644 --- a/crates/omnigraph-cli/src/managed.rs +++ b/crates/omnigraph-cli/src/managed.rs @@ -1,7 +1,7 @@ //! Managed control routing (RFC 0052) and offline data credentials (RFC 0053). //! Context selects authority, never caches a ledger; errors never fall back to Core. -use crate::cli::{Cli, ClusterCommand, Command, ManagedRunArgs}; +use crate::cli::{Cli, Command, ManagedCommand, ManagedRunArgs}; use reqwest::{Client, Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -492,41 +492,19 @@ fn idempotency_key(value: Option<&str>) -> Result { Ok(key) } -fn managed_flags(command: &ClusterCommand) -> bool { - let opts = |o: &ManagedRunArgs| o.no_wait || o.timeout.is_some() || o.idempotency_key.is_some(); +fn config_and_json(command: &ManagedCommand) -> (&Path, bool) { match command { - ClusterCommand::Plan { - revision, managed, .. - } => revision.is_some() || opts(managed), - ClusterCommand::Apply { plan, managed, .. } => plan.is_some() || opts(managed), - ClusterCommand::Status { run_id, .. } => run_id.is_some(), - ClusterCommand::History { .. } - | ClusterCommand::Cancel { .. } - | ClusterCommand::Token { .. } - | ClusterCommand::Create { .. } - | ClusterCommand::Delete { .. } - | ClusterCommand::UndoDelete { .. } - | ClusterCommand::Push { .. } => true, - _ => false, - } -} - -fn config_and_json(command: &ClusterCommand) -> (&Path, bool) { - match command { - ClusterCommand::UpgradeLedger { json, .. } => (Path::new("."), *json), - ClusterCommand::Validate { config, json } - | ClusterCommand::Plan { config, json, .. } - | ClusterCommand::Apply { config, json, .. } - | ClusterCommand::Status { config, json, .. } - | ClusterCommand::Observe { config, json } - | ClusterCommand::ForceUnlock { config, json, .. } - | ClusterCommand::History { config, json, .. } - | ClusterCommand::Cancel { config, json, .. } - | ClusterCommand::Token { config, json, .. } - | ClusterCommand::Create { config, json, .. } - | ClusterCommand::Delete { config, json, .. } - | ClusterCommand::UndoDelete { config, json, .. } - | ClusterCommand::Push { config, json, .. } => (config, *json), + ManagedCommand::Plan { config, json, .. } + | ManagedCommand::Apply { config, json, .. } + | ManagedCommand::Status { config, json, .. } + | ManagedCommand::Operation { config, json, .. } + | ManagedCommand::History { config, json, .. } + | ManagedCommand::Cancel { config, json, .. } + | ManagedCommand::Token { config, json, .. } + | ManagedCommand::Create { config, json, .. } + | ManagedCommand::Delete { config, json, .. } + | ManagedCommand::UndoDelete { config, json, .. } + | ManagedCommand::Push { config, json, .. } => (config, *json), } } @@ -549,66 +527,21 @@ fn reject_scope(cli: &Cli) -> Result<()> { async fn cluster_command( cli: &Cli, context: &Context, - command: &ClusterCommand, + command: &ManagedCommand, ) -> Result<(Value, i32)> { - if let ClusterCommand::Token { ttl, clear, .. } = command { + if let ManagedCommand::Token { ttl, clear, .. } = command { return data::token(cli, context, *ttl, *clear) .await .map(|body| (body, 0)); } reject_scope(cli)?; - if matches!( - command, - ClusterCommand::Apply { - deployment_id: Some(_), - .. - } | ClusterCommand::Apply { - writers_stopped: true, - .. - } | ClusterCommand::Status { - deployment_id: Some(_), - .. - } - ) { - return Err(Failure::refused( - "managed_command_unsupported", - "Core deployment flags do not apply to managed API runs", - )); - } - // Reject unsupported verbs and invalid requests before accessing credentials. - match command { - ClusterCommand::Apply { plan: None, .. } => { - return Err(Failure::refused( - "plan_required", - "managed apply requires --plan with the exact saved plan run id", - )); - } - ClusterCommand::Plan { .. } - | ClusterCommand::Apply { .. } - | ClusterCommand::Status { .. } - | ClusterCommand::History { .. } - | ClusterCommand::Cancel { .. } => {} - _ => { - return Err(Failure::refused( - "managed_command_unsupported", - "this cluster command is not supported by the managed API; use --direct only when direct Core access is intended", - )); - } - } let api = Api::authenticated(context.api.clone())?; let base = format!("/v1/clusters/{}", context.cluster); match command { - ClusterCommand::Plan { - revision, managed, .. - } - | ClusterCommand::Apply { - plan: revision, - managed, - .. - } => { + ManagedCommand::Plan { managed, .. } | ManagedCommand::Apply { managed, .. } => { let deadline = Instant::now() + Duration::from_secs(managed.timeout.unwrap_or(300)); let body = match command { - ClusterCommand::Plan { .. } => { + ManagedCommand::Plan { revision, .. } => { if revision.as_ref().is_some_and(|r| { r.is_empty() || r.len() > 1024 || r.chars().any(char::is_control) }) { @@ -622,11 +555,11 @@ async fn cluster_command( None => json!({"kind":"plan"}), } } - _ => { - let plan = revision.as_deref().ok_or_else(Failure::protocol)?; + ManagedCommand::Apply { plan, .. } => { identifier(plan)?; json!({"kind":"apply","plan_run":plan}) } + _ => unreachable!(), }; let key = idempotency_key(managed.idempotency_key.as_deref())?; let path = format!("{base}/runs"); @@ -640,7 +573,7 @@ async fn cluster_command( }; wait_run(&api, context, response, managed, deadline).await } - ClusterCommand::Status { run_id, .. } => { + ManagedCommand::Status { run_id, .. } => { let body = if let Some(id) = run_id { identifier(id)?; let body = api @@ -657,7 +590,7 @@ async fn cluster_command( }; Ok((body, 0)) } - ClusterCommand::History { limit, since, .. } => { + ManagedCommand::History { limit, since, .. } => { let mut url = Url::parse(&format!("{}{base}/history", context.api)) .map_err(|_| Failure::protocol())?; url.query_pairs_mut() @@ -675,7 +608,7 @@ async fn cluster_command( cluster_matches(&body, &context.cluster)?; Ok((body, 0)) } - ClusterCommand::Cancel { run_id, .. } => { + ManagedCommand::Cancel { run_id, .. } => { identifier(run_id)?; let before = api .request(Method::GET, &format!("/v1/runs/{run_id}"), None, None) @@ -699,11 +632,11 @@ async fn cluster_command( let exit = run_exit(&body)?.ok_or_else(Failure::protocol)?; Ok((body, exit)) } - _ => unreachable!("unsupported commands refused above"), + _ => unreachable!("lifecycle and token commands dispatch separately"), } } -/// None is returned only for explicitly direct or context-free legacy routes. +/// Only explicit managed commands and managed authentication enter this dispatcher. pub(crate) async fn dispatch(cli: &Cli) -> Option { match &cli.command { Command::Login { @@ -765,61 +698,38 @@ pub(crate) async fn dispatch(cli: &Cli) -> Option { .await; Some(Output::from_result(result, *json, 0)) } - Command::Cluster { command } => { - // Explicit root-addressed Core operations never discover or load - // mutable folder context, including managed credentials. - if cli.cluster.is_some() - && matches!( - command, - ClusterCommand::Apply { .. } - | ClusterCommand::Status { .. } - | ClusterCommand::ForceUnlock { .. } - | ClusterCommand::UpgradeLedger { .. } - ) - { - if managed_flags(command) { - return Some(Output::from_result( - Err(Failure::refused( - "managed_scope_conflict", - "root-addressed Core operations do not accept managed run arguments", - )), - config_and_json(command).1, - 2, - )); - } - return None; - } + Command::Managed { command } => { let (config, json) = config_and_json(command); + if cli.direct { + return Some(Output::from_result( + Err(Failure::refused( + "managed_scope_conflict", + "managed commands cannot be used with --direct; use cluster for self-hosted deployment", + )), + json, + 2, + )); + } if lifecycle::handles(command) { return Some(match lifecycle::dispatch(cli, command).await { Ok((body, exit)) => Output::from_result(Ok(body), json, exit), Err(err) => Output::from_result(Err(err), json, 1), }); } - let context = if cli.direct { - Ok(None) - } else { - read_context(config) - }; - match context { - Err(err) => Some(Output::from_result(Err(err), json, 2)), - Ok(None) if managed_flags(command) => Some(Output::from_result( - Err(Failure::refused( + let result = async { + let context = read_context(config)?.ok_or_else(|| { + Failure::refused( "managed_context_required", - "managed-only arguments require .omnigraph/context and cannot be used with --direct", - )), - json, - 2, - )), - Ok(None) => None, - Ok(Some(context)) => { - let result = cluster_command(cli, &context, command).await; - Some(match result { - Ok((body, exit)) => Output::from_result(Ok(body), json, exit), - Err(err) => Output::from_result(Err(err), json, 1), - }) - } + "managed commands require the selected folder's .omnigraph/context", + ) + })?; + cluster_command(cli, &context, command).await } + .await; + Some(match result { + Ok((body, exit)) => Output::from_result(Ok(body), json, exit), + Err(err) => Output::from_result(Err(err), json, 1), + }) } _ => None, } diff --git a/crates/omnigraph-cli/src/managed/data.rs b/crates/omnigraph-cli/src/managed/data.rs index 1014a2b4b..0798d6014 100644 --- a/crates/omnigraph-cli/src/managed/data.rs +++ b/crates/omnigraph-cli/src/managed/data.rs @@ -53,7 +53,7 @@ fn key(context: &Context) -> String { fn invalid() -> Failure { Failure::refused( "data_credential_invalid", - "the cached data credential is invalid; mint a new cluster token", + "the cached data credential is invalid; mint a new managed token", ) } @@ -135,7 +135,7 @@ impl Credential { if expires <= now { return Err(Failure::refused( "data_credential_expired", - "the data credential has expired; mint a new cluster token", + "the data credential has expired; mint a new managed token", )); } Ok(()) @@ -290,7 +290,7 @@ fn load_credential(store: &impl Store, context: &Context) -> Result let raw = store.get(&key(context))?.ok_or_else(|| { Failure::refused( "data_credential_required", - "no data credential is cached for this cluster; run cluster token", + "no data credential is cached for this cluster; run managed token", ) })?; if raw.len() > MAX_CREDENTIAL { diff --git a/crates/omnigraph-cli/src/managed/data/tests.rs b/crates/omnigraph-cli/src/managed/data/tests.rs index 0e01e2c81..74a60c194 100644 --- a/crates/omnigraph-cli/src/managed/data/tests.rs +++ b/crates/omnigraph-cli/src/managed/data/tests.rs @@ -322,9 +322,9 @@ fn token_arguments_bound_lifetime_and_refuse_removed_actions() { ] { assert!(graph_id(bad).is_err()); } - assert!(Cli::try_parse_from(["omnigraph", "cluster", "token", "--clear"]).is_ok()); - assert!(Cli::try_parse_from(["omnigraph", "cluster", "token"]).is_ok()); - assert!(Cli::try_parse_from(["omnigraph", "cluster", "token", "--actions", "read"]).is_err()); + assert!(Cli::try_parse_from(["omnigraph", "managed", "token", "--clear"]).is_ok()); + assert!(Cli::try_parse_from(["omnigraph", "managed", "token"]).is_ok()); + assert!(Cli::try_parse_from(["omnigraph", "managed", "token", "--actions", "read"]).is_err()); assert!( Cli::try_parse_from(["omnigraph", "cluster", "status", "--direct"]) .unwrap() diff --git a/crates/omnigraph-cli/src/managed/lifecycle.rs b/crates/omnigraph-cli/src/managed/lifecycle.rs index 53b555dd3..2e876895b 100644 --- a/crates/omnigraph-cli/src/managed/lifecycle.rs +++ b/crates/omnigraph-cli/src/managed/lifecycle.rs @@ -4,17 +4,14 @@ use super::*; mod capture; mod pending; -pub(super) fn handles(command: &ClusterCommand) -> bool { +pub(super) fn handles(command: &ManagedCommand) -> bool { matches!( command, - ClusterCommand::Create { .. } - | ClusterCommand::Delete { .. } - | ClusterCommand::UndoDelete { .. } - | ClusterCommand::Push { .. } - | ClusterCommand::Status { - operation: Some(_), - .. - } + ManagedCommand::Create { .. } + | ManagedCommand::Delete { .. } + | ManagedCommand::UndoDelete { .. } + | ManagedCommand::Push { .. } + | ManagedCommand::Operation { .. } ) } @@ -31,17 +28,11 @@ fn context_required(config: &Path) -> Result { }) } -pub(super) async fn dispatch(cli: &Cli, command: &ClusterCommand) -> Result<(Value, i32)> { +pub(super) async fn dispatch(cli: &Cli, command: &ManagedCommand) -> Result<(Value, i32)> { reject_scope(cli)?; - if cli.direct { - return Err(Failure::refused( - "managed_context_required", - "managed lifecycle and upload commands cannot be used with --direct", - )); - } let (config, _) = config_and_json(command); match command { - ClusterCommand::Create { + ManagedCommand::Create { name, api, managed, .. } => { if name.is_empty() @@ -71,12 +62,12 @@ pub(super) async fn dispatch(cli: &Cli, command: &ClusterCommand) -> Result<(Val ) .await } - ClusterCommand::Delete { + ManagedCommand::Delete { incarnation, managed, .. } - | ClusterCommand::UndoDelete { + | ManagedCommand::UndoDelete { incarnation, managed, .. @@ -84,14 +75,14 @@ pub(super) async fn dispatch(cli: &Cli, command: &ClusterCommand) -> Result<(Val identifier(incarnation)?; let context = context_required(config)?; let (kind, body, tombstone) = match command { - ClusterCommand::Delete { + ManagedCommand::Delete { retention_seconds, .. } => ( "delete", json!({"incarnation":incarnation,"retention_seconds":retention_seconds}), *retention_seconds > 0, ), - ClusterCommand::UndoDelete { deletion_id, .. } => { + ManagedCommand::UndoDelete { deletion_id, .. } => { identifier(deletion_id)?; ( "undo", @@ -117,8 +108,8 @@ pub(super) async fn dispatch(cli: &Cli, command: &ClusterCommand) -> Result<(Val ) .await } - ClusterCommand::Status { - operation: Some(id), + ManagedCommand::Operation { + operation_id: id, api, wait, timeout, @@ -173,7 +164,7 @@ pub(super) async fn dispatch(cli: &Cli, command: &ClusterCommand) -> Result<(Val identity.matches(context.as_ref(), None, Some(id))?; wait_operation(&api, body, &identity, &options, deadline, false).await } - ClusterCommand::Push { + ManagedCommand::Push { expected_revision, message, .. @@ -497,7 +488,7 @@ async fn wait_operation( if next >= deadline { tokio::time::sleep_until(deadline).await; eprintln!( - "wait deadline reached; operation {} continues; use cluster status --operation {}", + "wait deadline reached; operation {} continues; use managed operation {}", identity.operation_id, identity.operation_id ); return Ok((body, 5)); diff --git a/crates/omnigraph-cli/src/managed/lifecycle/pending.rs b/crates/omnigraph-cli/src/managed/lifecycle/pending.rs index 914d20cb9..95e485cee 100644 --- a/crates/omnigraph-cli/src/managed/lifecycle/pending.rs +++ b/crates/omnigraph-cli/src/managed/lifecycle/pending.rs @@ -127,7 +127,7 @@ pub(super) fn prepare( if bound_create { return Err(Failure::refused( "context_already_bound", - "cluster create requires an unbound folder; its existing context was preserved", + "managed create requires an unbound folder; its existing context was preserved", )); } let pending = Pending { diff --git a/crates/omnigraph-cli/src/managed/tests.rs b/crates/omnigraph-cli/src/managed/tests.rs index 18f2743c3..6be5266ac 100644 --- a/crates/omnigraph-cli/src/managed/tests.rs +++ b/crates/omnigraph-cli/src/managed/tests.rs @@ -68,7 +68,7 @@ fn legacy_login_and_managed_login_are_exclusive() { assert!(Cli::try_parse_from(std::iter::once("omnigraph").chain(args)).is_err()); } for value in ["0", "3601"] { - assert!(Cli::try_parse_from(["omnigraph", "cluster", "plan", "--timeout", value]).is_err()); + assert!(Cli::try_parse_from(["omnigraph", "managed", "plan", "--timeout", value]).is_err()); } } @@ -97,14 +97,14 @@ fn contexts_are_exact_and_cannot_hide_unknown_authority() { } #[tokio::test] -async fn explicit_direct_is_the_only_context_override() { +async fn managed_namespace_is_explicit_and_core_ignores_context() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join(".omnigraph")).unwrap(); std::fs::write(dir.path().join(".omnigraph/context"), "malformed").unwrap(); let config = dir.path().to_str().unwrap(); let managed = Cli::try_parse_from([ "omnigraph", - "cluster", + "managed", "status", "--config", config, @@ -112,26 +112,46 @@ async fn explicit_direct_is_the_only_context_override() { ]) .unwrap(); assert_eq!(dispatch(&managed).await.unwrap().exit, 2); + for verb in ["validate", "plan", "apply", "status", "observe"] { + let core = Cli::try_parse_from(["omnigraph", "cluster", verb, "--config", config]).unwrap(); + assert!(dispatch(&core).await.is_none(), "{verb}"); + } let direct = Cli::try_parse_from([ "omnigraph", - "cluster", + "managed", "status", "--config", config, "--direct", ]) .unwrap(); - assert!(dispatch(&direct).await.is_none()); - let forbidden = Cli::try_parse_from([ - "omnigraph", - "cluster", - "apply", - "--config", - config, - "--direct", - "--plan", - "plan-id", - ]) - .unwrap(); - assert_eq!(dispatch(&forbidden).await.unwrap().exit, 2); + assert_eq!(dispatch(&direct).await.unwrap().exit, 2); + for args in [ + vec![ + "cluster", + "create", + "demo", + "--api", + "https://control.example", + ], + vec!["cluster", "apply", "--plan", "saved-plan"], + vec!["cluster", "plan", "--rev", "revision"], + vec!["cluster", "status", "run-id"], + vec!["cluster", "status", "--operation", "operation-id"], + vec!["managed", "apply"], + vec![ + "managed", + "apply", + "--plan", + "saved-plan", + "--deployment-id", + "id", + ], + vec!["managed", "status", "--deployment-id", "id"], + vec!["managed", "status", "--operation", "id"], + vec!["managed", "observe"], + ] { + assert!(Cli::try_parse_from(std::iter::once("omnigraph").chain(args)).is_err()); + } + assert!(Cli::try_parse_from(["omnigraph", "managed", "operation", "op-id"]).is_ok()); } diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index 535cd72f8..85aeacce1 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -154,7 +154,9 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool || matches!( cmd, Command::Cluster { - command: ClusterCommand::Apply { .. } | ClusterCommand::Status { .. } + command: ClusterCommand::Plan { .. } + | ClusterCommand::Apply { .. } + | ClusterCommand::Status { .. } } ) } @@ -228,7 +230,7 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool ScopeFlag::Profile => match capability { Any | Served => true, Direct => !matches!(cmd, Command::Init { .. }), - Control => !matches!(cmd, Command::Cluster { .. }), + Control => !matches!(cmd, Command::Cluster { .. } | Command::Managed { .. }), Local => false, }, } @@ -289,7 +291,7 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane { | Command::Repair { .. } | Command::Cleanup { .. } | Command::Lint { .. } => Plane::Storage, - Command::Cluster { .. } => Plane::Control, + Command::Cluster { .. } | Command::Managed { .. } => Plane::Control, Command::Alias { .. } | Command::Embed(_) | Command::Login { .. } @@ -345,6 +347,7 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str { Command::Repair { .. } => "repair", Command::Cleanup { .. } => "cleanup", Command::Cluster { .. } => "cluster", + Command::Managed { .. } => "managed", Command::Graphs { command } => match command { GraphsCommand::List { .. } => "graphs list", }, @@ -553,7 +556,7 @@ mod tests { ), ( parse(&["omnigraph", "cluster", "plan", "--config", "."]), - [false, false, false, false, true, false], + [true, false, false, false, true, false], ), ( parse(&["omnigraph", "cluster", "apply", "--config", "."]), @@ -574,6 +577,10 @@ mod tests { ]), [false, true, false, false, true, false], ), + ( + parse(&["omnigraph", "managed", "status"]), + [false, false, false, false, false, false], + ), ( parse(&["omnigraph", "version"]), [false, false, false, false, false, false], @@ -604,7 +611,6 @@ mod tests { &["--writers-stopped", "--deployment-id", "unused"], "--writers-stopped cannot be used with --server", ), - (&["--no-wait"], "does not accept managed run arguments"), (&["--graph", "knowledge"], "--graph selects a graph"), ]; for (extra, expected) in cases { diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index e48b6428a..83aac500c 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -89,7 +89,7 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { "status": {"canonical_root":remote_root, "ledger_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV", "state_revision":1, "result_revision":0, "next_sequence":1, "lock_id":"server-owner", "outstanding_id":null, "lookup":null}, - "active":false + "active":false, "in_progress":false }); let schema = fs::read_to_string(temp.path().join("people.pg")).unwrap(); for declared_storage in [Some(remote_root.as_str()), None] { @@ -161,6 +161,230 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { api.assert_complete(); } + // Submission is once-only: acceptance and later activation are distinct + // observations. Lost delivery permits GETs under the original identity. + let input_digest = omnigraph_cluster::capture_deployment_for_server(temp.path(), &remote_root) + .unwrap() + .input_digest() + .unwrap(); + let accepted = serde_json::json!({ + "deployment":{"status":"outstanding", "id":id, "input_digest":input_digest, "graphs":{"knowledge":"started"}}, + "active":false, "in_progress":true, + }); + let complete = serde_json::json!({ + "deployment":{"status":"complete", "result":{ + "id":id, "input_digest":input_digest, "authority":{"kind":"authenticated_identity","actor":"operator"}, + "base":{"result_revision":0,"resource_digests":{},"schema_contracts":{},"capture_cas":"base"}, + "result_revision":1,"config_digest":"desired","graphs":{},"recovery_executors":{},"converged":true, + }}, "active":true, "in_progress":false, + }); + for mode in ["accepted", "poll", "lost", "timeout"] { + let mut replies = vec![IntentReply::json(200, status.clone())]; + replies.push(if mode == "lost" { + IntentReply::json(504, serde_json::json!({"error":"proxy lost acceptance"})) + } else { + IntentReply::json(202, accepted.clone()) + }); + if mode == "lost" { + replies.push(IntentReply::json(200, serde_json::json!({"deployment":{"status":"not_recorded"},"active":false,"in_progress":true}))); + replies.push(IntentReply::json(200, accepted.clone())); + } + if mode == "poll" { + let mut activating = complete.clone(); + activating["active"] = false.into(); + activating["in_progress"] = true.into(); + replies.push(IntentReply::json(200, activating)); + } + if mode == "timeout" { + replies.push(IntentReply::json(200, accepted.clone())); + } else if mode != "accepted" { + replies.push(IntentReply::json(200, complete.clone())); + } + let api = IntentApiFixture::graph(replies); + let mut command = cli(); + command + .args([ + "cluster", + "apply", + "--server", + &api.origin, + "--deployment-id", + id, + "--config", + ]) + .arg(temp.path()) + .arg("--json"); + if mode == "accepted" { + command.args(["--no-wait", "--timeout", "10"]); + } else { + command.args(["--timeout", if mode == "timeout" { "1" } else { "10" }]); + } + let output = command.output().unwrap(); + let result = parse_stdout_json(&output); + if mode == "timeout" { + assert_eq!(output.status.code(), Some(5), "{output:?}"); + assert_eq!(result["outcome"], "wait_timeout"); + assert_eq!(result["deployment_id"], id); + assert_eq!(result["last_observation"], accepted); + } else { + assert!(output.status.success(), "{mode}: {output:?}"); + assert_eq!( + result, + if mode == "accepted" { + accepted.clone() + } else { + complete.clone() + } + ); + } + let requests = api.workflow_requests(); + assert_eq!( + requests + .iter() + .filter(|request| request.method == "POST") + .count(), + 1 + ); + for request in requests.iter().skip(2) { + assert_eq!(request.method, "GET"); + assert_eq!(request.path, format!("/cluster/deployments/{id}")); + } + assert_no_core_effects(temp.path()); + api.assert_complete(); + } + // A proxy can lose the input-mismatch refusal for an existing identity. + // Observing its older receipt must never acknowledge this different input. + let mut wrong_input_successes = Vec::new(); + for no_wait in [false, true] { + let mut old_receipt = if no_wait { + accepted.clone() + } else { + complete.clone() + }; + if no_wait { + old_receipt["deployment"]["input_digest"] = "0".repeat(64).into(); + } else { + old_receipt["deployment"]["result"]["input_digest"] = "0".repeat(64).into(); + } + let api = IntentApiFixture::graph(vec![ + IntentReply::json(200, status.clone()), + IntentReply::json( + 504, + serde_json::json!({"error":"proxy lost input-mismatch refusal"}), + ), + IntentReply::json(200, old_receipt), + ]); + let mut command = cli(); + command + .args([ + "cluster", + "apply", + "--server", + &api.origin, + "--deployment-id", + id, + "--config", + ]) + .arg(temp.path()) + .args(["--json", "--timeout", "10"]); + if no_wait { + command.arg("--no-wait"); + } + let output = command.output().unwrap(); + if output.status.success() { + wrong_input_successes.push(no_wait); + } else { + let error = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(error.contains("input digest"), "{output:?}"); + } + let requests = api.workflow_requests(); + assert_eq!( + requests + .iter() + .filter(|request| request.method == "POST") + .count(), + 1 + ); + assert_eq!(requests.len(), 3); + assert_eq!(requests[2].path, format!("/cluster/deployments/{id}")); + api.assert_complete(); + } + assert!( + wrong_input_successes.is_empty(), + "different input was incorrectly acknowledged for no_wait={wrong_input_successes:?}" + ); + + // Exact status can wait without loading a config or general cluster status. + let api = IntentApiFixture::graph(vec![ + IntentReply::json(200, accepted.clone()), + IntentReply::json(200, complete.clone()), + ]); + let observed = output_success(cli().args([ + "cluster", + "status", + "--server", + &api.origin, + "--deployment-id", + id, + "--wait", + "--timeout", + "10", + "--json", + ])); + assert_eq!(parse_stdout_json(&observed), complete); + assert!( + api.workflow_requests() + .iter() + .all(|request| request.method == "GET" + && request.path == format!("/cluster/deployments/{id}")) + ); + api.assert_complete(); + + for terminal in [ + "not_recorded", + "identity_mismatch", + "different_ledger", + "result_expired", + "outstanding", + "complete", + ] { + let response = match terminal { + "outstanding" => { + let mut response = accepted.clone(); + response["in_progress"] = false.into(); + response + } + "complete" => { + let mut response = complete.clone(); + response["active"] = false.into(); + response + } + "result_expired" => { + serde_json::json!({"deployment":{"status":terminal,"acceptance":"unknown","outcome":"unknown"},"active":false,"in_progress":false}) + } + _ => { + serde_json::json!({"deployment":{"status":terminal},"active":false,"in_progress":false}) + } + }; + let api = IntentApiFixture::graph(vec![IntentReply::json(200, response.clone())]); + let output = output_failure(cli().args([ + "cluster", + "status", + "--server", + &api.origin, + "--deployment-id", + id, + "--wait", + "--json", + ])); + assert_eq!(parse_stdout_json(&output), response); + api.assert_complete(); + } + // A different declared root still refuses before POST; the server's status // is binding evidence, never permission to silently retarget the bundle. fs::write(&config, format!("storage: {remote_root}-other\n{source}")).unwrap(); @@ -211,7 +435,7 @@ fn managed_lifecycle_uncertain_create_reuses_durable_key_and_preserves_context() .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "create", name, "--api", @@ -306,7 +530,7 @@ fn managed_lifecycle_pending_is_principal_bound_across_session_renewal() { .env("OMNIGRAPH_CONTROL_TOKEN", token) .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "create", "new-name", "--api", @@ -388,7 +612,7 @@ fn managed_lifecycle_definitive_first_refusal_releases_pending_intent() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "create", name, "--api", @@ -455,7 +679,7 @@ fn managed_lifecycle_delete_and_undo_send_exact_authority_targets() { .current_dir(temp.path()) .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) - .arg("cluster") + .arg("managed") .args(args) .args(["--no-wait", "--idempotency-key", "exact-intent", "--json"]), ); @@ -491,7 +715,7 @@ fn managed_lifecycle_wait_reports_tombstone_and_checks_every_poll_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "delete", "--incarnation", "inc-one", @@ -525,9 +749,8 @@ fn managed_lifecycle_wait_reports_tombstone_and_checks_every_poll_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", - "status", - "--operation", + "managed", + "operation", "operation-one", "--api", &api.origin, @@ -555,7 +778,7 @@ fn managed_lifecycle_bad_acceptance_and_deadline_keep_recovery_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "create", "new", "--api", @@ -582,7 +805,7 @@ fn managed_lifecycle_bad_acceptance_and_deadline_keep_recovery_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "create", "new", "--api", @@ -632,7 +855,7 @@ fn managed_lifecycle_push_sends_only_complete_referenced_files() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "push", "--expected-revision", &"b".repeat(40), @@ -683,7 +906,7 @@ fn managed_lifecycle_push_refuses_unsafe_paths_and_oversized_files_before_http() .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "push", "--expected-revision", &"b".repeat(40), @@ -718,7 +941,7 @@ fn managed_lifecycle_local_lock_and_direct_flags_refuse_without_submission() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "create", "locked", "--api", @@ -742,16 +965,16 @@ fn managed_lifecycle_local_lock_and_direct_flags_refuse_without_submission() { drop(lock); for args in [ vec![ - "cluster", + "managed", "create", "direct", "--api", &api.origin, "--direct", ], - vec!["cluster", "delete", "--incarnation", "inc-one", "--direct"], + vec!["managed", "delete", "--incarnation", "inc-one", "--direct"], vec![ - "cluster", + "managed", "undo-delete", "--incarnation", "inc-one", @@ -760,9 +983,8 @@ fn managed_lifecycle_local_lock_and_direct_flags_refuse_without_submission() { "--direct", ], vec![ - "cluster", - "status", - "--operation", + "managed", + "operation", "op", "--api", &api.origin, @@ -776,10 +998,7 @@ fn managed_lifecycle_local_lock_and_direct_flags_refuse_without_submission() { .output() .unwrap(); assert_eq!(output.status.code(), Some(2)); - assert_eq!( - parse_stdout_json(&output)["type"], - "managed_context_required" - ); + assert_eq!(parse_stdout_json(&output)["type"], "managed_scope_conflict"); } assert!(api.workflow_requests().is_empty()); assert_no_core_effects(temp.path()); @@ -804,7 +1023,7 @@ fn managed_lifecycle_capture_and_retry_records_refuse_symlinks_without_reading_t .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "push", "--expected-revision", &"b".repeat(40), @@ -826,7 +1045,7 @@ fn managed_lifecycle_capture_and_retry_records_refuse_symlinks_without_reading_t .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "cluster", + "managed", "delete", "--incarnation", "inc-one", @@ -861,7 +1080,7 @@ fn managed_data_process_refuses_missing_graph_and_actor_override_before_keychain "--json", ], vec![ - "cluster", + "managed", "token", "--clear", "--graph", @@ -1510,7 +1729,7 @@ fn managed_invalid_context_refuses_without_network_or_core_effects() { write_managed_context(temp.path(), &api.origin); fs::write(temp.path().join(".omnigraph/context"), context).unwrap(); let output = managed_cli(temp.path(), &api.origin) - .args(["apply", "--json"]) + .args(["apply", "--plan", "saved-plan", "--json"]) .output() .unwrap(); assert_eq!(output.status.code(), Some(2)); @@ -1557,7 +1776,7 @@ fn managed_context_links_and_fifo_refuse_without_blocking_or_core_effects() { _ => unreachable!(), } let output = managed_cli(temp.path(), &api.origin) - .args(["apply", "--json"]) + .args(["apply", "--plan", "saved-plan", "--json"]) .output() .unwrap(); assert_eq!(output.status.code(), Some(2), "{variant}"); @@ -1572,31 +1791,51 @@ fn managed_context_links_and_fifo_refuse_without_blocking_or_core_effects() { } #[test] -fn managed_context_is_exact_directory_and_explicit_direct_preserves_core() { +fn managed_namespace_is_explicit_and_core_ignores_folder_context() { let temp = tempdir().unwrap(); let api = IntentApiFixture::new(vec![]); write_cluster_config_fixture(temp.path()); write_managed_context(temp.path(), &api.origin); - let unsupported = managed_cli(temp.path(), &api.origin) - .args(["observe", "--json"]) + for context in ["valid", "malformed"] { + if context == "malformed" { + fs::write(temp.path().join(".omnigraph/context"), "{\n").unwrap(); + } + for verb in ["validate", "plan", "status"] { + let output = output_success( + cli() + .current_dir(temp.path()) + .args(["cluster", verb, "--json"]), + ); + assert_eq!(parse_stdout_json(&output)["ok"], true, "{context}: {verb}"); + } + let observed = cli() + .current_dir(temp.path()) + .args(["cluster", "observe", "--json"]) + .output() + .unwrap(); + assert!(!observed.status.success()); + let observed = parse_stdout_json(&observed); + assert_eq!(observed["ok"], false); + assert!( + observed["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_missing") + ); + assert_no_core_effects(temp.path()); + } + let child = temp.path().join("nested"); + fs::create_dir(&child).unwrap(); + let missing = managed_cli(&child, &api.origin) + .args(["status", "--json"]) .output() .unwrap(); - assert_eq!(unsupported.status.code(), Some(2)); + assert_eq!(missing.status.code(), Some(2)); assert_eq!( - parse_stdout_json(&unsupported)["type"], - "managed_command_unsupported" + parse_stdout_json(&missing)["type"], + "managed_context_required" ); - assert_no_core_effects(temp.path()); - fs::write(temp.path().join(".omnigraph/context"), "{\n").unwrap(); - let direct = output_success( - managed_cli(temp.path(), &api.origin).args(["--direct", "validate", "--json"]), - ); - assert_eq!(parse_stdout_json(&direct)["ok"], true); - let child = temp.path().join("nested"); - fs::create_dir(&child).unwrap(); - write_cluster_config_fixture(&child); - let implicit = output_success(managed_cli(&child, &api.origin).args(["validate", "--json"])); - assert_eq!(parse_stdout_json(&implicit)["ok"], true); assert!(api.requests().is_empty()); assert_no_core_effects(&child); } diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs index 5abe03b1e..f6695d4f6 100644 --- a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -258,7 +258,7 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root ]), )); assert_eq!(status["active"], true, "{status}"); - assert_eq!(status["status"]["lookup"], applied["deployment"]); + assert_eq!(status["deployment"], applied["deployment"]); // Repeating the exact identity is observation, never a second schema or // graph creation invocation. Changed bytes under that identity refuse. let repeat = parse_stdout_json(&output_success( @@ -526,16 +526,24 @@ fn cluster_e2e_offline_deployment_has_root_only_receipts_and_explicit_unlock() { assert_eq!(missing_root.status.code(), Some(2)); assert!(String::from_utf8_lossy(&missing_root.stderr).contains("requires --cluster")); } - for managed_flag in [ + for unsupported_flag in [ vec!["--no-wait"], vec!["--timeout", "1"], vec!["--idempotency-key", "key"], ] { let output = output_failure( - rooted(&["apply", "--deployment-id", id, "--json"]).args(managed_flag), + rooted(&["apply", "--deployment-id", id, "--json"]).args(&unsupported_flag), ); assert_eq!(output.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&output.stdout).contains("managed_scope_conflict")); + let expected = if unsupported_flag[0] == "--idempotency-key" { + "unexpected argument" + } else { + "deployment wait options require --server" + }; + assert!( + String::from_utf8_lossy(&output.stderr).contains(expected), + "{output:?}" + ); } output_failure(&mut rooted(&["force-unlock", "wrong-lock", "--json"])); let unknown_id = format!( @@ -1008,7 +1016,64 @@ fn live_graph_removal_deletes_owned_storage( ); assert_eq!(live_snapshot(server, "retired"), deleted_before); - let deleted = live_apply(config_dir, server, "live-deployment-token"); + // Server-only preview describes destructive removal without changing + // either the ledger or the graph's readable data/history. + let plan = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token") + .args(["cluster", "plan", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json"), + )); + assert_eq!(plan["ok"], true, "{plan}"); + let removal = plan["changes"] + .as_array() + .unwrap() + .iter() + .find(|change| change["resource"] == "graph.retired") + .unwrap(); + assert_eq!(removal["operation"], "delete"); + assert_eq!( + removal["delete_root"], + format!( + "{}/graphs/retired.omni", + status_before["status"]["canonical_root"] + .as_str() + .unwrap() + .trim_start_matches("file://") + .trim_end_matches('/') + ) + ); + assert_eq!( + live_policy_status(server, "live-deployment-token"), + status_before + ); + assert_eq!(live_snapshot(server, "retired"), deleted_before); + + let accepted = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .args(["--no-wait", "--timeout", "30", "--json"]), + )); + let deployment_id = accepted["deployment"]["id"] + .as_str() + .or_else(|| accepted["deployment"]["result"]["id"].as_str()) + .unwrap(); + let deleted = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--deployment-id", + deployment_id, + "--wait", + "--timeout", + "30", + "--json", + ]), + )); + assert_eq!(deleted["active"], true, "{deleted}"); assert_eq!( deleted["deployment"]["result"]["graphs"]["retired"]["outcome"], "deleted", "{deleted}" diff --git a/crates/omnigraph-cli/tests/support/mod.rs b/crates/omnigraph-cli/tests/support/mod.rs index fce862e4d..84ee44dd7 100644 --- a/crates/omnigraph-cli/tests/support/mod.rs +++ b/crates/omnigraph-cli/tests/support/mod.rs @@ -83,7 +83,7 @@ pub fn managed_cli(config: &Path, origin: &str) -> Command { .env("OMNIGRAPH_CONTROL_API", origin) .env("OMNIGRAPH_TOKEN", "data-token-must-not-be-used") .current_dir(config) - .arg("cluster") + .arg("managed") .timeout(Duration::from_secs(15)); command } diff --git a/crates/omnigraph-cluster/src/authorization.rs b/crates/omnigraph-cluster/src/authorization.rs index 900d08180..df1da02e0 100644 --- a/crates/omnigraph-cluster/src/authorization.rs +++ b/crates/omnigraph-cluster/src/authorization.rs @@ -784,14 +784,14 @@ pub async fn authorize_apply_plan( return Err(diagnostics.remove(0)); } } - let prior = snapshot - .state - .as_ref() - .map(state_resource_digests) - .unwrap_or_default(); - let mut changes = diff_resources(&prior, &desired.resource_digests); - append_policy_binding_changes(&mut changes, snapshot.state.as_ref(), &desired); - append_embedding_profile_changes(&mut changes, snapshot.state.as_ref(), &desired); + let changes = crate::diff::diff_state_resources( + &snapshot + .state + .as_ref() + .map(|state| state.applied_revision.resources.clone()) + .unwrap_or_default(), + &captured.resources, + ); let (authorization, _) = authorize_candidate( &backend, &desired, &captured, &snapshot, &changes, identity, true, ) diff --git a/crates/omnigraph-cluster/src/deployment.rs b/crates/omnigraph-cluster/src/deployment.rs index 0178519d6..5ec9d6a77 100644 --- a/crates/omnigraph-cluster/src/deployment.rs +++ b/crates/omnigraph-cluster/src/deployment.rs @@ -39,8 +39,8 @@ pub(crate) fn pristine_bootstrap_state(state: &ClusterState) -> bool { && state.applied_revision.resources.is_empty() } -/// The caller still owns authentication. A stored authority record never -/// grants permission to invoke an effect or disclose an earlier result. +/// The caller still owns authentication. Stored authority never grants effect +/// permission; an authenticated initiating actor may read its exact receipt. #[derive(Debug, Clone)] pub enum DeploymentCaller { StorageOwner { actor: Option }, @@ -132,6 +132,12 @@ impl CapturedDeployment { pub fn config_digest(&self) -> &str { &self.config_digest } + /// Immutable input identity used by the ledger and deployment receipts. + pub fn input_digest(&self) -> Result { + serde_json::to_vec(self) + .map(|bytes| sha256_hex(&bytes)) + .map_err(|error| refusal("deployment_encode", error.to_string())) + } pub fn graph_ids(&self) -> Vec { self.resources .keys() @@ -276,6 +282,7 @@ impl DeploymentStatus { pub enum DeploymentLookup { Outstanding { id: String, + input_digest: String, graphs: BTreeMap, }, Complete { diff --git a/crates/omnigraph-cluster/src/deployment/execution.rs b/crates/omnigraph-cluster/src/deployment/execution.rs index a73ae97f8..0273b73c1 100644 --- a/crates/omnigraph-cluster/src/deployment/execution.rs +++ b/crates/omnigraph-cluster/src/deployment/execution.rs @@ -201,6 +201,48 @@ pub async fn deployment_status( }) } +/// Read one durable invocation without disclosing the current cluster status. +/// Current management permission or the exact authenticated initiating actor +/// authorizes this receipt only. Missing/expired ownership evidence grants no +/// fallback permission, and storage-owner attribution is never authentication. +pub async fn deployment_receipt( + root: &str, + id: &str, + identity: &IdentityAuthorization, +) -> Result<(DeploymentLookup, u64), Diagnostic> { + let store = ClusterStore::for_storage_root(root)?; + let (state, _) = read_existing(&store).await?; + require_v2(&state)?; + let authority = state + .outstanding + .as_ref() + .filter(|pending| pending.id == id) + .map(|pending| &pending.authorization.authority) + .or_else(|| { + state + .deployment_results + .as_ref()? + .iter() + .find(|result| result.id == id) + .map(|result| &result.authority) + }); + if !authority.is_some_and(|authority| { + authority.kind == AuthorityKind::AuthenticatedIdentity + && authority.actor.as_deref() == Some(identity.actor()) + }) { + policies( + &store, + &state, + &DeploymentCaller::AuthenticatedIdentity(identity.clone()), + ) + .await?; + } + Ok(( + lookup(&state, id)?, + state.applied_revision.result_revision.unwrap(), + )) +} + fn lookup(state: &ClusterState, requested: &str) -> Result { let id = DeploymentId::parse(requested)?; if Some(id.ledger.as_str()) != state.ledger_id.as_deref() { @@ -210,6 +252,7 @@ fn lookup(state: &ClusterState, requested: &str) -> Result Result<(), Diagnostic> { - let digest = sha256_hex( - &serde_json::to_vec(bundle).map_err(|e| refusal("deployment_encode", e.to_string()))?, - ); + let digest = bundle.input_digest()?; let recorded = state .outstanding .as_ref() @@ -1236,6 +1274,22 @@ pub async fn prepare_deployment_preview( admission: &ClusterAdmission, caller: &DeploymentCaller, ) -> Result { + let (store, state, cas, affected_graphs) = + capture_deployment_preview(bundle, admission, caller).await?; + let serving = serve::preview_snapshot_with_store(&store, bundle, &affected_graphs, state, cas) + .await + .map_err(|mut diagnostics| diagnostics.remove(0))?; + Ok(DeploymentPreview { + affected_graphs, + serving, + }) +} + +async fn capture_deployment_preview( + bundle: &CapturedDeployment, + admission: &ClusterAdmission, + caller: &DeploymentCaller, +) -> Result<(ClusterStore, ClusterState, String, Vec), Diagnostic> { if bundle.canonical_root() != admission.canonical_root() { return Err(refusal( "cluster_admission_root_mismatch", @@ -1262,15 +1316,189 @@ pub async fn prepare_deployment_preview( for graph in &affected_graphs { authorize_graph_change(&state, bundle, caller, &policy, graph)?; } - let serving = serve::preview_snapshot_with_store(&store, bundle, &affected_graphs, state, cas) + Ok((store, state, cas, affected_graphs)) +} + +/// Observe a captured deployment through the serving owner's existing handles. +/// No graph is opened, no admission is closed, and no execution intent or +/// sequence is reserved. Apply repeats physical eligibility after drainage. +pub async fn plan_captured_deployment( + bundle: &CapturedDeployment, + admission: &ClusterAdmission, + caller: &DeploymentCaller, + live_graphs: &BTreeMap>, +) -> Result { + let (store, state, cas, affected) = + capture_deployment_preview(bundle, admission, caller).await?; + // Verify the same control payloads and candidate bindings used by preview. + // This projection reads no graph datasets or source paths on the client. + serve::preview_snapshot_with_store(&store, bundle, &affected, state.clone(), cas.clone()) .await .map_err(|mut diagnostics| diagnostics.remove(0))?; - Ok(DeploymentPreview { - affected_graphs, - serving, + let mut changes = + crate::diff::diff_state_resources(&state.applied_revision.resources, &bundle.resources); + let mut diagnostics = Vec::new(); + for graph in &affected { + if !state + .applied_revision + .resources + .contains_key(&graph_address(graph)) + { + continue; + } + let Some(db) = live_graphs.get(graph) else { + if !bundle.resources.contains_key(&graph_address(graph)) { + // Deletion can remove a blocked or missing graph. Its ledger + // identity is enough to describe the change; apply verifies + // the physical root after draining affected work. + continue; + } + return Err(refusal( + "graph_unavailable", + format!("graph {graph} has no ready serving handle for an observed plan"), + )); + }; + admission::admitted_graph_id( + &bundle.canonical_root, + state.applied_revision.schema_contracts.as_ref().unwrap(), + db.uri(), + )?; + let expected = &state.applied_revision.schema_contracts.as_ref().unwrap()[graph]; + if &db.schema_contract_digest() != expected { + return Err(refusal( + "applied_schema_drift", + format!("graph {graph} differs from its achieved contract"), + )); + } + let address = schema_address(graph); + if let Some(change) = changes + .iter_mut() + .find(|change| change.resource == address && change.operation == PlanOperation::Update) + { + let migration = db + .plan_schema_at_contract(source_for(bundle, &address)?, expected) + .map_err(|error| refusal("schema_preview_unavailable", error.to_string()))?; + if !migration.supported { + diagnostics.push(refusal( + "schema_preflight_failed", + "observed schema migration is unsupported", + )); + } + if db + .branch_list() + .await + .map_err(|error| refusal("schema_preview_unavailable", error.to_string()))? + .iter() + .any(|branch| branch != "main") + { + diagnostics.push(refusal( + "schema_preflight_failed", + format!("graph {graph} schema apply requires only main"), + )); + } + change.migration = Some(migration); + } + } + annotate_plan_changes(&store, &mut changes, diagnostics.first())?; + let mut dependencies = BTreeSet::new(); + for (address, resource) in &bundle.resources { + match resource_kind(address) { + ResourceKind::Schema(graph) => { + dependencies.insert(Dependency { + from: address.clone(), + to: graph_address(&graph), + }); + } + ResourceKind::Query { graph, .. } => { + dependencies.insert(Dependency { + from: address.clone(), + to: graph_address(&graph), + }); + dependencies.insert(Dependency { + from: address.clone(), + to: schema_address(&graph), + }); + } + ResourceKind::Policy(_) => { + for target in resource + .applies_to + .iter() + .flatten() + .filter(|target| target.starts_with("graph.")) + { + dependencies.insert(Dependency { + from: address.clone(), + to: target.clone(), + }); + } + } + ResourceKind::Graph(_) => { + if let Some(provider) = &resource.embedding_provider { + dependencies.insert(Dependency { + from: address.clone(), + to: provider.clone(), + }); + } + } + _ => {} + } + } + let dependencies: Vec<_> = dependencies.into_iter().collect(); + let mut observations = store.observations(); + observations.state_found = true; + observations.state_revision = state.state_revision; + observations.state_cas = Some(cas); + observations.applied_config_digest = state.applied_revision.config_digest; + observations.resource_count = state.applied_revision.resources.len(); + // The lifetime admission is already held. A plan does not acquire it. + observations.locked = true; + observations.lock_id = Some(admission.lock_id().to_owned()); + Ok(PlanOutput { + ok: !has_errors(&diagnostics), + authority: LedgerAuthority::Observed, + config_dir: bundle.canonical_root.clone(), + desired_revision: DesiredRevision { + config_digest: Some(bundle.config_digest.clone()), + }, + input_digest: Some(bundle.input_digest()?), + resource_digests: bundle + .resources + .iter() + .map(|(address, resource)| (address.clone(), resource.digest.clone())) + .collect(), + blast_radius: compute_blast_radius(&changes, &dependencies), + dependencies, + state_observations: observations, + changes, + diagnostics, }) } +pub(crate) fn annotate_plan_changes( + store: &ClusterStore, + changes: &mut [PlanChange], + error: Option<&Diagnostic>, +) -> Result<(), Diagnostic> { + for change in changes { + change.disposition = Some(if error.is_some() { + ApplyDisposition::Blocked + } else if matches!(resource_kind(&change.resource), ResourceKind::Graph(_)) + && change.operation == PlanOperation::Update + { + ApplyDisposition::Derived + } else { + ApplyDisposition::Applied + }); + change.reason = error.map(|error| error.code.clone()); + if let ResourceKind::Graph(graph) = resource_kind(&change.resource) + && change.operation == PlanOperation::Delete + { + change.delete_root = Some(store.canonical_managed_graph_root(&graph)?); + } + } + Ok(()) +} + /// Authorize from applied control metadata before any graph gate or open. /// The executor repeats the same check at its fresh accepted-base capture. fn authorize_graph_change( @@ -1332,6 +1560,7 @@ pub async fn apply_deployment( &admission, &BTreeMap::new(), report_id, + |_| {}, &mut effects_started, ) .await; @@ -1352,6 +1581,9 @@ pub async fn apply_deployment( /// Every affected live handle must be drained with healthy native operations /// before invocation. Root admission excludes participating writers; it does /// not fence excluded/raw writers or prove remote I/O settlement after failure. +/// `on_accepted` receives the confirmed outstanding receipt after its ledger +/// CAS, before graph effects. Lookup and pre-acceptance refusal never call it. +#[allow(clippy::too_many_arguments)] // Distinct identity-report and durable-acceptance boundaries. pub async fn apply_captured_deployment( bundle: &CapturedDeployment, requested_id: Option<&str>, @@ -1359,6 +1591,7 @@ pub async fn apply_captured_deployment( admission: &ClusterAdmission, live_graphs: &BTreeMap>, report_id: impl FnOnce(&str, &str, &str), + on_accepted: impl FnOnce(DeploymentLookup), effects_started: &mut bool, ) -> Result { execute_captured_deployment( @@ -1368,11 +1601,13 @@ pub async fn apply_captured_deployment( admission, live_graphs, report_id, + on_accepted, effects_started, ) .await } +#[allow(clippy::too_many_arguments)] // Shared executor for direct and served acceptance observers. async fn execute_captured_deployment( bundle: &CapturedDeployment, requested_id: Option<&str>, @@ -1380,6 +1615,7 @@ async fn execute_captured_deployment( admission: &ClusterAdmission, live_graphs: &BTreeMap>, report_id: impl FnOnce(&str, &str, &str), + on_accepted: impl FnOnce(DeploymentLookup), effects_started: &mut bool, ) -> Result { let store = ClusterStore::for_storage_root(bundle.canonical_root())?; @@ -1392,10 +1628,7 @@ async fn execute_captured_deployment( )); } admission.validate_deployment().await?; - let input_digest = sha256_hex( - &serde_json::to_vec(&bundle) - .map_err(|error| refusal("deployment_encode", error.to_string()))?, - ); + let input_digest = bundle.input_digest()?; // Existing identities are lookup-only, including while their owner holds // the lock. Input equality is required before exposing the original result. let (before, before_cas) = read_existing(&store).await?; @@ -1476,6 +1709,7 @@ async fn execute_captured_deployment( } seams::fail(&DEPLOYMENT_BEFORE_ACCEPTANCE)?; cas = replace(&store, &mut state, &cas).await?; + on_accepted(lookup(&state, &id)?); seams::fail(&DEPLOYMENT_AFTER_ACCEPTANCE)?; install_catalog_payloads(&store, &state, bundle).await?; for (graph, entry) in state.outstanding.as_ref().unwrap().graphs.clone() { @@ -1692,10 +1926,7 @@ pub(crate) async fn preflight_deployment_at( let cas = snapshot .state_cas .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b""))); - let input_digest = sha256_hex( - &serde_json::to_vec(bundle) - .map_err(|error| refusal("deployment_encode", error.to_string()))?, - ); + let input_digest = bundle.input_digest()?; let prepared = prepare_deployment( &store, bundle, diff --git a/crates/omnigraph-cluster/src/deployment/tests.rs b/crates/omnigraph-cluster/src/deployment/tests.rs index abfea655a..982cb0e0f 100644 --- a/crates/omnigraph-cluster/src/deployment/tests.rs +++ b/crates/omnigraph-cluster/src/deployment/tests.rs @@ -182,6 +182,7 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { ) .unwrap(); let bundle = capture_deployment(dir.path()).unwrap(); + let input_digest = bundle.input_digest().unwrap(); // The serving preview reads control metadata only. Full engine // preparation runs after the server closes and drains affected work; // reopening here would queue an exclusive schema gate behind writers. @@ -192,7 +193,37 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { fs::rename(&parked_root, &graph_root).unwrap(); let preview = preview.expect("serving preview must not open an affected graph"); assert_eq!(preview.affected_graphs, ["knowledge"]); + let before_plan = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let observed = plan_captured_deployment(&bundle, &admission, &owner(), &handles) + .await + .unwrap(); + assert!(observed.ok, "{observed:?}"); + assert_eq!(observed.authority, LedgerAuthority::Observed); + assert!(!observed.state_observations.lock_acquired); + assert_eq!( + observed.input_digest.as_deref(), + Some(sha256_hex(&serde_json::to_vec(&bundle).unwrap()).as_str()) + ); + assert!( + observed + .changes + .iter() + .any(|change| change.resource == "schema.knowledge" + && change.migration.as_ref().is_some_and(|plan| plan.supported)) + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before_plan + ); + assert_eq!( + plan_captured_deployment(&bundle, &admission, &owner(), &BTreeMap::new()) + .await + .unwrap_err() + .code, + "graph_unavailable" + ); let mut started = false; + let mut accepted = false; let applied = apply_captured_deployment( &bundle, None, @@ -200,15 +231,33 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { &admission, &handles, |_, _, _| {}, + |lookup| { + let DeploymentLookup::Outstanding { + id, + input_digest: recorded_digest, + .. + } = lookup + else { + panic!("acceptance must be outstanding") + }; + let state: serde_json::Value = + serde_json::from_slice(&fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap()) + .unwrap(); + assert_eq!(state["outstanding"]["id"], id); + assert_eq!(state["outstanding"]["input_digest"], input_digest); + assert_eq!(recorded_digest, input_digest); + accepted = true; + }, &mut started, ) .await .unwrap(); - assert!(started); + assert!(started && accepted); let DeploymentLookup::Complete { result } = applied else { panic!("{applied:?}"); }; assert!(result.converged); + assert_eq!(result.input_digest, input_digest); assert_eq!( db.schema_contract_digest().source_hash, bundle.resources["schema.knowledge"].digest @@ -236,6 +285,20 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { fs::read(dir.path().join("__cluster/state.json")).unwrap(), before ); + let mut repeated_effects = false; + apply_captured_deployment( + &bundle, + Some(&result.id), + &owner(), + &admission, + &handles, + |_, _, _| panic!("lookup cannot report a new invocation"), + |_| panic!("lookup cannot acknowledge new acceptance"), + &mut repeated_effects, + ) + .await + .unwrap(); + assert!(!repeated_effects); } // Membership captured at server boot is not current deployment authority: // create and delete a later graph under this same lifetime writer. @@ -249,6 +312,7 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { &admission, &handles, |_, _, _| {}, + |_| {}, &mut started, ) .await @@ -267,6 +331,29 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { config["policies"]["base"]["applies_to"] = serde_yaml::from_str("[knowledge]").unwrap(); }); let bundle = capture_deployment(dir.path()).unwrap(); + // A removed graph may be blocked without a live engine. Observing its + // deletion uses ledger authority; apply checks its physical identity later. + let planned = plan_captured_deployment(&bundle, &admission, &owner(), &handles) + .await + .unwrap(); + assert!(planned.ok, "{planned:?}"); + let deletion = planned + .changes + .iter() + .find(|change| change.resource == "graph.second") + .unwrap(); + assert_eq!(deletion.operation, PlanOperation::Delete); + assert_eq!( + deletion.delete_root.as_deref(), + Some( + format!( + "{}/graphs/second.omni", + admission.canonical_root().trim_start_matches("file://") + ) + .as_str() + ) + ); + assert!(dir.path().join("graphs/second.omni").exists()); let deleted = apply_captured_deployment( &bundle, None, @@ -274,6 +361,7 @@ async fn held_server_owner_applies_twice_and_retains_exact_achieved_receipts() { &admission, &handles, |_, _, _| {}, + |_| {}, &mut started, ) .await @@ -332,6 +420,7 @@ async fn captured_input_is_revalidated_and_preflight_keeps_original_writer() { &admission, &BTreeMap::new(), |_, _, _| {}, + |_| panic!("refused input must not be accepted"), &mut effects, ) .await @@ -630,6 +719,90 @@ async fn authenticated_deployment_records_the_authenticated_actor() { panic!("{:?}", result.graphs); }; assert_eq!(commit.actor_id.as_deref(), Some("principal:owner")); + + // The initiator retains only exact receipt access after its own policy + // handoff. This cannot grant cluster status, successor execution, another + // actor's result, or a storage owner's merely attributed result. + unlock(root).await; + let management = dir.path().join("management.policy.yaml"); + let source = fs::read_to_string(&management) + .unwrap() + .replace("principal:owner", "principal:successor"); + fs::write(&management, source).unwrap(); + let handoff = apply_deployment(dir.path(), None, &identity("principal:owner"), |_, _, _| {}) + .await + .unwrap(); + let DeploymentLookup::Complete { result: handoff } = handoff else { + panic!("handoff missing") + }; + let actor = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + for id in [&result.id, &handoff.id] { + assert!(matches!( + deployment_receipt(root, id, &actor).await.unwrap().0, + DeploymentLookup::Complete { .. } + )); + assert_eq!( + deployment_receipt( + root, + id, + &IdentityAuthorization::authenticated("principal:reader").unwrap() + ) + .await + .unwrap_err() + .code, + "policy_denied" + ); + } + assert_eq!( + deployment_status(root, None, &identity("principal:owner")) + .await + .unwrap_err() + .code, + "policy_denied" + ); + let store = ClusterStore::for_config_dir(dir.path()); + let (mut state, cas) = execution::read_existing(&store).await.unwrap(); + let attributed = &state.deployment_results.as_ref().unwrap()[0]; + assert_eq!(attributed.authority.kind, AuthorityKind::StorageOwner); + assert_eq!( + deployment_receipt(root, &attributed.id, &actor) + .await + .unwrap_err() + .code, + "policy_denied" + ); + let next = deployment_status(root, None, &owner()) + .await + .unwrap() + .next_deployment_id(); + assert_eq!( + deployment_receipt(root, &next, &actor) + .await + .unwrap_err() + .code, + "policy_denied" + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + state + .deployment_results + .as_mut() + .unwrap() + .retain(|entry| entry.id == handoff.id); + store + .write_state(&state, Some(&cas), &mut store.observations()) + .await + .unwrap(); + assert_eq!( + deployment_receipt(root, &result.id, &actor) + .await + .unwrap_err() + .code, + "policy_denied" + ); } #[tokio::test] diff --git a/crates/omnigraph-cluster/src/diff.rs b/crates/omnigraph-cluster/src/diff.rs index 1bc5c0aa2..062cb2ed5 100644 --- a/crates/omnigraph-cluster/src/diff.rs +++ b/crates/omnigraph-cluster/src/diff.rs @@ -20,6 +20,7 @@ pub(crate) fn diff_resources( binding_change: false, metadata_change: None, migration: None, + delete_root: None, }), Some(before) if before != after => changes.push(PlanChange { resource: address.clone(), @@ -31,6 +32,7 @@ pub(crate) fn diff_resources( binding_change: false, metadata_change: None, migration: None, + delete_root: None, }), Some(_) => {} } @@ -47,6 +49,7 @@ pub(crate) fn diff_resources( binding_change: false, metadata_change: None, migration: None, + delete_root: None, }); } } @@ -54,82 +57,53 @@ pub(crate) fn diff_resources( changes } -/// Binding-only policy changes: the file digest is unchanged (so -/// `diff_resources` saw nothing) but the applied `applies_to` differs from -/// the desired bindings — including the pre-5A case where the state entry -/// has no bindings recorded yet. These are first-class plan changes: without -/// this pass a binding edit would silently rot or silently converge. -pub(crate) fn append_policy_binding_changes( - changes: &mut Vec, - prior_state: Option<&ClusterState>, - desired: &DesiredCluster, -) { - let Some(state) = prior_state else { - return; // no state: everything is already a Create carrying bindings +/// The same normalized resource projection drives captured and file plans. +pub(crate) fn diff_state_resources( + prior: &BTreeMap, + desired: &BTreeMap, +) -> Vec { + let digests = |resources: &BTreeMap| { + resources + .iter() + .map(|(address, resource)| (address.clone(), resource.digest.clone())) + .collect() }; - for (address, desired_bindings) in &desired.policy_bindings { - if changes.iter().any(|change| &change.resource == address) { - continue; // content change already covers it - } - let Some(entry) = state.applied_revision.resources.get(address) else { - continue; // not applied yet: the Create covers it + let mut changes = diff_resources(&digests(prior), &digests(desired)); + for (address, after) in desired { + let Some(before) = prior.get(address) else { + continue; }; - if entry.applies_to.as_ref() == Some(desired_bindings) { + if changes.iter().any(|change| &change.resource == address) { continue; } - changes.push(PlanChange { - resource: address.clone(), - operation: PlanOperation::Update, - before_digest: Some(entry.digest.clone()), - after_digest: Some(entry.digest.clone()), - disposition: None, - reason: None, - binding_change: true, - metadata_change: Some(PlanMetadataChange::PolicyBindings), - migration: None, - }); - } - changes.sort_by(|a, b| a.resource.cmp(&b.resource)); -} - -/// Metadata-only embedding provider changes: the provider digest is unchanged -/// but the applied state predates storing the profile body needed by -/// config-free serving. This mirrors policy binding backfill instead of -/// hiding a serving-time failure behind a no-op plan. -pub(crate) fn append_embedding_profile_changes( - changes: &mut Vec, - prior_state: Option<&ClusterState>, - desired: &DesiredCluster, -) { - let Some(state) = prior_state else { - return; // no state: provider Creates carry profiles already - }; - for (address, desired_profile) in &desired.embedding_providers { - if changes - .iter() - .any(|change| change.resource.as_str() == address.as_str()) - { - continue; // content change already covers it - } - let Some(entry) = state.applied_revision.resources.get(address) else { - continue; // not applied yet: the Create covers it + let metadata_change = match resource_kind(address) { + ResourceKind::Policy(_) if before.applies_to != after.applies_to => { + Some(PlanMetadataChange::PolicyBindings) + } + ResourceKind::EmbeddingProvider(_) + if before.embedding_profile != after.embedding_profile => + { + Some(PlanMetadataChange::EmbeddingProfile) + } + _ => None, }; - if entry.embedding_profile.as_ref() == Some(desired_profile) { - continue; + if let Some(metadata_change) = metadata_change { + changes.push(PlanChange { + resource: address.clone(), + operation: PlanOperation::Update, + before_digest: Some(before.digest.clone()), + after_digest: Some(after.digest.clone()), + disposition: None, + reason: None, + binding_change: metadata_change == PlanMetadataChange::PolicyBindings, + metadata_change: Some(metadata_change), + migration: None, + delete_root: None, + }); } - changes.push(PlanChange { - resource: address.clone(), - operation: PlanOperation::Update, - before_digest: Some(entry.digest.clone()), - after_digest: Some(entry.digest.clone()), - disposition: None, - reason: None, - binding_change: false, - metadata_change: Some(PlanMetadataChange::EmbeddingProfile), - migration: None, - }); } changes.sort_by(|a, b| a.resource.cmp(&b.resource)); + changes } pub(crate) fn compute_blast_radius( diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index 645a3f30d..1c7263063 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -46,10 +46,7 @@ use config::{ schema_address, state_resource_digests, validate_cluster_header, }; pub use deployment::*; -use diff::{ - ResourceKind, append_embedding_profile_changes, append_policy_binding_changes, - compute_blast_radius, diff_resources, resource_kind, -}; +use diff::{ResourceKind, compute_blast_radius, resource_kind}; pub use graph_read::GraphReadAuthority; #[cfg(any(test, feature = "test-util"))] pub use serve::read_serving_snapshot_with_display_root; @@ -201,6 +198,7 @@ async fn plan_config_dir_impl( desired_revision: DesiredRevision { config_digest: None, }, + input_digest: None, resource_digests: BTreeMap::new(), dependencies: Vec::new(), state_observations: observations, @@ -218,6 +216,7 @@ async fn plan_config_dir_impl( desired_revision: DesiredRevision { config_digest: Some(desired.config_digest), }, + input_digest: None, resource_digests: desired.resource_digests, dependencies: desired.dependencies, state_observations: observations, @@ -234,7 +233,6 @@ async fn plan_config_dir_impl( // Plan reports pending recovery without executing it. warn_pending_recovery_sidecars(&backend, &mut diagnostics).await; - let mut prior_resources = BTreeMap::new(); let mut prior_state: Option = None; let mut prior_cas = None; if !has_errors(&diagnostics) { @@ -242,7 +240,6 @@ async fn plan_config_dir_impl( Ok(snapshot) => { prior_cas = snapshot.state_cas; if let Some(state) = snapshot.state { - prior_resources = state_resource_digests(&state); prior_state = Some(state); } } @@ -250,16 +247,24 @@ async fn plan_config_dir_impl( } } + let bundle = capture_desired_deployment(&desired, captured.sources, None); + let input_digest = bundle + .as_ref() + .ok() + .and_then(|bundle| bundle.input_digest().ok()); let mut changes = if has_errors(&diagnostics) { Vec::new() + } else if let Ok(bundle) = &bundle { + crate::diff::diff_state_resources( + &prior_state + .as_ref() + .map(|state| state.applied_revision.resources.clone()) + .unwrap_or_default(), + &bundle.resources, + ) } else { - diff_resources(&prior_resources, &desired.resource_digests) + Vec::new() }; - if !has_errors(&diagnostics) { - append_policy_binding_changes(&mut changes, prior_state.as_ref(), &desired); - append_embedding_profile_changes(&mut changes, prior_state.as_ref(), &desired); - } - let bundle = capture_desired_deployment(&desired, captured.sources, None); // The same v2 scope rules govern previews and execution. A refused scope // is wholly pre-effect; no approval artifact can authorize a removed path. let scope_error = prior_state.as_ref().and_then(|state| { @@ -275,22 +280,8 @@ async fn plan_config_dir_impl( Err(error) => Some(error.clone()), } }); - for change in &mut changes { - if let Some(error) = &scope_error { - change.disposition = Some(ApplyDisposition::Blocked); - change.reason = Some(error.code.clone()); - } else { - change.disposition = Some( - if matches!(resource_kind(&change.resource), ResourceKind::Graph(_)) - && change.operation == PlanOperation::Update - { - ApplyDisposition::Derived - } else { - ApplyDisposition::Applied - }, - ); - change.reason = None; - } + if let Err(error) = annotate_plan_changes(&backend, &mut changes, scope_error.as_ref()) { + diagnostics.push(error); } if let Some(error) = scope_error { diagnostics.push(error); @@ -357,6 +348,7 @@ async fn plan_config_dir_impl( desired_revision: DesiredRevision { config_digest: Some(desired.config_digest), }, + input_digest, resource_digests: desired.resource_digests, dependencies: desired.dependencies, state_observations: observations, diff --git a/crates/omnigraph-cluster/src/types.rs b/crates/omnigraph-cluster/src/types.rs index 0fb9e791d..f9693c981 100644 --- a/crates/omnigraph-cluster/src/types.rs +++ b/crates/omnigraph-cluster/src/types.rs @@ -198,6 +198,9 @@ pub struct PlanChange { /// unavailable (error `schema_preview_unavailable`). #[serde(skip_serializing_if = "Option::is_none")] pub migration: Option, + /// Removing a declared graph deletes its managed root and retained history. + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_root: Option, } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -220,6 +223,10 @@ pub struct PlanOutput { pub authority: LedgerAuthority, pub config_dir: String, pub desired_revision: DesiredRevision, + /// Immutable captured input identity, when capture succeeded. This is an + /// observation, not a reservation or permission to execute it later. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_digest: Option, pub resource_digests: BTreeMap, pub dependencies: Vec, pub state_observations: StateObservations, diff --git a/crates/omnigraph-cluster/tests/failpoints.rs b/crates/omnigraph-cluster/tests/failpoints.rs index 4b7b1ed65..c69ec297a 100644 --- a/crates/omnigraph-cluster/tests/failpoints.rs +++ b/crates/omnigraph-cluster/tests/failpoints.rs @@ -476,18 +476,31 @@ async fn offline_deployment_failure_windows_preserve_original_identity() { #[tokio::test] #[serial] async fn offline_preacceptance_id_never_aliases_a_later_nonce() { - use omnigraph_cluster::{DeploymentLookup, apply_deployment, deployment_status}; + use omnigraph_cluster::{ + ClusterAdmissionPurpose, DeploymentLookup, acquire_cluster_admission, + apply_captured_deployment, apply_deployment, capture_deployment, deployment_status, + }; let _scenario = FailScenario::setup(); let dir = offline_fixture().await; let root = dir.path().to_str().unwrap(); let mut unaccepted = String::new(); { + let admission = acquire_cluster_admission(root, ClusterAdmissionPurpose::Deployment) + .await + .unwrap() + .unwrap(); + let bundle = capture_deployment(dir.path()).unwrap(); + let mut effects = false; let _fail = omnigraph_cluster::seams::catalog::DEPLOYMENT_BEFORE_ACCEPTANCE.fire_always(); - Box::pin(apply_deployment( - dir.path(), + Box::pin(apply_captured_deployment( + &bundle, None, &deployment_owner(), + &admission, + &std::collections::BTreeMap::new(), |id, _, _| unaccepted = id.into(), + |_| panic!("pre-acceptance failure cannot acknowledge acceptance"), + &mut effects, )) .await .unwrap_err(); diff --git a/crates/omnigraph-server/Cargo.toml b/crates/omnigraph-server/Cargo.toml index de26939d0..cce8b3fca 100644 --- a/crates/omnigraph-server/Cargo.toml +++ b/crates/omnigraph-server/Cargo.toml @@ -56,7 +56,7 @@ aws-sdk-secretsmanager = { version = "1", optional = true, default-features = fa [dev-dependencies] omnigraph = { package = "omnigraph-engine", path = "../omnigraph", features = ["failpoints"] } -omnigraph-cluster = { path = "../omnigraph-cluster", version = "0.12.0", features = ["test-util"] } +omnigraph-cluster = { path = "../omnigraph-cluster", version = "0.12.0", features = ["test-util", "failpoints"] } tempfile = { workspace = true } tower = { workspace = true } serial_test = "3" diff --git a/crates/omnigraph-server/src/deployment.rs b/crates/omnigraph-server/src/deployment.rs index 10f5db6f8..44a393b38 100644 --- a/crates/omnigraph-server/src/deployment.rs +++ b/crates/omnigraph-server/src/deployment.rs @@ -3,18 +3,19 @@ //! only request lifetime, graph admission and activation of serving bindings. use std::collections::{BTreeMap, HashMap}; -use std::sync::Arc; +use std::sync::{Arc, Mutex as SyncMutex, PoisonError}; use axum::{ Extension, Json, extract::{Path, State}, + http::StatusCode, }; use omnigraph_cluster::{ CapturedDeployment, DeploymentCaller, DeploymentLookup, DeploymentStatus, GraphDeploymentResult, IdentityAuthorization, }; use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, oneshot}; use tokio::time::{Duration, Instant}; use utoipa::ToSchema; @@ -33,6 +34,44 @@ const DRAIN_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Default)] pub(crate) struct DeploymentRuntime { gate: Arc>, + // Process observation generation and exact current owned invocation. + current: SyncMutex<(u64, Option)>, +} + +impl DeploymentRuntime { + fn observation(&self, id: Option<&str>) -> (u64, bool) { + let current = self.current.lock().unwrap_or_else(PoisonError::into_inner); + ( + current.0, + current + .1 + .as_ref() + .is_some_and(|current| id.is_none_or(|id| id == current)), + ) + } + + // The deployment gate is already held; this is a derived process observation, + // never durable execution permission or a second deployment queue. + fn own(self: &Arc, id: String) -> RunningDeployment { + let mut current = self.current.lock().unwrap_or_else(PoisonError::into_inner); + current.0 = current.0.wrapping_add(1); + current.1 = Some(id); + RunningDeployment(Arc::clone(self)) + } +} + +struct RunningDeployment(Arc); + +impl Drop for RunningDeployment { + fn drop(&mut self) { + let mut current = self + .0 + .current + .lock() + .unwrap_or_else(PoisonError::into_inner); + current.0 = current.0.wrapping_add(1); + current.1 = None; + } } #[derive(Deserialize, ToSchema)] @@ -43,11 +82,19 @@ pub(crate) struct DeploymentRequest { deployment: CapturedDeployment, } +#[derive(Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct PlanRequest { + #[schema(value_type = Object)] + deployment: CapturedDeployment, +} + #[derive(Serialize, ToSchema)] pub(crate) struct DeploymentResponse { #[schema(value_type = Object)] deployment: DeploymentLookup, active: bool, + in_progress: bool, } #[derive(Serialize, ToSchema)] @@ -55,6 +102,7 @@ pub(crate) struct DeploymentStatusResponse { #[schema(value_type = Object)] status: DeploymentStatus, active: bool, + in_progress: bool, } fn caller(state: &AppState, actor: &AuthenticatedActor) -> Result { @@ -95,6 +143,15 @@ fn refusal(error: omnigraph_cluster::Diagnostic) -> ApiError { } } +fn observation_changed() -> ApiError { + let mut error = ApiError::conflict( + "deployment ownership changed during observation; repeat this status request", + ); + error.status = StatusCode::SERVICE_UNAVAILABLE; + error.code = None; + error +} + fn uncertain(message: impl Into) -> ApiError { let message = message.into(); tracing::error!(reason = %message, "deployment requires reconciliation"); @@ -207,7 +264,8 @@ fn active_result( #[utoipa::path( get, path = "/cluster/deployments", tag = "cluster", operation_id = "deployment_status", - responses((status = 200, body = DeploymentStatusResponse), (status = 403, body = crate::api::ErrorOutput)), + responses((status = 200, body = DeploymentStatusResponse), (status = 403, body = crate::api::ErrorOutput), + (status = 503, description = "Deployment ownership changed during the bounded ledger observation; retry this GET within the caller deadline", body = crate::api::ErrorOutput)), security(("bearer_token" = [])) )] pub(crate) async fn status( @@ -220,15 +278,36 @@ pub(crate) async fn status( #[utoipa::path( get, path = "/cluster/deployments/{id}", tag = "cluster", operation_id = "deployment_lookup", params(("id" = String, Path, description = "Original deployment identity")), - responses((status = 200, body = DeploymentStatusResponse), (status = 403, body = crate::api::ErrorOutput)), + responses((status = 200, body = DeploymentResponse), (status = 403, body = crate::api::ErrorOutput), + (status = 503, description = "Deployment ownership changed during the bounded ledger observation; retry this GET within the caller deadline", body = crate::api::ErrorOutput)), security(("bearer_token" = [])) )] pub(crate) async fn lookup( State(state): State, Extension(actor): Extension, Path(id): Path, -) -> Result, ApiError> { - lookup_status(&state, &actor, Some(&id)).await.map(Json) +) -> Result, ApiError> { + let owner = admission(&state)?; + let identity = IdentityAuthorization::authenticated(actor.actor_id_str()).map_err(refusal)?; + for _ in 0..3 { + let (generation, _) = state.deployments.observation(Some(&id)); + let (deployment, revision) = + omnigraph_cluster::deployment_receipt(owner.canonical_root(), &id, &identity) + .await + .map_err(refusal)?; + let (observed_generation, in_progress) = state.deployments.observation(Some(&id)); + if generation != observed_generation { + // An owner starting or finishing during the read can make an old + // ledger snapshot appear ownerless. Retry this read, never apply. + continue; + } + return Ok(Json(DeploymentResponse { + active: active_result(&state, Some(&deployment), revision), + deployment, + in_progress, + })); + } + Err(observation_changed()) } async fn lookup_status( @@ -238,17 +317,66 @@ async fn lookup_status( ) -> Result { let caller = caller(state, actor)?; let owner = admission(state)?; - let status = omnigraph_cluster::deployment_status(owner.canonical_root(), id, &caller) + for _ in 0..3 { + let (generation, _) = state.deployments.observation(id); + let status = omnigraph_cluster::deployment_status(owner.canonical_root(), id, &caller) + .await + .map_err(refusal)?; + let (observed_generation, in_progress) = state.deployments.observation(id); + if generation != observed_generation { + continue; + } + let active = active_result(state, status.lookup.as_ref(), status.result_revision); + return Ok(DeploymentStatusResponse { + status, + active, + in_progress, + }); + } + Err(observation_changed()) +} + +#[utoipa::path( + post, path = "/cluster/plan", tag = "cluster", operation_id = "deployment_plan", + request_body = PlanRequest, + responses((status = 200, body = Object), (status = 400, body = crate::api::ErrorOutput), + (status = 403, body = crate::api::ErrorOutput), (status = 409, body = crate::api::ErrorOutput), + (status = 413, body = crate::api::ErrorOutput)), + security(("bearer_token" = [])) +)] +pub(crate) async fn plan( + State(state): State, + Extension(actor): Extension, + request: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let caller = caller(&state, &actor)?; + let Json(request) = request.map_err(|error| ApiError::bad_request(error.body_text()))?; + let owner = admission(&state)?; + let _gate = state.deployments.gate.try_lock().map_err(|_| { + ApiError::conflict("deployment planning or apply is already running; observe submitted work by its original identity") + })?; + let preview = + omnigraph_cluster::prepare_deployment_preview(&request.deployment, owner, &caller) + .await + .map_err(refusal)?; + validate_serving_candidate(&state, owner, preview.serving)?; + let live = state + .routing + .registry + .list() + .into_iter() + .map(|view| (view.key.graph_id.to_string(), Arc::clone(&view.engine))) + .collect(); + omnigraph_cluster::plan_captured_deployment(&request.deployment, owner, &caller, &live) .await - .map_err(refusal)?; - let active = active_result(state, status.lookup.as_ref(), status.result_revision); - Ok(DeploymentStatusResponse { status, active }) + .map(Json) + .map_err(refusal) } #[utoipa::path( post, path = "/cluster/deployments", tag = "cluster", operation_id = "deployment_apply", request_body = DeploymentRequest, - responses((status = 200, body = DeploymentResponse), (status = 400, body = crate::api::ErrorOutput), + responses((status = 200, body = DeploymentResponse), (status = 202, description = "Durably accepted; poll the exact receipt until in_progress is false and inspect active", body = DeploymentResponse), (status = 400, body = crate::api::ErrorOutput), (status = 403, body = crate::api::ErrorOutput), (status = 409, body = crate::api::ErrorOutput), (status = 413, body = crate::api::ErrorOutput), (status = 503, body = crate::api::ErrorOutput)), security(("bearer_token" = [])) @@ -258,7 +386,7 @@ pub(crate) async fn apply( Extension(actor): Extension, Extension(ingress): Extension, request: Result, axum::extract::rejection::JsonRejection>, -) -> Result, ApiError> { +) -> Result<(StatusCode, Json), ApiError> { let caller = caller(&state, &actor)?; let Json(request) = request.map_err(|error| ApiError::bad_request(error.body_text()))?; let owner = admission(&state)?.clone(); @@ -278,22 +406,34 @@ pub(crate) async fn apply( .clone() .try_lock_owned() .map_err(|_| { - ApiError::conflict("a deployment is already running; observe its original identity") + ApiError::conflict("deployment planning or apply is already running; observe submitted work by its original identity") })?; let reservation = state .workload .try_admit(&actor.actor_id, REQUEST_BYTES as u64) .map_err(ApiError::from_workload_reject)?; let operation_state = state.clone(); - state + let running = state.deployments.own(request.deployment_id.clone()); + let (accepted, acceptance) = oneshot::channel(); + let response = state .operations .submit((gate, reservation, ingress), async move { - let result = execute(operation_state, owner, caller, request).await; + let _running = running; + let result = execute(operation_state, owner, caller, request, accepted).await; OwnedResult::from(result) - })? - .result() - .await - .map(Json) + })?; + let result = response.result(); + tokio::pin!(result); + tokio::select! { + biased; + result = &mut result => result.map(|response| (StatusCode::OK, Json(response))), + accepted = acceptance => match accepted { + Ok(deployment) => Ok((StatusCode::ACCEPTED, Json(DeploymentResponse { + deployment, active: false, in_progress: true, + }))), + Err(_) => result.await.map(|response| (StatusCode::OK, Json(response))), + }, + } } async fn execute( @@ -301,6 +441,7 @@ async fn execute( owner: omnigraph_cluster::ClusterAdmission, caller: DeploymentCaller, request: DeploymentRequest, + accepted: oneshot::Sender, ) -> Result { // An original identity is observation-only. Validate immutable input through // the shared executor before returning it, without closing graph admission. @@ -320,6 +461,7 @@ async fn execute( &owner, &BTreeMap::new(), |_, _, _| {}, + |_| {}, &mut effects_started, ) .await @@ -327,6 +469,7 @@ async fn execute( return Ok(DeploymentResponse { active: active_result(&state, Some(&deployment), status.result_revision), deployment, + in_progress: false, }); } // Validate and authorize before pausing healthy graphs. No client-supplied @@ -412,6 +555,9 @@ async fn execute( &owner, &live, |_, _, _| {}, + |deployment| { + let _ = accepted.send(deployment); + }, &mut effects_started, ) .await; @@ -436,6 +582,7 @@ async fn execute( return Ok(DeploymentResponse { active: active_result(&state, Some(&applied), status.result_revision), deployment: applied, + in_progress: false, }); }; let contracts = omnigraph_cluster::applied_deployment_contracts(&owner, result.result_revision) @@ -568,6 +715,7 @@ async fn execute( Ok(DeploymentResponse { active: active_result(&state, Some(&applied), result.result_revision), deployment: applied, + in_progress: false, }) } diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 1a9c80462..202721acd 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -303,6 +303,7 @@ const ALWAYS_FLAT_PATHS: &[&str] = &[ "/readyz", "/graphs", "/graphs/discovery", + "/cluster/plan", "/cluster/deployments", "/cluster/deployments/{id}", "/.well-known/oauth-protected-resource", diff --git a/crates/omnigraph-server/src/http_contract.rs b/crates/omnigraph-server/src/http_contract.rs index b19834616..2edcfda2d 100644 --- a/crates/omnigraph-server/src/http_contract.rs +++ b/crates/omnigraph-server/src/http_contract.rs @@ -63,9 +63,8 @@ pub(crate) fn describe_contract(doc: &mut utoipa::openapi::OpenApi) { if path == "/.well-known/oauth-protected-resource" { continue; } - let protected = path == "/graphs" - || path.starts_with("/graphs/") - || path.starts_with("/cluster/deployments"); + let protected = + path == "/graphs" || path.starts_with("/graphs/") || path.starts_with("/cluster/"); for operation in crate::handlers::path_item_operations_mut(item) { if protected { let mut parameter = Parameter::new(HTTP_API_CONTRACT_HEADER); @@ -95,7 +94,7 @@ pub(crate) fn describe_contract(doc: &mut utoipa::openapi::OpenApi) { ); } } - if path.starts_with("/graphs/{graph_id}/") || path.starts_with("/cluster/deployments") { + if path.starts_with("/graphs/{graph_id}/") || path.starts_with("/cluster/") { for (status, description) in [ ( "429", diff --git a/crates/omnigraph-server/src/ingress.rs b/crates/omnigraph-server/src/ingress.rs index bf144d8b1..e826c4a1b 100644 --- a/crates/omnigraph-server/src/ingress.rs +++ b/crates/omnigraph-server/src/ingress.rs @@ -90,7 +90,10 @@ async fn classify(parts: &mut Parts, route: &str) -> Result BearerTokenHash { deployment::status, deployment::lookup, deployment::apply, + deployment::plan, handlers::server_health, handlers::server_ready, handlers::server_graphs_list, @@ -2443,6 +2444,7 @@ pub fn build_app(state: AppState) -> Router { )); let deployments = Router::new() + .route("/cluster/plan", post(deployment::plan)) .route( "/cluster/deployments", get(deployment::status).post(deployment::apply), diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index 1dcf698a8..09afadca5 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -2122,6 +2122,11 @@ async fn live_query_deployment_closes_admission_before_waiting_for_a_merge() { live_deployment_fixture("merge").await; } +#[tokio::test(flavor = "multi_thread")] +async fn deployment_acceptance_and_polling_track_the_owned_executor_through_activation() { + live_deployment_fixture("acceptance").await; +} + async fn live_deployment_fixture(mode: &str) { use omnigraph_cluster::{CapturedDeployment, DeploymentStatus}; use omnigraph_server::{GraphId, GraphKey, RegistryLookup, ServerConfigMode}; @@ -2141,16 +2146,60 @@ async fn live_deployment_fixture(mode: &str) { )) .unwrap() } + // Existing lifecycle assertions observe completed activation; the acceptance + // case below uses the raw HTTP helper to test each intermediate boundary. + async fn json_response(app: &Router, request: Request) -> (StatusCode, Value) { + let token = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or("") + .to_owned(); + let (code, body) = support::json_response(app, request).await; + if code != StatusCode::ACCEPTED { + return (code, body); + } + assert_eq!(body["in_progress"], true, "{body}"); + let id = body["deployment"]["id"].as_str().unwrap(); + tokio::time::timeout(Duration::from_secs(20), async { + loop { + let (code, result) = support::json_response( + app, + get_request(&format!("/cluster/deployments/{id}"), &token), + ) + .await; + assert_eq!(code, StatusCode::OK, "{result}"); + if result["in_progress"] == false { + return (code, result); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("accepted deployment must finish under its server owner") + } async fn status(app: &Router, id: Option<&str>) -> (DeploymentStatus, bool) { - let path = id - .map(|id| format!("/cluster/deployments/{id}")) - .unwrap_or_else(|| "/cluster/deployments".into()); - let (code, body) = json_response(app, get_request(&path, "operator-token")).await; + let (code, body) = + json_response(app, get_request("/cluster/deployments", "operator-token")).await; assert_eq!(code, StatusCode::OK, "{body}"); - ( - serde_json::from_value(body["status"].clone()).unwrap(), - body["active"].as_bool().unwrap(), - ) + let mut status: DeploymentStatus = serde_json::from_value(body["status"].clone()).unwrap(); + let mut active = body["active"].as_bool().unwrap(); + if let Some(id) = id { + let (code, receipt) = json_response( + app, + get_request(&format!("/cluster/deployments/{id}"), "operator-token"), + ) + .await; + assert_eq!(code, StatusCode::OK, "{receipt}"); + assert!( + receipt.get("status").is_none(), + "exact receipts do not expose cluster status" + ); + status.lookup = Some(serde_json::from_value(receipt["deployment"].clone()).unwrap()); + active = receipt["active"].as_bool().unwrap(); + } + (status, active) } let temp = tempfile::tempdir().unwrap(); @@ -2377,6 +2426,195 @@ async fn live_deployment_fixture(mode: &str) { .unwrap(); fs::write(temp.path().join("people.gq"), "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.bio } }\n").unwrap(); let deployment = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); + if mode == "acceptance" { + use omnigraph_cluster::seams::catalog::{ + DEPLOYMENT_AFTER_ACCEPTANCE, DEPLOYMENT_AFTER_RESULT, DEPLOYMENT_BEFORE_ACCEPTANCE, + }; + struct ScopedHold { + prefix: String, + hold: Arc, + } + impl omnigraph::seams::Behavior for ScopedHold { + fn uninstalling(&self) { + self.hold.release(); + } + } + impl omnigraph::seams::Decide for ScopedHold { + fn decide(&self, name: &'static str) -> omnigraph::seams::Decision { + if std::thread::current() + .name() + .is_some_and(|name| name == self.prefix) + { + omnigraph::seams::Decide::decide(self.hold.as_ref(), name) + } else { + omnigraph::seams::Decision::Pass + } + } + } + async fn reached(hold: &omnigraph::seams::Hold) { + tokio::time::timeout(Duration::from_secs(10), async { + while !hold.reached() { + tokio::task::yield_now().await; + } + }) + .await + .expect("owned executor must reach its next boundary"); + } + let plan_request = |token: &str| { + Request::post("/cluster/plan") + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({"deployment": deployment})).unwrap(), + )) + .unwrap() + }; + let (code, plan) = json_response(&app, plan_request("operator-token")).await; + assert_eq!(code, StatusCode::OK, "{plan}"); + assert_eq!(plan["ok"], true, "{plan}"); + assert!( + plan["input_digest"] + .as_str() + .is_some_and(|digest| !digest.is_empty()) + ); + assert!( + plan["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| change.get("migration").is_some()) + ); + assert_eq!( + json_response(&app, plan_request("reader-token")).await.0, + StatusCode::FORBIDDEN + ); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + before + ); + assert_eq!(original_engine.schema_contract_digest(), original_contract); + assert!(matches!( + state.routing().registry.get(&key), + RegistryLookup::Ready(_) + )); + + let prefix = format!("deployment-{}", initial_status.ledger_id); + let before_acceptance = Arc::new(omnigraph::seams::Hold::default()); + let after_acceptance = Arc::new(omnigraph::seams::Hold::default()); + let after_result = Arc::new(omnigraph::seams::Hold::default()); + let guards = [ + DEPLOYMENT_BEFORE_ACCEPTANCE.install(Arc::new(ScopedHold { + prefix: prefix.clone(), + hold: Arc::clone(&before_acceptance), + })), + DEPLOYMENT_AFTER_ACCEPTANCE.install(Arc::new(ScopedHold { + prefix: prefix.clone(), + hold: Arc::clone(&after_acceptance), + })), + DEPLOYMENT_AFTER_RESULT.install(Arc::new(ScopedHold { + prefix: prefix.clone(), + hold: Arc::clone(&after_result), + })), + ]; + let request_app = app.clone(); + let request = submit(&id, &deployment, "operator-token"); + let (response, mut received) = tokio::sync::oneshot::channel(); + let (stop, stopped) = tokio::sync::oneshot::channel(); + let server = std::thread::spawn(move || { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name(prefix) + .enable_all() + .build() + .unwrap() + .block_on(async move { + let reply = support::json_response(&request_app, request).await; + response.send(reply).unwrap(); + let _ = stopped.await; + }); + }); + reached(&before_acceptance).await; + assert!(matches!( + received.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + let path = format!("/cluster/deployments/{id}"); + let (code, preparing) = json_response(&app, get_request(&path, "operator-token")).await; + assert_eq!(code, StatusCode::OK, "{preparing}"); + assert_eq!(preparing["deployment"]["status"], "not_recorded"); + assert_eq!(preparing["in_progress"], true); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + before + ); + before_acceptance.release(); + reached(&after_acceptance).await; + let (code, accepted) = tokio::time::timeout(Duration::from_secs(5), received) + .await + .expect("HTTP acceptance must not wait for graph effects or activation") + .unwrap(); + assert_eq!(code, StatusCode::ACCEPTED, "{accepted}"); + assert_eq!(accepted["deployment"]["status"], "outstanding"); + assert_eq!(accepted["deployment"]["id"], id); + assert_eq!(accepted["deployment"]["input_digest"], plan["input_digest"]); + assert_eq!(accepted["active"], false); + assert_eq!(accepted["in_progress"], true); + let (_, running) = json_response(&app, get_request(&path, "operator-token")).await; + assert_eq!(running["deployment"]["status"], "outstanding"); + assert_eq!(running["deployment"]["input_digest"], plan["input_digest"]); + assert_eq!(running["in_progress"], true); + assert!(running.get("status").is_none()); + assert_eq!( + json_response(&app, submit(&id, &deployment, "operator-token")) + .await + .0, + StatusCode::CONFLICT + ); + assert_eq!( + json_response(&app, plan_request("operator-token")).await.0, + StatusCode::CONFLICT + ); + after_acceptance.release(); + reached(&after_result).await; + let (_, completing) = json_response(&app, get_request(&path, "operator-token")).await; + assert_eq!(completing["deployment"]["status"], "complete"); + assert_eq!(completing["active"], false); + assert_eq!( + completing["in_progress"], true, + "durable result precedes activation" + ); + after_result.release(); + let final_receipt = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let (_, receipt) = json_response(&app, get_request(&path, "operator-token")).await; + if receipt["in_progress"] == false { + break receipt; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(final_receipt["active"], true, "{final_receipt}"); + assert_eq!(final_receipt["deployment"]["result"]["id"], id); + assert_eq!( + final_receipt["deployment"]["result"]["input_digest"], + plan["input_digest"] + ); + assert!(!state.operation_runtime().snapshot().closed); + assert!( + !before_acceptance.timed_out() + && !after_acceptance.timed_out() + && !after_result.timed_out() + ); + stop.send(()).unwrap(); + tokio::task::spawn_blocking(move || server.join().unwrap()) + .await + .unwrap(); + drop(guards); + return; + } let (code, _) = json_response(&app, submit(&id, &deployment, "reader-token")).await; assert_eq!(code, StatusCode::FORBIDDEN); assert_eq!( @@ -2522,6 +2760,18 @@ async fn live_deployment_fixture(mode: &str) { !status(&app, Some(&boot_id)).await.1, "transitioning bindings are not active" ); + let (_, preparing) = json_response( + &app, + get_request(&format!("/cluster/deployments/{id}"), "operator-token"), + ) + .await; + assert_eq!(preparing["deployment"]["status"], "not_recorded"); + assert_eq!(preparing["in_progress"], true); + assert!( + !observer.is_finished(), + "HTTP acceptance must await durable recording" + ); + let (code, _) = json_response(&app, get_request("/graphs/peer/snapshot", "operator-token")).await; assert_eq!(code, StatusCode::OK, "unaffected graph keeps serving"); @@ -2967,7 +3217,8 @@ async fn live_deployment_fixture(mode: &str) { assert_eq!(body["served_graph_count"], 0); // Management-policy handoff takes effect on all cloned routers. The old - // administrator cannot deploy or list; the newly granted one can observe. + // administrator cannot deploy or list; its own receipt remains observable. + // The newly granted administrator can observe the current cluster. fs::write( temp.path().join("cluster-policy.yaml"), format!( @@ -2978,13 +3229,32 @@ async fn live_deployment_fixture(mode: &str) { .unwrap(); let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let (next, _) = status(&app, None).await; - let (code, body) = json_response( + let handoff_id = next.next_deployment_id(); + let (code, body) = json_response(&app, submit(&handoff_id, &candidate, "operator-token")).await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(body["active"], true); + let (code, own_receipt) = json_response( &app, - submit(&next.next_deployment_id(), &candidate, "operator-token"), + get_request( + &format!("/cluster/deployments/{handoff_id}"), + "operator-token", + ), ) .await; - assert_eq!(code, StatusCode::OK, "{body}"); - assert_eq!(body["active"], true); + assert_eq!(code, StatusCode::OK, "{own_receipt}"); + assert_eq!(own_receipt["deployment"], body["deployment"]); + assert_eq!(own_receipt["in_progress"], false); + assert!(own_receipt.get("status").is_none()); + assert_eq!( + json_response( + &app, + get_request(&format!("/cluster/deployments/{boot_id}"), "operator-token") + ) + .await + .0, + StatusCode::FORBIDDEN, + "storage-owner attribution is not the authenticated receipt owner" + ); assert_eq!( json_response(&app, get_request("/cluster/deployments", "operator-token")) .await diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index f5be1be9b..36fe07567 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -196,6 +196,7 @@ const EXPECTED_PATHS: &[&str] = &[ "/readyz", "/graphs", "/graphs/discovery", + "/cluster/plan", "/cluster/deployments", "/cluster/deployments/{id}", "/graphs/{graph_id}/snapshot", @@ -2213,6 +2214,7 @@ async fn multi_mode_openapi_keeps_management_paths_flat() { "/healthz", "/graphs", "/graphs/discovery", + "/cluster/plan", "/cluster/deployments", "/cluster/deployments/{id}", "/.well-known/oauth-protected-resource", @@ -2251,6 +2253,7 @@ async fn multi_mode_openapi_prefixes_operation_ids_with_cluster() { | "/readyz" | "/graphs" | "/graphs/discovery" + | "/cluster/plan" | "/cluster/deployments" | "/cluster/deployments/{id}" | "/.well-known/oauth-protected-resource" @@ -2322,6 +2325,7 @@ async fn multi_mode_openapi_declares_graph_id_path_parameter() { "/healthz", "/graphs", "/graphs/discovery", + "/cluster/plan", "/cluster/deployments", "/cluster/deployments/{id}", "/.well-known/oauth-protected-resource", @@ -2453,9 +2457,8 @@ fn openapi_describes_api_contract_admission_and_response_identity() { .filter(|parameter| parameter["name"] == HTTP_API_CONTRACT_HEADER) .collect(); let oauth = path == "/.well-known/oauth-protected-resource"; - let protected = path == "/graphs" - || path.starts_with("/graphs/") - || path.starts_with("/cluster/deployments"); + let protected = + path == "/graphs" || path.starts_with("/graphs/") || path.starts_with("/cluster/"); if protected { assert_eq!(contract_parameters.len(), 1, "{method} {path}"); let parameter = contract_parameters[0]; diff --git a/crates/omnigraph/src/db/omnigraph.rs b/crates/omnigraph/src/db/omnigraph.rs index a43773db1..a82d1bb68 100644 --- a/crates/omnigraph/src/db/omnigraph.rs +++ b/crates/omnigraph/src/db/omnigraph.rs @@ -223,6 +223,18 @@ struct HandleSchemaView { schema_identity_domain: String, } +impl HandleSchemaView { + fn contract_digest(&self) -> SchemaContractDigest { + use sha2::Digest; + SchemaContractDigest { + source_hash: format!("{:x}", sha2::Sha256::digest(self.source.as_bytes())), + schema_ir_hash: self.schema_ir_hash.clone(), + schema_identity_domain: self.schema_identity_domain.clone(), + schema_identity_version: super::schema_state::SCHEMA_IDENTITY_VERSION, + } + } +} + /// Top-level handle to an Omnigraph database. /// /// An Omnigraph is a Lance-native graph database with git-style branching. @@ -788,15 +800,7 @@ impl Omnigraph { /// This does not refresh storage; callers comparing current durable /// authority must open or refresh under their writer-exclusion boundary. pub fn schema_contract_digest(&self) -> SchemaContractDigest { - use sha2::Digest; - - let view = self.schema_view.load(); - SchemaContractDigest { - source_hash: format!("{:x}", sha2::Sha256::digest(view.source.as_bytes())), - schema_ir_hash: view.schema_ir_hash.clone(), - schema_identity_domain: view.schema_identity_domain.clone(), - schema_identity_version: super::schema_state::SCHEMA_IDENTITY_VERSION, - } + self.schema_view.load().contract_digest() } /// Publish one coherent handle-local projection after the durable schema @@ -1120,6 +1124,17 @@ impl Omnigraph { schema_apply::plan_schema(self, desired_schema_source).await } + /// Describe schema evolution from one exact handle-local accepted contract. + /// This performs no storage I/O or gate acquisition. It is advisory: branch, + /// physical and current durable eligibility are checked again before apply. + pub fn plan_schema_at_contract( + &self, + desired_schema_source: &str, + expected: &SchemaContractDigest, + ) -> Result { + schema_apply::plan_schema_at_contract(self, desired_schema_source, expected) + } + pub async fn preview_schema_apply( &self, desired_schema_source: &str, diff --git a/crates/omnigraph/src/db/omnigraph/schema_apply.rs b/crates/omnigraph/src/db/omnigraph/schema_apply.rs index 1edda0727..cabdf0b43 100644 --- a/crates/omnigraph/src/db/omnigraph/schema_apply.rs +++ b/crates/omnigraph/src/db/omnigraph/schema_apply.rs @@ -90,6 +90,25 @@ pub(super) async fn plan_schema( .map_err(|err| OmniError::manifest(err.to_string())) } +pub(super) fn plan_schema_at_contract( + db: &Omnigraph, + desired_schema_source: &str, + expected: &SchemaContractDigest, +) -> Result { + let view = db.schema_view.load(); + if &view.contract_digest() != expected { + return Err(OmniError::manifest_conflict( + "handle schema differs from the observed deployment contract", + )); + } + let accepted_ir = view.catalog.bound_schema_ir().ok_or_else(|| { + OmniError::manifest_internal("accepted catalog carries no bound SchemaIR") + })?; + let desired_ir = resolve_desired_schema_ir(accepted_ir, desired_schema_source)?; + plan_schema_migration(accepted_ir, &desired_ir) + .map_err(|error| OmniError::manifest(error.to_string())) +} + /// The accepted IR a plan is made against: the contract of the live view the /// handle resolves now (probe, refresh when the manifest moved), not the /// handle's warm ArcSwap catalog. diff --git a/crates/omnigraph/tests/schema_apply.rs b/crates/omnigraph/tests/schema_apply.rs index 1369856b4..3209149d5 100644 --- a/crates/omnigraph/tests/schema_apply.rs +++ b/crates/omnigraph/tests/schema_apply.rs @@ -92,6 +92,17 @@ async fn plan_schema_reports_supported_additive_change() { let preview = db.preview_schema_apply(&desired).await.unwrap(); assert_eq!(preview.catalog.node_types.len(), 2); + + let contract = db.schema_contract_digest(); + // The served observational planner uses the accepted in-memory contract; + // it must neither reopen this root nor wait on a schema gate. + let parked = dir.path().with_extension("parked"); + std::fs::rename(dir.path(), &parked).unwrap(); + let observed = db.plan_schema_at_contract(&desired, &contract).unwrap(); + std::fs::rename(&parked, dir.path()).unwrap(); + assert_eq!(observed, plan); + db.apply_schema(&desired).await.unwrap(); + assert!(db.plan_schema_at_contract(&desired, &contract).is_err()); } #[tokio::test] diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index 337e3dbe4..7771c84fd 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -205,6 +205,23 @@ to the running owner. The bearer actor needs applied cluster `ConfigManage` and the graph permissions required by the shared executor. Graph-scoped data tokens cannot deploy. One process gate owns execution and activation after caller disconnect; no polling worker or second storage-writing CLI is involved. +POST returns 202 after the outstanding ledger CAS confirms acceptance. The CLI +then polls exact-ID GET; its wait deadline does not cancel the owned operation. +Exact GET returns the receipt, `active`, and `in_progress` derived from the +current process owner. A complete receipt can precede activation. A missing +receipt during preparation is not proof of refusal, and an outstanding receipt +without a live owner requires recovery. + +Exact receipt reads also permit the authenticated initiating actor recorded in +that receipt, even after management permission is revoked. This narrow observation +cannot disclose current inventory, next sequence or lock identity and grants no +effect permission. Aggregate status still needs current management permission. + +`POST /cluster/plan` shares captured-input/current-policy validation and resource +diffs. It plans migrations against a coherent accepted schema view without graph +opens, a schema gate, writer admission, graph closure or ledger writes. The plan +reports its observed base and destructive root removal; physical execution checks +remain the post-drain executor's responsibility. The controller validates captured input, current authorization and the serving projection before closing admission, without opening affected graphs or acquiring diff --git a/docs/dev/testing.md b/docs/dev/testing.md index dfaa193ff..7ca194fb5 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -137,11 +137,15 @@ scope, schema identity and candidate bounds; disconnected writes and stream bytes, and `boot_settings`/`mcp` check authorized availability. The same owners cover coherent schema/query batch activation; these assertions are not generic native settlement. Server `boot_settings` exercises authenticated -submission, parked requests, caller disconnect, pre-effect refusal and historical -ID observation. CLI `cli_cluster_e2e` proves one PID/listener survives schema/query +submission, parked requests, caller disconnect, pre-effect refusal, durable +acceptance before completion, activation-in-progress observation and exact receipt +access after management handoff. CLI `cli_cluster` owns submit-once polling, +lost-response observation, terminal outcomes and caller timeout without replay. +CLI `cli_cluster_e2e` proves one PID/listener survives schema/query replacement, graph addition, policy grant/revocation and management handoff. Extend that same journey for graph deletion, proving target storage/history -removal, peer preservation and unchanged PID/listener. It checks +removal, peer preservation and unchanged PID/listener, including a served deletion +preview followed by `--no-wait` submission and exact-ID `status --wait`. It checks the achieved configuration again after restart. The local journey also exercises counted embedding-provider replacement, external-Blob admission changes, catalog integrity reporting, schema-drift refusal and missing-root refusal. S3/Azure wrappers diff --git a/docs/rfcs/0052-managed-control-plane-cli.md b/docs/rfcs/0052-managed-control-plane-cli.md index 33c73d4f3..4fde2f3ad 100644 --- a/docs/rfcs/0052-managed-control-plane-cli.md +++ b/docs/rfcs/0052-managed-control-plane-cli.md @@ -7,7 +7,7 @@ implementation: complete authors: - andrew created: 2026-09-05 -updated: 2026-09-16 +updated: 2026-10-06 discussion: https://github.com/ModernRelay/omnigraph/pull/629 supersedes: [] superseded_by: [] @@ -23,9 +23,10 @@ blocked_on: [] ## Summary -Add an HTTP client for the managed Intent API to the existing CLI. A folder's -`.omnigraph/context` selects managed cluster operations; absence preserves -the existing direct Core path. Managed authentication establishes identity, +The explicit `managed` command family addresses the managed Intent API. A +folder's `.omnigraph/context` selects the service and cluster for bound +operations. Core `cluster` commands never inspect that context. +Managed authentication establishes identity, and current permissions authorize execution of an exact saved plan. There is no separate managed approval workflow. Engine behavior and storage formats are unchanged. @@ -44,11 +45,11 @@ rather than a local command refactor. ```sh omnigraph login --api https://control.example omnigraph use CLUSTER_ID --api https://control.example -omnigraph cluster plan --config . --json -omnigraph cluster apply --config . --plan PLAN_RUN_ID --json -omnigraph cluster status --config . --json -omnigraph cluster history --config . --json -omnigraph cluster cancel RUN_ID --config . --json +omnigraph managed plan --config . --json +omnigraph managed apply --config . --plan PLAN_RUN_ID --json +omnigraph managed status --config . --json +omnigraph managed history --config . --json +omnigraph managed cancel RUN_ID --config . --json omnigraph logout --api https://control.example ``` @@ -59,11 +60,11 @@ does not commit files, upload a bundle, silently replan, or ask for another approval. Current permissions, binding, digests, and lease are checked by the API and execution authority is independently checked by the executor. -`--direct` explicitly selects existing Core behavior for `--config`; its -`cluster.yaml` still owns the root. There is no root substitution. Without -that flag, malformed context, unknown context version, unsupported managed -commands, provider/API errors, and permission refusals fail closed. No such -failure may invoke Core or consult graph-root credentials. +The command family selects authority before context discovery. `cluster` uses +Core configuration or its explicit server/root target; `managed` uses only the +service API and rejects `--direct`. Malformed context, unknown context version, +provider/API errors and permission refusals fail closed. No managed failure may +invoke Core or consult graph-root credentials. `--no-wait` returns the accepted run envelope. Otherwise the CLI polls every two seconds for up to 300 seconds; `--timeout` selects 1–3,600 seconds. Timeout @@ -86,7 +87,7 @@ serialized by the API. It never cancels accepted effects through lease expiry. The versioned, secret-free context is at most 16 KiB and lives only in the selected config directory; parent directory search is not performed. Context must be a regular file and neither it nor `.omnigraph` may be a symbolic link; -a dangling link is a refusal, never absence selecting the Core: +a dangling link is a refusal, never permission to select Core: ```yaml version: 1 @@ -132,22 +133,22 @@ fork, cloud-only correctness fix, duplicated lock, or new writer fence. Existing profiles/servers/clusters config and `login SERVER --token` / `logout SERVER` remain intact. The new `--api` mode is mutually exclusive -with legacy server login. Existing direct commands without context retain -their flags and behavior. Managed-only flags without context refuse. -Unsupported managed commands refuse before direct execution. - -No stored graph or server API format changes. Removing the context or using -`--direct` restores the existing direct path intentionally. Older CLI builds -do not understand this context contract and must not be presented as safe -managed clients; deploy a compatible CLI and API together. +with named-server login. Core commands retain their own addressing regardless +of folder context. Managed commands and their arguments live only under +`managed`; former managed `cluster` spellings are not aliases. + +No stored graph or server API format changes. Bound managed commands refuse +without context; its absence cannot select direct execution. Deploy a compatible +CLI and API together. The server SDK and Python SDK gain no new authority or dependencies. ## Alternatives - Raw HTTP keeps the current gap in bounded waits, identity storage, and context isolation and makes every script reproduce the managed contract. -- A second top-level verb family avoids dispatch integration but contradicts - the existing cluster command model and duplicates discoverability/docs. +- Ambient context dispatch makes identical cluster commands select different + protocols and exposes mutually incompatible flags in one parser. Explicit + command families keep that choice visible without another mode flag. - Inferring managed mode from storage roots or existing server aliases risks forwarding the wrong credential; explicit context and origin binding win. - Requiring browser sign-in after every short access lifetime interrupts @@ -203,3 +204,9 @@ separate increment; this decision authorizes control-plane operations only. managed-store editing and provisioning to this original increment, and links RFC 0061 as the owner of those additional client commands. Its authentication, folder context, direct routing and exact-plan execution contract is unchanged. + +2026-10-06: The maintainer requested explicit command namespaces. Summary, +User behavior, Design, Compatibility and Alternatives now place service commands +under `managed` and remove ambient interception of Core `cluster` commands. +Managed data-command context and service protocols remain unchanged. Existing +CLI owners cover namespace refusal and Core isolation from malformed context. diff --git a/docs/rfcs/0053-offline-data-token-verification.md b/docs/rfcs/0053-offline-data-token-verification.md index 85ef0efbc..5399ffbcc 100644 --- a/docs/rfcs/0053-offline-data-token-verification.md +++ b/docs/rfcs/0053-offline-data-token-verification.md @@ -7,7 +7,7 @@ implementation: complete authors: - andrew created: 2026-09-05 -updated: 2026-10-05 +updated: 2026-10-06 discussion: https://github.com/ModernRelay/omnigraph/pull/633 supersedes: [] superseded_by: [] @@ -208,7 +208,7 @@ Cedar/default-deny semantics. ### Managed CLI data access The companion issuance API supplies a data endpoint and signed credential. -`omnigraph cluster token --config DIR --ttl 1h` requests a version-2 identity +`omnigraph managed token --config DIR --ttl 1h` requests a version-2 identity credential. TTL defaults to 3,600 seconds and must be 60–86,400 seconds. Issuance adds no policy permissions. `--clear` instead forgets the selected cluster's local data credential. Token command output is @@ -282,7 +282,7 @@ authentication work. No deny-list exception is requested. Existing deployments without trust are unchanged. New credentials do not work on old servers; they cannot degrade into static credentials. Version-1 restricted tokens, their public data types, and CLI `--actions` are -removed. Old caches refuse without replacement; `cluster token` explicitly +removed. Old caches refuse without replacement; `managed token` explicitly obtains a current credential. Removing the trust flag disables signed credentials deliberately. The trust supplier must not leave a token-only deployment in unauthenticated mode after rollback. No graph storage, ledger, manifest, policy-file, or managed-marker format @@ -422,3 +422,7 @@ focused regressions and provider/KMS interoperability checks above. Existing engine and storage behavior was preserved. The later CLI diagnostic-redaction fix is covered by its focused regression and does not change the successfully qualified data path. + +2026-10-06: Credential-administration examples now use `managed token` under +the explicit service namespace from [RFC 0052](0052-managed-control-plane-cli.md). +Credential format, acquisition and authorization are unchanged. diff --git a/docs/rfcs/0061-managed-cluster-lifecycle.md b/docs/rfcs/0061-managed-cluster-lifecycle.md index fd3591048..d450fcfa5 100644 --- a/docs/rfcs/0061-managed-cluster-lifecycle.md +++ b/docs/rfcs/0061-managed-cluster-lifecycle.md @@ -7,7 +7,7 @@ implementation: complete authors: - Andrew created: 2026-09-06 -updated: 2026-09-06 +updated: 2026-10-06 discussion: null supersedes: [] superseded_by: [] @@ -37,21 +37,21 @@ operations rather than local storage effects or unbounded polling. ## User and operational behavior -`cluster create NAME --api ORIGIN --config DIR` requests an empty managed +`managed create NAME --api ORIGIN --config DIR` requests an empty managed cluster and binds the config directory to the returned identity. An existing context is never overwritten. `--no-wait` returns the accepted operation; otherwise the command waits up to 300 seconds, configurable from 1–3600 using `--timeout`. Readiness means the API's ordinary plan/apply and serving witness completed; acceptance alone does not mean ready or network reachable. -`cluster push --expected-revision COMMIT --message MESSAGE` captures only +`managed push --expected-revision COMMIT --message MESSAGE` captures only `cluster.yaml` and referenced schema, stored-query and policy files from the selected folder and uploads them with a conditional managed Git revision. -It does not apply. The caller next uses `cluster plan --rev COMMIT` and exact -`cluster apply --plan RUN`. No Git provider credentials or bucket access are +It does not apply. The caller next uses `managed plan --rev COMMIT` and exact +`managed apply --plan RUN`. No Git provider credentials or bucket access are required by the CLI. -`cluster delete --incarnation INCARNATION` requires exact targeting and current +`managed delete --incarnation INCARNATION` requires exact targeting and current scoped delete permission. `--retention-seconds` accepts 0–2,592,000 and defaults to 86,400. With a nonzero interval, ordinary waiting stops at the confirmed tombstone and reports its deadline and operation ID while preserving canonical @@ -59,12 +59,12 @@ state `running`; zero waits toward final purge. `--no-wait` and `--timeout` provide the same bounded alternatives as creation. There is no additional approval or confirmation workflow. -`cluster undo-delete --incarnation INCARNATION --deletion-id OPERATION` requests +`managed undo-delete --incarnation INCARNATION --deletion-id OPERATION` requests undo of that exact deletion. The service requires current delete, plan and apply permissions, checks the retained undo window, and restores through the normal plan/apply workflow. The CLI does not recreate a retired identity. -`cluster status --operation OPERATION` reads a service operation. `--api ORIGIN` +`managed operation OPERATION` reads a service operation. `--api ORIGIN` allows recovery before any folder context exists; otherwise the exact folder context selects the origin and cluster. `--wait --timeout SECONDS` explicitly polls to a canonical outcome. A local deadline returns exit 5 with the latest @@ -120,9 +120,9 @@ The Lance domain map therefore has no changed substrate surface. ## Compatibility and reversibility -Folder context remains version 1. Older clients retain plan/apply/status and -direct behavior; they cannot execute the new commands. Service refusal does -not trigger a legacy fallback. Removing this CLI surface does not cancel any +Folder context remains version 1. These commands use the explicit `managed` +namespace; former managed `cluster` spellings are refused. Service refusal +never invokes Core execution. Removing this CLI surface does not cancel any accepted service operation. Private routing still needs its explicit connection; public routing and a console are outside this RFC. @@ -170,3 +170,8 @@ CLI contract and compatibility. 2026-09-06: Completed the bounded client implementation and owned local regressions. Managed upload is qualified on Unix; other platforms refuse it. The companion service owns cell qualification and cloud rollout separately. + +2026-10-06: The maintainer requested the explicit `managed` namespace. User +behavior and Compatibility now use those commands and a separate `managed +operation ID` for lifecycle observation. Service APIs, identity checks, pending +request custody and bounded waits are unchanged. diff --git a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md index 85a6ac47c..5849500d0 100644 --- a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md +++ b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md @@ -7,7 +7,7 @@ implementation: complete authors: - andrew created: 2026-09-09 -updated: 2026-10-05 +updated: 2026-10-06 discussion: https://github.com/ModernRelay/omnigraph/pull/691 supersedes: [] superseded_by: [] @@ -48,7 +48,7 @@ returned by the existing graph catalog. Normal credential acquisition requests version 2 without graph/actions: ```text -omnigraph cluster token --ttl 3600 +omnigraph managed token --ttl 3600 omnigraph --graph knowledge query list_people omnigraph graphs list ``` @@ -60,7 +60,7 @@ select the new minimal endpoint with No JWT-shaped static token is reinterpreted to select a different command. CLI `--actions` is removed. Unsupported caches refuse without automatic -replacement; `cluster token` explicitly requests a current identity credential. +replacement; `managed token` explicitly requests a current identity credential. Permission denial never switches credentials or storage addressing. `GET /graphs/discovery` returns only @@ -176,7 +176,7 @@ documented trust boundary, never an automatic fallback from identity denial. ## Compatibility and reversibility Version-1 tokens, cache entries and public claim/grant types are unsupported; -CLI `--actions` is removed. A new explicit `cluster token` request replaces an +CLI `--actions` is removed. A new explicit `managed token` request replaces an old cache. Existing static/unauthenticated modes and direct APIs retain their behavior. Authorized entry points remain additive. The public exhaustive policy enums gain `ConfigManage`/`Cluster` variants; @@ -243,6 +243,10 @@ of a particular deployment remain separate from the accepted library contract. ## Decision log +- 2026-10-06: Credential-administration examples now use `managed token` under + the explicit service namespace from [RFC 0052](0052-managed-control-plane-cli.md). + Credential format, acquisition and authorization are unchanged. + - 2026-10-05: The maintainer requested removal of compatibility interfaces. This replaces Summary's retained version-1 profile, User behavior's `--actions`/restricted-cache and filtered-discovery sentences, Credential diff --git a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md index 71c04b629..e3c5e11ad 100644 --- a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md +++ b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md @@ -562,8 +562,9 @@ independently of configuration discovery or current input files. | `omnigraph --cluster ROOT cluster status [--deployment-id ID]` | Bounded authorized lookup; return incarnation, next sequence, outstanding full ID and lock identity without graph opens or effects. | | `omnigraph cluster plan --config DIR` | Observe without a writer lock; derive migration steps from the same captured preparation with the intended actor. | | `omnigraph cluster apply --config DIR [--deployment-id ID]` | Bootstrap or execute under direct exclusive admission; allocate and emit the original ID unless supplied. | -| `omnigraph cluster apply --server URL --config DIR [--deployment-id ID]` | Submit captured input to the running owner; keep the PID/listener and activate coherent schema/query/policy/provider/Blob bindings and graph additions. | -| `omnigraph cluster status --server URL [--deployment-id ID]` | Authorized ledger lookup plus an observation of whether that result is active in this process. | +| `omnigraph cluster plan --server URL --config DIR` | Observe changes and schema migrations against captured applied state without closing serving admission or taking a schema gate. | +| `omnigraph cluster apply --server URL --config DIR [--deployment-id ID] [--no-wait] [--timeout SECONDS]` | Submit once to the running owner; wait for durable acceptance, then observe that ID until convergence and activation unless `--no-wait` is set. | +| `omnigraph cluster status --server URL [--deployment-id ID [--wait [--timeout SECONDS]]]` | Observe cluster status or an exact deployment receipt; optional waiting polls the original ID without executing it. | | `omnigraph --cluster ROOT cluster apply --deployment-id ID --writers-stopped` | Reconcile that original deployment only; never submit, use new files or replay schema effects. A terminal result is lookup-only. | | `omnigraph --cluster ROOT cluster force-unlock LOCK_ID` | Exact-ID release under the operator-exclusion/quiescence procedure; outstanding evidence remains intact. | @@ -574,8 +575,10 @@ fence and does not replace lock admission or Azure's wrapper. Conversion and reconciliation require it; read-only status does not. Routine apply does not need manual status-and-copy, and may not allocate a new ID when resolving a previous unknown result. Missing/expired original lookup never creates a replacement. -Managed remote runs keep their existing API; these flags do not manufacture a -remotely authenticated identity. Status needs no lock and cannot authorize a writer. +Hosted service workflows use the explicit `managed` command family and retain +their separate control-plane API. Folder context never redirects `cluster` +commands to that API. These flags do not manufacture a remotely authenticated +identity. Status needs no lock and cannot authorize a writer. Conversion preserves graph data, native identities, branches/history, applied resource digests, policy/provider bindings and existing audit/approval facts. @@ -652,15 +655,25 @@ root-disjointness and server-safe projection checks. A terminal partial result publishes only achieved graph state; serving must install achieved management policy too, so a policy handoff cannot strand the corrective deployment. -Plan always observes without writer admission. It captures source bytes and -the ledger once, and derives migration steps from shared effect-free preparation -for that input and actor. Missing retained graphs, schema drift, unsupported -migrations and branch restrictions are errors, not successful plans with a warning. Apply repeats -those checks under writer admission; a plan is not a reservation or permission -to replay a deployment. +Plan always observes without acquiring writer admission. Direct planning uses +shared effect-free preparation. Served planning captures source bytes, the ledger +and current runtime contracts, checks current authorization and deployment scope, +and derives schema migration steps from one accepted schema view. It never opens +a second engine or takes the exclusive schema gate. Relevant unavailable graphs, +schema drift, unsupported migrations and branch restrictions refuse. Physical +rewrite, creation and deletion eligibility remain the executor's post-drain checks. +Both plans include the observed ledger CAS, input digest, resource changes and +explicit managed-root deletion including history. They write no ledger or plan +resource and consume no deployment sequence. Apply checks its fresh accepted +base; an observed plan is neither a reservation nor permission to replay work. ## Online deployment +`POST /cluster/plan` accepts a bounded captured bundle and returns the observed +plan under current management and graph permissions. It uses the ordinary read +lane and resource limits, validates the serving projection, and never closes graph +admission or creates a deployment record. + `POST /cluster/deployments` accepts a client-known `deployment_id` and one bounded `CapturedDeployment`. The CLI discovers the v0.12 contract, reads `GET /cluster/deployments`, captures local source bytes bound to that authenticated @@ -670,6 +683,38 @@ storage lookup. The server independently revalidates canonical ownership. The CL does not write the ledger. `GET /cluster/deployments/{id}` observes the original outcome; disconnection never authorizes a new ID or replay. +POST returns `202` only after the outstanding ledger CAS confirms acceptance, +with `{deployment, active, in_progress}`. A pre-effect refusal still returns its +error; an existing original ID is lookup-only. Returning acceptance releases the +HTTP observer, while the existing owned executor retains completion and activation. +No job queue, background poller or second durable operation record is introduced. + +The CLI waits by default. `--no-wait` returns after confirmed acceptance, which +can itself require waiting for drain and preparation. `--timeout` bounds the +entire caller wait (default 300 seconds, maximum 3600), including acceptance; it +never cancels work. Each GET is separately bounded, and transient observation +failures may retry only GET within the same total budget. POST is sent at most +once. A lost POST response may be resolved through the original ID. Apply also +checks the immutable input digest on every outstanding or completed receipt; +an old ID can never confirm a different submitted bundle. Timeout +returns exit 5 and structured original-ID/last-observation output; it does not +assert deployment failure. `status --deployment-id ID --wait` resumes observation. + +Exact GET returns only `{deployment, active, in_progress}`. `in_progress` derives +from the current process owner of that exact ID, not another durable state machine. +`NotRecorded` with a live owner means preparation; `Complete` without activation +can still be in progress. Without that owner, an outstanding record requires +recovery and an inactive complete result is terminal for this observation. +Expired receipts, identity mismatch and different-ledger results terminate waiting +without replay. A process-local observation is not proof of native-I/O quiescence. + +Current management permission grants ordinary status access. A still-authenticated +initiator may additionally read its own exact durable receipt after a deployment +revokes its management permission, identified by recorded authenticated authority. +This grants neither current cluster inventory/sequence/lock observations nor any +write permission. A foreign, missing or expired receipt cannot establish that +exception; authentication and current permission remain required there. + The server resolves a trusted bearer actor, refuses graph-scoped data tokens, checks applied `ConfigManage`, and delegates exact graph authorization and input validation to the v2 executor. One process gate owns the complete operation, @@ -1226,6 +1271,12 @@ Before enabling an affected increment, its owners must implement and qualify: ## Decision log +- 2026-10-06: The maintainer approved served observational planning, durable + acceptance followed by bounded CLI observation, and explicit `managed` command + routing. Online deployment and CLI sections now define exact receipt reads for + initiating actors, process-owned progress, caller wait outcomes, and the shared + observed-plan limits. No new durable job authority is added. + - 2026-10-06: Review corrections amend the serving-transition and Online deployment sections: close and drain before full engine preparation, retain owned completion beyond the drain deadline, and qualify activation against diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index d84c85716..18fa823e2 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -22,11 +22,11 @@ Run `omnigraph --help` for the flags supported by your installed version. The [CLI reference](reference.md) summarizes addressing, commands, configuration, and output formats. -For a managed cluster, first select it with `use` and cache an identity -credential with `cluster token`. Run `graphs list` to discover graph names, -then `query` or `mutate` from that folder with an explicit `--graph`. Applied -Cedar policy determines your permissions. See [managed data access](managed-data.md) -for legacy restricted credentials, expiry, offline operation, and clearing. +For a managed cluster, select it with `use`. The CLI acquires identity credentials +when needed. Run `graphs list` to discover graph names, then `query` or `mutate` +from that folder with an explicit `--graph`. Applied Cedar policy determines your +permissions. See [managed data access](managed-data.md) for expiry, offline +operation and credential clearing. ## Create, load, and query a graph @@ -105,7 +105,7 @@ line in the source applies after `--set`, so the file wins. run (`--store`) accepts them with no effect. - `changes poll --set`: sent as `set=NAME=VALUE` on the change feed read, with the same validation and the same absence of effect. -- `load --set` and `ingest --set`: a direct run (`--store`) applies them to the +- `load --set`: a direct run (`--store`) applies them to the staged write (`--set stage_write_concurrency=16`); a served run refuses any `--set` before sending, since the served load routes carry no `settings` field. @@ -182,8 +182,8 @@ from the token; clients cannot override it with `--as`. ## Manage a cluster -Without a managed context, cluster commands read a directory containing -`cluster.yaml`: +Self-hosted `cluster` commands read a directory containing `cluster.yaml`, or address +a running server explicitly. They ignore managed folder context: ```bash omnigraph cluster validate --config ./company-brain @@ -204,22 +204,22 @@ your config directory: ```bash omnigraph login --api https://control.example omnigraph use CLUSTER_ID --api https://control.example --config ./company-brain -omnigraph cluster plan --config ./company-brain --json > plan.json -omnigraph cluster apply --config ./company-brain --plan "$(jq -r .data.run_id plan.json)" --json -omnigraph cluster status --config ./company-brain --json -omnigraph cluster history --config ./company-brain --json +omnigraph managed plan --config ./company-brain --json > plan.json +omnigraph managed apply --config ./company-brain --plan "$(jq -r .data.run_id plan.json)" --json +omnigraph managed status --config ./company-brain --json +omnigraph managed history --config ./company-brain --json omnigraph logout --api https://control.example ``` Commit and push external configuration before planning. The API plans its bound head, or the pushed revision selected with `--rev`. Apply uses the exact saved plan and your current permissions. To release an unused plan, run -`omnigraph cluster cancel PLAN_RUN_ID --config ./company-brain`; its result +`omnigraph managed cancel PLAN_RUN_ID --config ./company-brain`; its result remains in history and cannot be applied afterward. -The folder's `.omnigraph/context` selects the managed API. An unavailable API -or malformed context causes an error. To intentionally use the direct -`cluster.yaml` path, pass `--direct`. See [Managed cluster commands](reference.md#managed-cluster-commands) +The explicit `managed` namespace uses the folder's `.omnigraph/context` to select +the API. Missing or malformed context and unavailable APIs refuse without +falling back to local deployment. See [Managed cluster commands](reference.md#managed-cluster-commands) for credential storage, automation, bounded waits, and exit codes. ## Validate source before running it @@ -231,8 +231,3 @@ omnigraph queries validate --cluster ./company-brain --graph knowledge `lint` checks one `.gq` source. `queries validate` checks the applied stored query registry for a cluster graph. - -## Deprecated names - -`read`, `change`, `check`, and `ingest` remain compatibility shims. New scripts -should use `query`, `mutate`, `lint`, and `load`. diff --git a/docs/user/cli/managed-data.md b/docs/user/cli/managed-data.md index fb675c72b..1c47bf852 100644 --- a/docs/user/cli/managed-data.md +++ b/docs/user/cli/managed-data.md @@ -18,7 +18,7 @@ needed. A valid cached graph credential for your cached sign-in avoids an unrelated API session check; an explicit automation token verifies its own principal before reusing that cache. Authentication refresh never replays a submitted mutation. Explicit -`cluster token --ttl 1h` remains available for credential administration. +`managed token --ttl 1h` remains available for credential administration. The issuer must support identity credentials and admit your principal to the selected cluster. The credential proves who you are and which cluster you @@ -76,7 +76,7 @@ keychain; unattended clients needing a raw token use the issuance API directly. Managed `query`, `mutate`, `load`, `commit list`/`show`, and `graphs list` read `.omnigraph/context` only in the current directory; graph-specific commands require `--graph`. After -`cluster token --config DIR`, run these commands from `DIR`; no parent +`managed token --config DIR`, run these commands from `DIR`; no parent directory is searched. Ordinary data requests go directly to the cached endpoint without contacting the control API. They keep working during an API outage until the token expires or its @@ -120,12 +120,12 @@ omnigraph query find_person --profile staging --graph knowledge --json Ordinary token settings never supply managed authority. Global `--direct` continues to select ordinary addressing and credentials, including when the -context is malformed. Existing `cluster --direct` remains valid. Without +context is malformed. Core `cluster` commands never read managed context. Without managed context, existing data commands retain their behavior. ## Clear a credential -`cluster token --clear [--config DIR]` forgets that cluster's local data +`managed token --clear [--config DIR]` forgets that cluster's local data entry, independently of the control-plane session. Do not combine `--clear` with `--graph` or `--ttl`. Clearing is not server revocation: copies remain usable until expiry or signing-key retirement. `logout --api` diff --git a/docs/user/cli/managed-lifecycle.md b/docs/user/cli/managed-lifecycle.md index 81ab29f18..82eb91a82 100644 --- a/docs/user/cli/managed-lifecycle.md +++ b/docs/user/cli/managed-lifecycle.md @@ -11,7 +11,7 @@ Log in to your control API, then use an unbound configuration directory: ```sh omnigraph login --api https://control.example mkdir customer-demo -omnigraph cluster create customer-demo --api https://control.example \ +omnigraph managed create customer-demo --api https://control.example \ --config customer-demo --no-wait --json ``` @@ -28,8 +28,8 @@ sets a local 1–3600-second deadline; the default is 300. A timeout does not cancel creation. Follow the saved operation explicitly: ```sh -omnigraph cluster status --operation OPERATION_ID --config customer-demo --json -omnigraph cluster status --operation OPERATION_ID --api https://control.example \ +omnigraph managed operation OPERATION_ID --config customer-demo --json +omnigraph managed operation OPERATION_ID --api https://control.example \ --wait --timeout 600 --json ``` @@ -56,14 +56,14 @@ Create `customer-demo/catalog.pg` with your graph schema, then read the current managed revision before submitting the edit: ```sh -omnigraph cluster status --config customer-demo --json +omnigraph managed status --config customer-demo --json # Use data.requested.revision from that response. -omnigraph cluster push --config customer-demo --expected-revision COMMIT \ +omnigraph managed push --config customer-demo --expected-revision COMMIT \ --message "Add the knowledge graph" --json # Use data.revision returned by push. -omnigraph cluster plan --config customer-demo --rev NEW_COMMIT --json +omnigraph managed plan --config customer-demo --rev NEW_COMMIT --json # Use data.run_id from the converged plan. -omnigraph cluster apply --config customer-demo --plan PLAN_RUN_ID --json +omnigraph managed apply --config customer-demo --plan PLAN_RUN_ID --json ``` `push` uploads only `cluster.yaml`, referenced schemas, stored queries and @@ -86,7 +86,7 @@ permission and never executes a plan; `apply` requires its separate permission. Use the exact incarnation from your cluster's response: ```sh -omnigraph cluster delete --config customer-demo --incarnation INCARNATION \ +omnigraph managed delete --config customer-demo --incarnation INCARNATION \ --idempotency-key customer-demo-delete --json ``` @@ -105,7 +105,7 @@ Before the deadline and irreversible retirement, request undo of the exact deletion operation: ```sh -omnigraph cluster undo-delete --config customer-demo --incarnation INCARNATION \ +omnigraph managed undo-delete --config customer-demo --incarnation INCARNATION \ --deletion-id DELETE_OPERATION_ID --json ``` diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 08eb71c7c..b7bd7f35c 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -61,7 +61,8 @@ server resolves the actor from the bearer token. Drop it, or use `--store ` | `cleanup` | Delete table versions that no retained graph commit pins, under an explicit retention policy ([Maintenance](../operations/maintenance.md#cleanup)) | direct | | `graphs list` | List graph metadata or minimal identity discovery | served | | `queries list/validate` | Inspect or validate a cluster query registry | cluster | -| `cluster validate/plan/apply/...` | Operate declarative state; [deployment/recovery flags](../clusters/index.md) | config, Core server, explicit root, or managed context | +| `cluster validate/plan/apply/...` | Operate self-hosted declarative state; [deployment/recovery flags](../clusters/index.md) | config, server, explicit root | +| `managed create/push/plan/apply/...` | Operate a managed service cluster | managed API and folder context | | `policy validate/test/explain` | Validate or evaluate applied policy | cluster | | `embed` | Generate, clean, or refresh seed embeddings | local tooling | | `login`, `logout` | Manage a named server credential or a managed API session | local or managed API | @@ -81,8 +82,8 @@ multiple declarations the positional name selects one. A stored server query is its registry name alone. Parameters come inline, `--params '{"name":"Ada"}'`, or from a file, `--params-file params.json`. `--set NAME=VALUE`, repeatable, gives a [session setting](index.md#session-settings) a value for the run of -`query`, `mutate`, `branch merge`, `commit changes`, `changes poll`, `load` -and `ingest`. The source may instead be one branch statement, `mutate -e +`query`, `mutate`, `branch merge`, `commit changes`, `changes poll`, and `load`. +The source may instead be one branch statement, `mutate -e 'branch create b0'` or `query -e 'branch list'` (writes through `mutate`, the listing through `query`; no `--branch`, `--snapshot`, `--if-commit`, name, or params; see [Work with branches](index.md#work-with-branches)), or one @@ -101,8 +102,7 @@ envelope pretty and the `rows` array compact; a refusal follows [Diagnostics](.. head. The id and rows share one pinned snapshot; use that id for a later conditional mutation. -Successful `mutate --json`, `load --json`, and compatibility -`ingest --json` responses include `commit`, the exact commit published by +Successful `mutate --json` and `load --json` responses include `commit`, the exact commit published by that attempt. It contains `graph_commit_id`, optional `graph_branch`, `graph_manifest_version`, optional parent and merged-parent ids, optional `actor_id`, and `created_at` in Unix microseconds. A successful mutation @@ -252,20 +252,20 @@ cluster: CLUSTER_ID api: https://control.example ``` -The context contains no secret. Cluster commands read it only from the selected +The context contains no secret. Managed commands read it only from the selected `--config` directory, which defaults to `.`. Parent directories are not searched. Unknown fields, versions, malformed files, symbolic links, and files over 16 KiB are refused. API addresses must be origins without credentials, path, query, or fragment. HTTPS is required except for exact localhost, 127.0.0.1, and `[::1]` API hosts used for local integration. -| Command with managed context | Behavior | +| Managed command | Behavior | |---|---| -| `cluster plan [--rev REVISION]` | Plan the pushed revision, or the bound head when omitted | -| `cluster apply --plan PLAN_RUN_ID` | Apply exactly that saved plan with current permissions | -| `cluster status [RUN_ID]` | Read the cluster projections, or one run belonging to that cluster | -| `cluster history [--limit N] [--since RFC3339]` | Read up to N runs, default 100, maximum 1000 | -| `cluster cancel RUN_ID` | Cancel a pending run; abandon a converged unused plan and release its lease | +| `managed plan [--rev REVISION]` | Plan the pushed revision, or the bound head when omitted | +| `managed apply --plan PLAN_RUN_ID` | Apply exactly that saved plan with current permissions | +| `managed status [RUN_ID]` | Read the cluster projections, or one run belonging to that cluster | +| `managed history [--limit N] [--since RFC3339]` | Read up to N runs, default 100, maximum 1000 | +| `managed cancel RUN_ID` | Cancel a pending run; abandon a converged unused plan and release its lease | See [managed lifecycle](managed-lifecycle.md) for creation, upload, deletion, undo and operation status. @@ -276,12 +276,12 @@ submission. Reuse that key with the same body to recover from an uncertain response; changing the body under a key is refused by the API. Retry cancellation or abandonment using the same run id. Plan and apply do not upload local files or infer a revision from uncommitted -changes; `cluster push` explicitly prepares managed source. A saved plan retains the service's change lease +changes; `managed push` explicitly prepares managed source. A saved plan retains the service's change lease until it is applied, abandoned, or expires under the API's rules. Plan and apply normally poll every two seconds for up to 300 seconds. `--timeout` accepts 1–3600 seconds. Reaching the deadline stops only the local -wait; inspect `cluster status RUN_ID` to continue following the run. +wait; inspect `managed status RUN_ID` to continue following the run. `--no-wait` prints the accepted run and exits 0. Every HTTP request has a 10-second deadline and an 8 MiB response limit; redirects are refused. `--json` prints one API envelope to stdout with its provenance and @@ -310,19 +310,19 @@ its API origin together: ```bash export OMNIGRAPH_CONTROL_API=https://control.example # Supply OMNIGRAPH_CONTROL_TOKEN through your CI secret mechanism. -omnigraph cluster apply --plan PLAN_RUN_ID --idempotency-key DEPLOYMENT_KEY --json +omnigraph managed apply --plan PLAN_RUN_ID --idempotency-key DEPLOYMENT_KEY --json ``` The canonical `OMNIGRAPH_CONTROL_API` must match the selected context. A missing or mismatched pair refuses before any request. These credentials are separate from `OMNIGRAPH_BEARER_TOKEN`, named servers, and operator profiles. -Without a context, existing direct cluster commands behave as before. -`--direct` explicitly selects that path, ignoring even a malformed context; -`cluster.yaml` still owns the storage root. Managed-only arguments with -`--direct` or without a context refuse. Other cluster verbs, including -`observe` and `force-unlock`, refuse when a managed context is present. API -failures never trigger direct execution. +Self-hosted `cluster` commands always ignore managed context. The `managed` family +requires its own context, except creation and explicit-origin operation lookup; +`--direct` is rejected. Managed API failures never trigger local deployment. +Use `managed operation OPERATION_ID [--wait]` for service lifecycle observation; +`managed status [RUN_ID]` reads cluster projections or a managed run. Self-hosted +`cluster status --deployment-id ID` addresses a separate deployment receipt. ## Managed data access diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index bd101ac3d..21e2fce74 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -93,6 +93,18 @@ canonical root. An explicit absolute `storage` must match that root; relative storage paths refuse. The CLI reads only local source files and needs no local mount or storage credentials for the server’s root. +Preview the same local configuration through the server before applying: + +```bash +omnigraph cluster plan --server production --config . --json +omnigraph cluster apply --server production --config . --timeout 1800 --json +``` + +The served plan lists resource changes and schema migrations, including the exact +managed root and history that a graph removal deletes. It writes nothing and keeps +serving admission open. Its ledger CAS and input digest identify an observation; +apply rechecks current authority and physical execution eligibility. + The CLI prints the deployment ID before submission. The server keeps its PID, listener and writer ownership. It closes admission on affected graphs, finishes their admitted requests, publishes schema changes, and activates @@ -103,8 +115,25 @@ Graph additions and removals use the same deployment; see The response separates the durable deployment result from `active`, which means that result's affected bindings are installed in this process. An unrelated -blocked graph does not invalidate that activation. A successful response requires -both convergence and activation. Each graph publishes atomically; deployment +blocked graph does not invalidate that activation. Default apply waits for +convergence and activation by polling the original ID. `--no-wait` instead returns +after durable acceptance; drain and preparation may precede that acknowledgment. +`--timeout SECONDS` bounds caller waiting, including acceptance (default 300, +maximum 3600). Expiry exits 5 with the original ID and last observation; it does +not cancel execution or prove failure. Resume observation with: + +```bash +omnigraph cluster status --server production --deployment-id ID --wait --timeout 1800 --json +``` + +The exact response contains `deployment`, `active` and `in_progress`. General +cluster status retains its aggregate `status` object. An authenticated submitter +can read its own durable receipt even after its deployment removes its management +permission; general status and later deployments still require current permission. +Lost submission responses are followed only by original-ID reads, never automatic +resubmission. Expired receipts and unknown outcomes require investigation. + +Each graph publishes atomically; deployment across multiple graphs is not one transaction. Query-only changes create no graph commit and also work with multiple branches. Schema changes remain main-only and require a single live branch. diff --git a/openapi.json b/openapi.json index 3502ec3c6..8d88e5b4d 100644 --- a/openapi.json +++ b/openapi.json @@ -143,7 +143,7 @@ } }, "503": { - "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "description": "Deployment ownership changed during the bounded ledger observation; retry this GET within the caller deadline; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { "omnigraph-http-api": { "schema": { @@ -221,6 +221,27 @@ } } }, + "202": { + "description": "Durably accepted; poll the exact receipt until in_progress is false and inspect active", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentResponse" + } + } + } + }, "400": { "description": "; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", "headers": { @@ -428,7 +449,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentStatusResponse" + "$ref": "#/components/schemas/DeploymentResponse" } } } @@ -502,6 +523,219 @@ } } }, + "503": { + "description": "Deployment ownership changed during the bounded ledger observation; retry this GET within the caller deadline; Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, + "/cluster/plan": { + "post": { + "tags": [ + "cluster" + ], + "operationId": "deployment_plan", + "parameters": [ + { + "name": "omnigraph-http-api", + "in": "header", + "description": "exactly one omnigraph-http-api header with value 0.12 is required", + "required": true, + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "; api_contract_mismatch when the required API contract header is missing, duplicated or unsupported", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "408": { + "description": "Request body deadline exceeded before operation admission", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "409": { + "description": "", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "413": { + "description": "; Request body exceeds its route limit before operation admission", + "headers": { + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "429": { + "description": "Server observer, ingress or operation admission capacity exhausted; honor Retry-After", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum delay before a caller-bounded retry of this refused request; not a scheduled server retry." + }, + "omnigraph-http-api": { + "schema": { + "type": "string", + "enum": [ + "0.12" + ] + }, + "description": "Supported OmniGraph HTTP API contract." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, "503": { "description": "Known graph unavailable (graph_unavailable) or server operation admission closed; reconcile any earlier write before retrying", "headers": { @@ -7832,7 +8066,8 @@ "type": "object", "required": [ "deployment", - "active" + "active", + "in_progress" ], "properties": { "active": { @@ -7840,6 +8075,9 @@ }, "deployment": { "type": "object" + }, + "in_progress": { + "type": "boolean" } } }, @@ -7847,12 +8085,16 @@ "type": "object", "required": [ "status", - "active" + "active", + "in_progress" ], "properties": { "active": { "type": "boolean" }, + "in_progress": { + "type": "boolean" + }, "status": { "type": "object" } @@ -8732,6 +8974,18 @@ "list" ] }, + "PlanRequest": { + "type": "object", + "required": [ + "deployment" + ], + "properties": { + "deployment": { + "type": "object" + } + }, + "additionalProperties": false + }, "PositionOutput": { "type": "object", "description": "A source position: 1-based line and column (in characters) and the byte\noffset.", diff --git a/skills/omnigraph/SKILL.md b/skills/omnigraph/SKILL.md index b6ca1ef59..c36b6bf03 100644 --- a/skills/omnigraph/SKILL.md +++ b/skills/omnigraph/SKILL.md @@ -49,7 +49,7 @@ See [traversal](../../docs/user/queries/traversal.md) and 1. **Lint before commit** — `omnigraph lint --schema schema.pg --query queries/foo.gq` validates both sides against each other. No running repo required. 2. **Plan before apply** — never run `schema apply` without a successful `schema plan` first. Apply is destructive; plan is free. (Cluster mode has the same rule with different verbs: `cluster plan` before `cluster apply` — the plan embeds the engine's real migration steps.) -3. **Branches are for data; apply is for schema** — review bulk data loads on a feature branch then merge. Schema changes go straight to `main`: in cluster mode edit the `.pg` and run `cluster apply` (`--server ` deploys to the running server without a restart; without it serving must be stopped first; a direct `schema apply` **refuses** a cluster-managed graph); in a managed folder commit, `cluster push`, `cluster plan --rev `, then `cluster apply --plan `; `schema plan`/`apply` is for a non-cluster store. +3. **Branches are for data; apply is for schema** — review bulk data loads on a feature branch then merge. Schema changes go straight to `main`: in cluster mode edit the `.pg` and run `cluster apply` (`--server ` deploys to the running server without a restart; without it serving must be stopped first; a direct `schema apply` **refuses** a cluster-managed graph); for the managed service commit, `managed push`, `managed plan --rev `, then `managed apply --plan `; `schema plan`/`apply` is for a non-cluster store. 4. **Pick the right write command** — `mutate` for edits (typechecked, parameterized); `load` for bulk JSONL, local **or** remote, with a **required** `--mode` (`merge` upsert · `append` strict-insert · `overwrite` replaces only the node/edge types represented in the batch). `load --from ` forks a review branch in one shot; bare `load` needs an existing target branch. 5. **Parameterize everything** — never string-interpolate values into `.gq` bodies or `--params`. Declare `$var: Type` and pass via `--params`. 6. **Expose agent reads as aliases** — aliases decouple a read operation name @@ -158,11 +158,11 @@ single source of truth. Notation: `` required · `[x]` optional · `` choice · `…` repeatable. -**Global addressing flags**: `--as ` (direct-engine writes, `rebuild-full-text-indexes`, and `cluster apply`/`upgrade-ledger`; a served write **refuses** `--as` because the server resolves the actor from the bearer token, and read verbs reject it), `--server `, `--cluster ` (cluster-managed storage, primarily for maintenance), `--graph ` (selects within a `--server` or `--cluster` scope; required for managed queries, mutations, loads and commit reads, and for `cluster token --actions`), `--profile ` (`$OMNIGRAPH_PROFILE`), `--store `, `--direct` (ignore a folder's managed `.omnigraph/context`). Commands with an open positional slot also accept `file://`, `s3://`, or preview `az://` directly. `--config ` belongs only to `cluster` subcommands and `use`. Output: `--json`, or read queries take `--format ` (`arrow` is listed but always fails in 0.12.0). **Write guards:** `--yes` skips non-local confirmation for destructive writes; `--quiet` suppresses the resolved-target echo. +**Global addressing flags**: `--as ` (direct-engine writes, `rebuild-full-text-indexes`, and `cluster apply`/`upgrade-ledger`; a served write **refuses** `--as` because the server resolves the actor from the bearer token, and read verbs reject it), `--server `, `--cluster ` (cluster-managed storage, primarily for maintenance), `--graph ` (selects within a `--server` or `--cluster` scope; required for managed queries, mutations, loads and commit reads), `--profile ` (`$OMNIGRAPH_PROFILE`), `--store `, `--direct` (ignore managed context for data commands). Commands with an open positional slot also accept `file://`, `s3://`, or preview `az://` directly. `--config ` selects source/context folders for `cluster`, `managed`, and `use`. Output: `--json`, or read queries take `--format ` (`arrow` is listed but always fails in 0.12.0). **Write guards:** `--yes` skips non-local confirmation for destructive writes; `--quiet` suppresses the resolved-target echo. **Data plane** — `any` (served via `--server`/`--profile`, or direct via `--store`/URI): -- `query` (alias `read`) `` — a **served stored query** by name (via `--server`/`--profile`); or ad-hoc `[] (--query | -e '')` where `` picks which query in the source. `[--params | --params-file

] [--branch | --snapshot ] [--format | --json]`. No positional URI — address via `--server`/`--store`/`--profile`. -- `mutate` (alias `change`) — same shape (served stored mutation by ``, or ad-hoc `--query`/`-e`); `[--params …] [--branch ] [--if-commit ] [--json]`. The verb asserts kind; a failed precondition has no effect: a served mutate exits 4, a direct (`--store`) one exits 1. +- `query` `` — a **served stored query** by name (via `--server`/`--profile`); or ad-hoc `[] (--query | -e '')` where `` picks which query in the source. `[--params | --params-file

] [--branch | --snapshot ] [--format | --json]`. No positional URI — address via `--server`/`--store`/`--profile`. +- `mutate` — same shape (served stored mutation by ``, or ad-hoc `--query`/`-e`); `[--params …] [--branch ] [--if-commit ] [--json]`. The verb asserts kind; a failed precondition has no effect: a served mutate exits 4, a direct (`--store`) one exits 1. - `load --data --mode [--branch ] [--from ] [--json]` — `--mode` required; `--from` forks a missing `--branch`; overwrite replaces only represented types - `blob ` — dedicated Blob-cell reads; `get` supports ranges/`--out`, `stat` returns metadata - `snapshot [--branch ] [--json]` @@ -170,7 +170,7 @@ Notation: `` required · `[x]` optional · `` choice · `…` repeatable - `branch [--from ] | list | delete | merge [--into ] [--delete-branch]> [--json]` (`--from`/`--into` default to `main`; also available as GQ statements via `mutate -e`/`query -e`) - `commit ] | show | changes [filters…]> [--json]` - `changes | --cursor ] | baseline --out > [filters…] [--json]` -- `schema apply --schema [--json]` · `schema show` (alias `get`) — `apply` **refuses a cluster-managed graph** (evolve those via `cluster apply`) +- `schema show` (alias `get`) Every read query runs on engine v2, the planned execution route and the only `engine` value. `query -e 'explain query q() { … }'` @@ -180,10 +180,10 @@ executing the query. It uses the ordinary query target and parameter flags. **Served only** (needs `--server`/`--profile`): `graphs list [--json]` **Direct / storage** — reject `--server`. `init` requires its positional URI; -`schema plan` uses a positional URI or `--store`; lint and maintenance also +`schema plan`/`apply` use a positional URI or `--store`; lint and maintenance also accept `--cluster --graph `: - `init --schema [--force]` -- `schema plan --schema [--json]` +- `schema --schema [--json]` — `apply` refuses a cluster-managed graph; evolve those via `cluster apply` - `upgrade [--check] [--to-format 14] [--json]` — offline standalone conversion of a v8, v9 or v13 graph to format 14, the only target. `schema upgrade-system-columns [--check] [--json]` respells a @@ -192,13 +192,13 @@ accept `--cluster --graph `: cluster-managed roots refuse. The 0.11 binary's `upgrade` took `--to-format <7|8|9>` and defaulted to v9. - `lint --query [--schema ] [] [--json]` — offline with `--schema`, graph-backed with a URI -- `optimize [--json]` · `repair [--confirm] [--force] [--json]` · `cleanup [--keep ] [--older-than <7d>] --confirm [--json]` (at least one retention option; both may be combined) +- `optimize [--json]` · `repair [--json]` · `cleanup [--keep ] [--older-than <7d>] --confirm [--json]` (at least one retention option; both may be combined) - `rebuild-full-text-indexes [--branch ] [--json]` — replace full-text indexes on one branch with default English analysis; custom tokenizer settings are replaced. Stop overlapping writers and retain a whole-store backup for upgrades. `--as` records attribution; direct access does not load server policy. See [maintenance commands](references/commands.md#rebuild-full-text-indexes--explicit-analyzer-upgrade). **Control plane**: -- `cluster [--config

] [--json]` — `observe`/`plan --observe` take no lock and write nothing; `apply` and `status` also take `--server ` (deploy to, or inspect, the running server) and `--deployment-id `. 0.12 removed `refresh`, `import` and `approve` -- `cluster force-unlock [--config ] [--json]` · `--cluster cluster upgrade-ledger --writers-stopped [--json]` (one-time conversion of an existing cluster's v1 ledger, with serving and writers stopped) -- Managed folder: `use --api ` · `cluster --api | push --expected-revision --message | plan [--rev ] | apply --plan | status [RUN_ID] | history | cancel | token ([--ttl 1h] [--graph --actions ] | --clear) | delete --incarnation | undo-delete --incarnation --deletion-id >` — see [managed clusters](references/cluster.md#managed-clusters) +- `cluster [--config ] [--json]` — always self-hosted, independent of managed context. `plan` and `observe` are read-only. `plan`, `apply`, and `status` accept `--server `. Served `apply` waits for activation; `--no-wait` returns after durable acceptance, and `--timeout <1..3600>` bounds caller waiting. Resume observation with `status --deployment-id --wait [--timeout ]`; timeout never cancels execution. Direct recovery uses the original `--deployment-id` with `--writers-stopped`. +- `cluster force-unlock [--config ] [--json]` · `--cluster cluster upgrade-ledger --writers-stopped [--json]` (explicit conversion of a supported legacy ledger, with serving and writers stopped) +- Managed service: `use --api ` · `managed --api | push --expected-revision --message | plan [--rev ] | apply --plan | status [RUN_ID] | operation [--wait] | history | cancel | token ([--ttl 1h] | --clear) | delete --incarnation | undo-delete --incarnation --deletion-id >` — see [managed clusters](references/cluster.md#managed-clusters) - `policy | explain --actor --action [--branch | --target-branch ]> --cluster [--graph ]` - `queries --cluster [--graph ] [--json]` @@ -209,7 +209,7 @@ accept `--cluster --graph `: Managed folders can select an Intent API with `login --api` and `use`; implicit `query`, `mutate`, `load`, `commit list`/`show` and `graphs list` then acquire -and cache a separate identity credential themselves (`cluster token` remains +and cache a separate identity credential themselves (`managed token` remains for credential administration). Global `--direct` selects ordinary addressing. See [managed routing](references/cluster.md#managed-clusters) before using ambient targets or credentials. @@ -261,7 +261,7 @@ Keep development credentials in a git-ignored `.env.omni` and source it before C set -a && source .env.omni && set +a ``` -Direct `init`/`load` and **`cluster apply`** write storage without an HTTP server. A served `load` is different: the CLI sends it to `omnigraph-server`, which performs the graph write. A direct `cluster apply` reaches the cluster ledger and graph datasets directly, so its host needs storage credentials (a managed folder's `cluster apply --plan` is executed by the Intent API). A serving process also needs read-write access for served data-plane writes. Validate with `curl http://127.0.0.1:8080/readyz` (HTTP 200, `ready: true`; `/healthz` only proves the process is alive), then `omnigraph snapshot --server --graph --json`. +Direct `init`/`load` and **`cluster apply`** write storage without an HTTP server. A served `load` is different: the CLI sends it to `omnigraph-server`, which performs the graph write. A direct `cluster apply` reaches the cluster ledger and graph datasets directly, so its host needs storage credentials (`managed apply --plan` is executed by the Intent API). A serving process also needs read-write access for served data-plane writes. Validate with `curl http://127.0.0.1:8080/readyz` (HTTP 200, `ready: true`; `/healthz` only proves the process is alive), then `omnigraph snapshot --server --graph --json`. ## Project Layout @@ -308,7 +308,7 @@ aliases: # personal bindings to TEAM stored queries (see ref triage: { server: intel-dev, graph: spike, query: weekly_triage, args: [since] } ``` -The operator config and credentials are **auto-discovered — no flag points at them**: the CLI reads `$OMNIGRAPH_HOME/config.yaml` (default `~/.omnigraph/config.yaml`), and an absent file is just an empty layer (zero-config). `$OMNIGRAPH_HOME` relocates the *directory* only, not a specific file. Only `cluster` subcommands and `use` take `--config`. +The operator config and credentials are **auto-discovered — no flag points at them**: the CLI reads `$OMNIGRAPH_HOME/config.yaml` (default `~/.omnigraph/config.yaml`), and an absent file is just an empty layer (zero-config). `$OMNIGRAPH_HOME` relocates the *directory* only, not a specific file. `--config` selects source/context folders for `cluster`, `managed`, and `use`. Credentials live outside config: `echo $TOKEN | omnigraph login intel-dev` writes `~/.omnigraph/credentials` (`0600`); the matching token resolves via @@ -357,7 +357,7 @@ These are the traps most likely to bite. Scan this table before debugging any pa | `$p.id`, `{ id: $v }`, `where id =` from a v0.10 query | ``T6``/``T2``/``T11``: ``type `Person` has no property `id`; the system identity is `$p.@id` `` (a failing stored-query registry quarantines its graph) | Use `$p.@id`, `$e.@src`/`@dst`, `where @id =`; lint every `.gq` before restarting on v0.11 or later | | Long-lived feature branches | merge conflicts, schema apply blocked | Merge promptly; delete when done | | `mutation { ... }` wrapper in `.gq` | `parse error: expected query_file` at line 1 | Use `query (...) { insert T { ... } }`; there is no top-level `mutation` keyword | -| `--config` on a data/schema command | `unexpected argument '--config' found` | Only `cluster` subcommands and `use` accept it; use `--server`/`--graph`, `--store`, or `--profile` elsewhere | +| `--config` on a data/schema command | `unexpected argument '--config' found` | Use it for `cluster`, `managed`, and `use`; use `--server`/`--graph`, `--store`, or `--profile` elsewhere | | `--as` on a served write | `` `--as` is not allowed on a served write `` | Drop it; the bearer token selects the actor | | Reading a large schema via stdout-capped tool | Truncated, garbled, or duplicated output | `omnigraph schema show --server --graph > /tmp/schema.pg`, then read the file in chunks | | `omnigraph load` without `--mode` | `the following required arguments were not provided: --mode ` | Pass `--mode merge\|append\|overwrite` — there is no default (overwrite is destructive, so it is never implicit). Address direct storage or a served graph | diff --git a/skills/omnigraph/references/cluster.md b/skills/omnigraph/references/cluster.md index b206a8da5..cab92b9b9 100644 --- a/skills/omnigraph/references/cluster.md +++ b/skills/omnigraph/references/cluster.md @@ -80,7 +80,7 @@ omnigraph-server --cluster . --bind 127.0.0.1:8080 --unauthenticated # serve (l keep `__cluster/lock.json`, and the next writer or server start refuses with `state_lock_held`. Once the previous owner has stopped and its I/O has settled, clear that exact id with `cluster force-unlock --config .`. - While a lock is held, preview with `cluster plan --observe`. + While the server owns the lock, preview with `cluster plan --server `. - **`storage: s3://bucket/prefix`** (optional) puts the entire cluster — state ledger, lock, content-addressed catalog, and the derived graph roots (`/graphs/.omni`) — on @@ -116,21 +116,43 @@ omnigraph-server --cluster . --bind 127.0.0.1:8080 --unauthenticated # serve (l operator config's `operator.actor`. `cluster apply --server` refuses it, because the server takes the actor from the bearer token; the other cluster subcommands reject this flag. -- **Apply only adds and updates**: it creates graphs with their policy - bindings and changes schemas and stored queries. Removing a graph, or - changing an existing graph's policy, embedding-provider or `external_blobs` - binding, is refused with `deployment_scope`; no approval command exists. -- **Drift**: every apply compares each graph's schema with the one the ledger - recorded and refuses an out-of-band change with `applied_schema_drift`; - `cluster apply --schema-correction ` accepts a reviewed one. - To inspect without side effects, `cluster observe --config .` reports the - current ledger, catalog and graph observations, and `cluster plan --observe` - plans, without taking the lock or writing. Their output carries - `authority: "observed"` and the `state_cas` they read, and an existing lock - is reported rather than refused. +- **Deployment scope**: apply creates graphs and updates schemas, queries, + policies, provider definitions/bindings, and `external_blobs` rules. Removing + a graph declaration deletes its exact managed root, including retained + history, after affected admission closes and work drains. Current permissions + authorize changes; proposed permissions cannot authorize themselves. Shared + external Blob objects are not deleted, and provider retention may keep object + versions. Root changes, adoption and missing-root recreation refuse. +- **Drift**: apply refuses an out-of-band schema change with + `applied_schema_drift`; it never adopts it by matching schema text. To inspect + without effects, `cluster observe --config .` reports current ledger, catalog + and graph observations; `cluster plan --config .` previews desired changes. + Both are read-only, report `authority: "observed"` and the `state_cas` they + read, and take no writer lock. Apply revalidates current authority. - **Data is NOT cluster's job**: rows flow through `omnigraph load / mutate` against the served graph (`--server … --graph `), with branches as usual. +For a running server, preview and submit the same local bundle: + +```bash +omnigraph cluster plan --server production --config . --json +omnigraph cluster apply --server production --config . --timeout 1800 --json +omnigraph cluster status --server production --deployment-id ID --wait --timeout 1800 --json +``` + +Apply prints its deployment ID before submission and normally polls that ID +until convergence and activation. `--no-wait` returns after durable acceptance; +`--timeout` bounds caller waiting, including acceptance (default 300 seconds, +range 1–3600). Expiry exits 5 without cancelling work. Resume with the exact +`status --deployment-id ID --wait`; these wait flags require `--server`. +A lost submission response triggers original-ID reads, never automatic replay. +The exact response contains `deployment`, `active` and `in_progress`; `active` +means the result's affected bindings are installed in this process. An +unrelated blocked graph does not invalidate that activation. The authenticated +submitter can still read its own retained receipt after losing management +permission; aggregate status and new deployments require current permission. +Deployment across graphs is not one transaction. + ## The config contract (do not blur this) | File | Owns | Read by | @@ -139,10 +161,10 @@ omnigraph-server --cluster . --bind 127.0.0.1:8080 --unauthenticated # serve (l | `~/.omnigraph/config.yaml` | per-operator: identity (`operator.actor`), named `servers:`, output defaults, personal aliases | data-plane CLI commands (tokens live in `~/.omnigraph/credentials` via `omnigraph login`) | Direct cluster commands use the operator actor default when `--as` is omitted -(`--as` > `operator.actor`). Managed context selects a separate API route, as -described below. A `--cluster` server -reads it for **nothing** — boot from cluster state XOR the operator file, never -a merge. +(`--as` > `operator.actor`). `cluster` always selects self-hosted deployment, +independent of managed context; `managed` selects the service API described below. +A `--cluster` server never reads the operator file: its configuration comes from +applied cluster state. Address a cluster-managed graph's data via `--server`/aliases against the serving instance. A direct `--store /graphs/.omni` read takes no lock. On a ledger-v2 cluster a direct write takes the cluster lock: it is @@ -201,8 +223,8 @@ An Intent API can own the control plane while the same CLI operates it: ```bash omnigraph login --api https://control.example omnigraph use CLUSTER_ID --api https://control.example --config . -omnigraph cluster plan --config . --json -omnigraph cluster apply --plan PLAN_RUN_ID --config . --json +omnigraph managed plan --config . --json +omnigraph managed apply --plan PLAN_RUN_ID --config . --json omnigraph query find_person --graph knowledge --params '{"name":"Alice"}' --json ``` @@ -218,32 +240,30 @@ ordinary routing; global `--direct` selects ordinary ambient defaults. Missing or malformed managed authority refuses without fallback, and competing ambient targets require an explicit choice. -Managed creation, config upload, deletion and undo use `cluster create`, `push`, -`delete` and `undo-delete`; durable operation records bind uncertain submissions -to their exact identity. Reconcile the existing operation before issuing another. +Managed creation, config upload, deletion and undo use `managed create`, `push`, +`delete` and `undo-delete`. `managed status [RUN_ID]` reads cluster projections or +one run; `managed operation ID [--wait]` observes a lifecycle operation. Durable +operation records bind uncertain submissions to their exact identity. Reconcile +the existing operation before issuing another. -- **Schema/config changes**: commit, `cluster push --expected-revision - --message …`, `cluster plan --rev `, then `cluster apply --plan `. +- **Schema/config changes**: commit, `managed push --expected-revision + --message …`, `managed plan --rev `, then `managed apply --plan `. The Intent API executes the apply; there is no local `--as` or approval step. -- **Supported verbs** with a managed context: `plan`, `apply --plan`, - `status`, `history`, `cancel`, `token`, and the lifecycle verbs. `validate`, - `observe`, and config-addressed `force-unlock` refuse unless - `--direct` is given; `--as`, `--server`, `--profile`, `--store`, and - `--cluster` do not apply to managed cluster operations. +- **Explicit namespace**: `managed` requires context, except creation and + explicit-origin operation lookup. `cluster` ignores context and needs no + `--direct` escape. Managed operations reject `--direct`, `--as`, `--server`, + `--profile`, `--graph`, `--store`, and `--cluster`; failures never fall back to + local deployment. - **Sessions** from `login --api` hold an access credential of at most 15 minutes that renews silently for up to eight hours after sign-in; then log in again. For unattended runs, set `OMNIGRAPH_CONTROL_API` and `OMNIGRAPH_CONTROL_TOKEN` together; reuse the same `--idempotency-key` after an uncertain response. - **Data credentials**: graph commands acquire an identity credential on - their own; `cluster token [--ttl 1h]` issues one explicitly (TTL 60s–24h, - default 1h) and `--clear` forgets the local copy without revoking it. An - identity credential carries no graph or action grants: the applied Cedar - policy must permit its actor. `cluster token --graph --actions ` - requests the legacy restricted profile, for older issuers only: `read`, - `export`, `change`, `branch_create`, `branch_delete`, `branch_merge`, - `invoke_query`, `graph_list` (`admin`, `config_manage` and `schema_apply` - refuse). + their own; `managed token [--ttl 1h]` issues one explicitly (TTL 60s–24h, + default 1h) and `--clear` forgets the local copy without revoking it. Only + identity credentials are supported: they carry no graph or action grants, + and the applied Cedar policy must permit the authenticated actor. - **Exit codes** for plan/apply/lifecycle runs: 0 converged, 1 failed or transport error, 2 refused or blocked, 3 partially converged, 4 recovery required, 5 stalled or wait deadline, 6 cancelled. @@ -255,13 +275,14 @@ See the authoritative [managed command reference](https://github.com/ModernRelay | Symptom | Fix | |---|---| -| Apply interrupted or outcome unknown | never retry under a new deployment id. Read the original deployment with `omnigraph --cluster cluster status --deployment-id --json` (apply prints the id and root). Stop the prior owner, prove its I/O has settled, clear the held lock with `omnigraph --cluster cluster force-unlock `, then reconcile that same id: `omnigraph --cluster cluster apply --deployment-id --writers-stopped` | +| Served apply timed out or lost its response | continue original-ID observation with `cluster status --server --deployment-id --wait --json`; timeout does not cancel the owner. Never resubmit under a new ID | +| Direct apply interrupted, or accepted work needs stopped recovery | never retry under a new deployment id. Read the original deployment with `omnigraph --cluster cluster status --deployment-id --json` (apply prints the id and root). Stop the prior owner, prove its I/O has settled, clear the held lock with `omnigraph --cluster cluster force-unlock `, then reconcile that same id: `omnigraph --cluster cluster apply --deployment-id --writers-stopped` | | Held lock (`state_lock_held`) | expected after any apply, direct write or server run. `cluster observe` shows state and the holder without refusing. First stop the owner and prove its I/O has settled; then use `cluster status` and clear that exact id with `cluster force-unlock --config .` | | `config_path_escape` / `config_path_symlink` | move the referenced file inside the config directory and reference it by a plain relative path | | Missing `state.json` | new cluster: `cluster apply` creates the ledger. Bootstrap never adopts graph roots that already exist (`graph_already_exists`); for those, restore a trusted cluster-state backup | | Corrupt `state.json` | restore a trusted cluster-state backup or follow the diagnostic | | Server refuses to boot | the error names its code and remedy; common ones: `state_lock_held` (see Held lock above), `ledger_upgrade_required` (stop serving and writers, then `cluster upgrade-ledger --writers-stopped`), `cluster_deployment_outstanding` (reconcile that exact id with `cluster apply --deployment-id --writers-stopped`) | -| `ledger_upgrade_required` (ledger written before 0.12) | stop every server, writer and maintenance job, then `omnigraph --cluster cluster upgrade-ledger --writers-stopped`; it converts the ledger only and keeps rows, branches and history | +| `ledger_upgrade_required` (supported legacy ledger or obsolete completed-result fields) | stop every server, writer and maintenance job, then `omnigraph --cluster cluster upgrade-ledger --writers-stopped`; it converts the ledger only and keeps rows, branches and history | Full reference: the omnigraph repo's `docs/user/clusters/index.md` (operator guide) and `docs/user/clusters/config.md` (every key, flag, and diagnostic). diff --git a/skills/omnigraph/references/commands.md b/skills/omnigraph/references/commands.md index 93e922d9c..b091458b8 100644 --- a/skills/omnigraph/references/commands.md +++ b/skills/omnigraph/references/commands.md @@ -117,7 +117,9 @@ omnigraph schema plan --schema next.pg $REPO --json omnigraph schema apply --schema next.pg $REPO ``` -See `references/schema.md` for the full workflow. +Both commands require direct storage and refuse `--server`. Cluster-managed +graphs evolve through `cluster apply`. See `references/schema.md` for the full +workflow. ### Offline storage upgrade @@ -280,7 +282,7 @@ omnigraph login --api # managed Intent API session (OS keyc omnigraph logout --api # revoke that session and remove its keychain entry ``` -The operator config and `~/.omnigraph/credentials` are **auto-discovered — there is no flag to point at them.** `$OMNIGRAPH_HOME` relocates the `~/.omnigraph` directory, and an absent file is an empty layer. Only `cluster` subcommands and `use` accept `--config`. +The operator config and `~/.omnigraph/credentials` are **auto-discovered — there is no flag to point at them.** `$OMNIGRAPH_HOME` relocates the `~/.omnigraph` directory, and an absent file is an empty layer. `--config` selects source/context folders for `cluster`, `managed`, and `use`. ## Addressing a Graph @@ -302,8 +304,8 @@ operator default server/store competes, they refuse with `managed_target_ambiguous`. Global `--direct` restores the ordinary resolution above. Other data commands are unaffected. -Cluster subcommands use `--config `; policy and stored-query control-plane -commands use `--cluster `. Maintenance against a +`cluster` and `managed` use `--config ` for source/context folders; policy +and stored-query control-plane commands use `--cluster `. Maintenance against a cluster-managed graph uses `--cluster --graph `. Each command declares a **capability** — `any` / `served` / `direct` / `control` / `local` — shown in `omnigraph --help`; mis-addressing fails loudly. @@ -361,17 +363,24 @@ is healthy; authorized `graphs list --server --json` returns one omnigraph cluster validate --config # parse + typecheck the declaration omnigraph cluster plan --config [--json] # preview (schema changes show migration steps) omnigraph cluster apply --config --as # direct (serving stopped): bootstrap or update -omnigraph cluster apply --server --config # submit to the running server; activates without restart +omnigraph cluster plan --server --config --json # read-only served preview +omnigraph cluster apply --server --config [--no-wait] [--timeout 1800] --json +omnigraph cluster status --server --deployment-id --wait [--timeout 1800] --json omnigraph cluster status --config [--json] # read the ledger (read-only) omnigraph cluster observe --config [--json] # current observations: no lock, no write -omnigraph cluster plan --observe --config # plan without taking the lock omnigraph cluster force-unlock --config # remove an exact lock once its owner is quiescent -omnigraph --cluster cluster upgrade-ledger --writers-stopped # one-time: convert a stopped 0.11 (v1) ledger +omnigraph --cluster cluster upgrade-ledger --writers-stopped # convert a supported stopped legacy ledger ``` -`observe` and `plan --observe` report an existing lock instead of refusing, and -print `authority: "observed"` with the `state_cas` they read (`plan` output -carries `authority: locked|observed|unlocked`). +`plan` and `observe` are read-only and report `authority: "observed"` with the +ledger `state_cas` they read. A plan is an observation; apply revalidates it. +Served apply normally polls its original ID until convergence and activation. +`--no-wait` returns after durable acceptance; `--timeout` bounds caller waiting +(default 300 seconds, range 1–3600), including acceptance, and never cancels work. +A wait timeout exits 5. Exact served status returns `deployment`, `active`, and +`in_progress`; use `--wait` with `--deployment-id` to resume observation. These +wait flags require `--server`. Lost responses trigger original-ID reads, never +automatic resubmission. See [deployment receipts](cluster.md#the-loop-memorize-this). The 0.11 verbs `import`, `refresh` and `approve` are removed. A fresh direct `apply` creates the ledger, and a ledger written by 0.11 is converted once with @@ -379,26 +388,26 @@ The 0.11 verbs `import`, `refresh` and `approve` are removed. A fresh direct its admission lock after it completes: once the owner and its I/O have settled, release that exact lock id with `force-unlock` before starting the server. -A managed folder (see [`cluster.md`](cluster.md#managed-clusters)) uses a -different verb set; `validate`, `observe`, and `force-unlock` (without -`--cluster`) refuse there unless `--direct` is given: +Self-hosted `cluster` commands always ignore managed context. The managed +service has its own namespace (see [`cluster.md`](cluster.md#managed-clusters)): ```bash omnigraph use --api [--config ] -omnigraph cluster create --api -omnigraph cluster push --expected-revision --message -omnigraph cluster plan [--rev ] -omnigraph cluster apply --plan -omnigraph cluster status [RUN_ID | --operation [--wait]] -omnigraph cluster history [--limit N] [--since ] -omnigraph cluster cancel -omnigraph cluster token ([--ttl 1h] | --clear) # optional; --graph --actions … is the legacy restricted profile -omnigraph cluster delete --incarnation [--retention-seconds 86400] -omnigraph cluster undo-delete --incarnation --deletion-id +omnigraph managed create --api +omnigraph managed push --expected-revision --message +omnigraph managed plan [--rev ] +omnigraph managed apply --plan +omnigraph managed status [RUN_ID] +omnigraph managed operation [--api ] [--wait] [--timeout 1800] +omnigraph managed history [--limit N] [--since ] +omnigraph managed cancel +omnigraph managed token ([--ttl 1h] | --clear) # identity only; applied Cedar policy decides access +omnigraph managed delete --incarnation [--retention-seconds 86400] +omnigraph managed undo-delete --incarnation --deletion-id ``` -Managed runs take `--no-wait`, `--timeout <1..3600>`, and -`--idempotency-key` (reuse the same key after an uncertain response). Managed +Managed plan, apply, create, delete, and undo-delete take `--no-wait`, +`--timeout <1..3600>`, and `--idempotency-key` (reuse the same key after an uncertain response). Managed plan/apply/lifecycle exit codes: 0 converged, 1 failed or transport error, 2 refused or blocked, 3 partially converged, 4 recovery required, 5 stalled or wait deadline, 6 cancelled. From c31d0dd7cb6e9fe976c5ccdb5daa26b148edfacb Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 6 Oct 2026 22:03:05 +0300 Subject: [PATCH 09/12] fix(cli): recover deployment observations after interrupted responses Qualify submit-once behavior with real CLI/server disconnect and timeout cases, deterministic owner races, held-merge planning, and receipt access across policy handoff and restart. Extend managed command coverage and fix its timeout recovery hint. --- .github/workflows/ci.yml | 1 + .../deployment-observation-retries.fixed.md | 1 + crates/omnigraph-cli/src/cluster_remote.rs | 8 +- crates/omnigraph-cli/src/managed.rs | 2 +- crates/omnigraph-cli/src/managed/tests.rs | 20 ++ crates/omnigraph-cli/tests/cli_cluster.rs | 220 +++++++++++++- crates/omnigraph-cli/tests/cli_cluster_e2e.rs | 75 +++-- .../tests/support/managed_http.rs | 180 ++++++++++-- crates/omnigraph-cli/tests/system_remote.rs | 276 +++++++++++++++++- crates/omnigraph-server/src/deployment.rs | 204 ++++++++++--- .../omnigraph-server/tests/boot_settings.rs | 81 +++++ docs/dev/testing.md | 19 +- docs/user/clusters/index.md | 4 +- 13 files changed, 1001 insertions(+), 90 deletions(-) create mode 100644 changelog.d/deployment-observation-retries.fixed.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 620af07b6..6781c6de3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -715,6 +715,7 @@ jobs: remote_read_preserves_projection_order_in_json_and_csv \ remote_branch_create_list_merge_flow \ remote_merge_delivery_loss_never_replays_committed_effect \ + remote_deployment_delivery_loss_preserves_owned_completion \ remote_branch_delete_removes_branch \ remote_export_round_trips_full_branch_graph \ remote_ingest_creates_review_branch_and_keeps_it_readable \ diff --git a/changelog.d/deployment-observation-retries.fixed.md b/changelog.d/deployment-observation-retries.fixed.md new file mode 100644 index 000000000..fca89077a --- /dev/null +++ b/changelog.d/deployment-observation-retries.fixed.md @@ -0,0 +1 @@ +- Served deployment observation retries truncated HTTP response bodies within the caller's wait budget, without resubmitting the deployment. Malformed receipts still fail immediately. Managed wait timeouts now point to `managed status` for recovery. diff --git a/crates/omnigraph-cli/src/cluster_remote.rs b/crates/omnigraph-cli/src/cluster_remote.rs index 71274aafd..bf72a29e5 100644 --- a/crates/omnigraph-cli/src/cluster_remote.rs +++ b/crates/omnigraph-cli/src/cluster_remote.rs @@ -342,7 +342,13 @@ fn is_wait_timeout(error: &color_eyre::Report, deadline: Instant) -> bool { fn retry_observation(error: &color_eyre::Report) -> bool { error.is::() || error.downcast_ref::().is_some_and(|error| { - error.is_timeout() || error.is_connect() || error.is_body() || error.is_request() + error.is_timeout() + || error.is_connect() + || error.is_body() + // Response::chunk classifies a truncated transport body as + // decoding; parsed JSON/protocol failures are separate errors. + || error.is_decode() + || error.is_request() }) || error.downcast_ref::().is_some_and(|error| { matches!( diff --git a/crates/omnigraph-cli/src/managed.rs b/crates/omnigraph-cli/src/managed.rs index df7610a53..1f9d73a8b 100644 --- a/crates/omnigraph-cli/src/managed.rs +++ b/crates/omnigraph-cli/src/managed.rs @@ -460,7 +460,7 @@ async fn wait_run( let next = Instant::now() + POLL_INTERVAL; if next >= deadline { tokio::time::sleep_until(deadline).await; - eprintln!("wait deadline reached; run {id} continues; inspect `cluster status {id}`"); + eprintln!("wait deadline reached; run {id} continues; inspect `managed status {id}`"); return Ok((body, 5)); } tokio::time::sleep_until(next).await; diff --git a/crates/omnigraph-cli/src/managed/tests.rs b/crates/omnigraph-cli/src/managed/tests.rs index 6be5266ac..87d4e0b48 100644 --- a/crates/omnigraph-cli/src/managed/tests.rs +++ b/crates/omnigraph-cli/src/managed/tests.rs @@ -138,6 +138,26 @@ async fn managed_namespace_is_explicit_and_core_ignores_context() { vec!["cluster", "plan", "--rev", "revision"], vec!["cluster", "status", "run-id"], vec!["cluster", "status", "--operation", "operation-id"], + vec![ + "cluster", + "push", + "--expected-revision", + "rev", + "--message", + "update", + ], + vec!["cluster", "delete", "--incarnation", "inc-one"], + vec![ + "cluster", + "undo-delete", + "--incarnation", + "inc-one", + "--deletion-id", + "delete-one", + ], + vec!["cluster", "token"], + vec!["cluster", "history"], + vec!["cluster", "cancel", "run-id"], vec!["managed", "apply"], vec![ "managed", diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index 83aac500c..f789969da 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -344,6 +344,89 @@ fn core_live_apply_captures_server_file_root_without_local_storage_access() { ); api.assert_complete(); + // Observation is safe to retry after temporary service refusal or a + // truncated response. Apply submits once; standalone status only reads. + for apply in [false, true] { + let mut truncated = IntentReply::json(200, accepted.clone()); + truncated.headers.push(( + "content-length".into(), + (truncated.body.len() + 1).to_string(), + )); + let mut replies = Vec::new(); + if apply { + replies.extend([ + IntentReply::json(200, status.clone()), + IntentReply::json(202, accepted.clone()), + ]); + } + replies.extend([ + IntentReply::json(503, serde_json::json!({"error":"observation changed"})), + IntentReply::json(429, serde_json::json!({"error":"observation throttled"})), + truncated, + IntentReply::json(200, complete.clone()), + ]); + let api = IntentApiFixture::graph(replies); + let mut command = cli(); + command.timeout(std::time::Duration::from_secs(15)).args([ + "cluster", + if apply { "apply" } else { "status" }, + "--server", + &api.origin, + "--deployment-id", + id, + "--timeout", + "10", + "--json", + ]); + if apply { + command.arg("--config").arg(temp.path()); + } else { + command.arg("--wait"); + } + let observed = parse_stdout_json(&output_success(&mut command)); + assert_eq!(observed, complete); + let requests = api.workflow_requests(); + assert_eq!(requests.len(), if apply { 6 } else { 4 }); + assert_eq!( + requests + .iter() + .filter(|request| request.method == "POST") + .count(), + usize::from(apply), + ); + for request in requests.iter().skip(if apply { 2 } else { 0 }) { + assert_eq!(request.method, "GET"); + assert_eq!(request.path, format!("/cluster/deployments/{id}")); + } + api.assert_complete(); + } + + // Complete transport bytes with invalid JSON or a malformed receipt are + // protocol failures, not permission to keep retrying an observation. + let mut invalid_json = IntentReply::json(200, serde_json::json!({})); + invalid_json.body = b"{".to_vec(); + for reply in [invalid_json, IntentReply::json(200, serde_json::json!({}))] { + let api = IntentApiFixture::graph(vec![reply]); + let output = output_failure(cli().args([ + "cluster", + "status", + "--server", + &api.origin, + "--deployment-id", + id, + "--wait", + "--timeout", + "10", + "--json", + ])); + assert_ne!(output.status.code(), Some(5), "{output:?}"); + let requests = api.workflow_requests(); + assert_eq!(requests.len(), 1, "invalid receipt must not be retried"); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, format!("/cluster/deployments/{id}")); + api.assert_complete(); + } + for terminal in [ "not_recorded", "identity_mismatch", @@ -1542,7 +1625,56 @@ fn managed_use_verifies_access_before_writing_context() { let temp = tempdir().unwrap(); let body = serde_json::json!({"data":{"cluster_id":"managed-test","name":"prod"}, "meta":{"cluster_id":"managed-test","assurance":"verified_workload"}}); - let api = IntentApiFixture::new(vec![IntentReply::json(200, body.clone())]); + let status = serde_json::json!({ + "data":{"requested":{"revision":"revision-one"},"effective":{"revision":"revision-one"}}, + "meta":{"cluster_id":"managed-test","provenance":"service_db"} + }); + let run = managed_envelope("apply", "failed"); + let history = serde_json::json!({ + "data":{"runs":[run.clone()]}, + "meta":{"cluster_id":"managed-test","provenance":"service_db"} + }); + let mut reads = Vec::new(); + for (args, path, response) in [ + (vec!["status"], "/v1/clusters/managed-test/status", status), + (vec!["status", "run-one"], "/v1/runs/run-one", run), + ( + vec!["history"], + "/v1/clusters/managed-test/history?limit=100", + history.clone(), + ), + ( + vec![ + "history", + "--limit", + "7", + "--since", + "2026-09-29T12:30:00+02:00", + ], + "/v1/clusters/managed-test/history?limit=7&since=2026-09-29T12%3A30%3A00%2B02%3A00", + history, + ), + ] { + reads.push((args.clone(), path, response.clone(), false)); + let mut foreign = response.clone(); + foreign["meta"]["cluster_id"] = "another-cluster".into(); + reads.push((args.clone(), path, foreign, true)); + if args == ["status", "run-one"] { + for field in ["cluster_id", "run_id"] { + let mut foreign = response.clone(); + foreign["data"][field] = "another-identity".into(); + reads.push((args.clone(), path, foreign, true)); + } + } + } + let replies = std::iter::once(IntentReply::json(200, body.clone())) + .chain( + reads + .iter() + .map(|(_, _, response, _)| IntentReply::json(200, response.clone())), + ) + .collect(); + let api = IntentApiFixture::new(replies); let output = output_success( cli() .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") @@ -1569,6 +1701,42 @@ fn managed_use_verifies_access_before_writing_context() { None, ); assert_no_core_effects(temp.path()); + for (index, (args, path, response, refused)) in reads.into_iter().enumerate() { + let output = managed_cli(temp.path(), &api.origin) + .args(args) + .arg("--json") + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(if refused { 2 } else { 0 }), + "{output:?}" + ); + let received = parse_stdout_json(&output); + if refused { + assert_eq!(received["type"], "context_mismatch"); + } else { + // Observation succeeds even when the inspected run itself failed. + assert_eq!(received, response); + } + let requests = api.requests(); + assert_eq!( + requests.len(), + index + 2, + "no extra requests or submissions" + ); + assert_control_request(&requests[index + 1], "GET", path, Value::Null, None); + assert_no_core_effects(temp.path()); + } + let count = api.requests().len(); + let invalid = managed_cli(temp.path(), &api.origin) + .args(["history", "--since", "not-a-time", "--json"]) + .output() + .unwrap(); + assert_eq!(invalid.status.code(), Some(2)); + assert_eq!(parse_stdout_json(&invalid)["type"], "since_invalid"); + assert_eq!(api.requests().len(), count); + api.assert_complete(); } #[test] @@ -1651,6 +1819,14 @@ fn managed_plan_polls_the_accepted_run_and_timeout_does_not_cancel_it() { parse_stdout_json(&output), if timeout { proposed } else { converged } ); + if timeout { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("inspect `managed status run-one`"), + "{stderr}" + ); + assert!(!stderr.contains("cluster status"), "{stderr}"); + } let requests = api.requests(); assert_eq!(requests.len(), if timeout { 1 } else { 2 }); assert_control_request( @@ -1794,22 +1970,23 @@ fn managed_context_links_and_fifo_refuse_without_blocking_or_core_effects() { fn managed_namespace_is_explicit_and_core_ignores_folder_context() { let temp = tempdir().unwrap(); let api = IntentApiFixture::new(vec![]); - write_cluster_config_fixture(temp.path()); write_managed_context(temp.path(), &api.origin); for context in ["valid", "malformed"] { + let root = temp.path().join(context); + fs::create_dir(&root).unwrap(); + write_cluster_config_fixture(&root); + write_managed_context(&root, &api.origin); + let context_path = root.join(".omnigraph/context"); if context == "malformed" { - fs::write(temp.path().join(".omnigraph/context"), "{\n").unwrap(); + fs::write(&context_path, "{\n").unwrap(); } + let original_context = fs::read(&context_path).unwrap(); for verb in ["validate", "plan", "status"] { - let output = output_success( - cli() - .current_dir(temp.path()) - .args(["cluster", verb, "--json"]), - ); + let output = output_success(cli().current_dir(&root).args(["cluster", verb, "--json"])); assert_eq!(parse_stdout_json(&output)["ok"], true, "{context}: {verb}"); } let observed = cli() - .current_dir(temp.path()) + .current_dir(&root) .args(["cluster", "observe", "--json"]) .output() .unwrap(); @@ -1823,7 +2000,30 @@ fn managed_namespace_is_explicit_and_core_ignores_folder_context() { .iter() .any(|diagnostic| diagnostic["code"] == "state_missing") ); - assert_no_core_effects(temp.path()); + assert_no_core_effects(&root); + let output = output_success( + cli() + .current_dir(&root) + .env("OMNIGRAPH_CONTROL_API", &api.origin) + .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") + .args(["cluster", "apply", "--json"]), + ); + let receipt = parse_stdout_json(&output); + assert_eq!(receipt["status"], "complete", "{context}: {receipt}"); + assert_eq!(receipt["result"]["converged"], true); + assert_eq!( + receipt["result"]["graphs"]["knowledge"]["outcome"], + "created" + ); + assert!(root.join("graphs/knowledge.omni/__manifest").exists()); + let ledger: Value = + serde_json::from_slice(&fs::read(root.join("__cluster/state.json")).unwrap()).unwrap(); + assert_eq!(ledger["deployment_results"][0], receipt["result"]); + assert_eq!(fs::read(&context_path).unwrap(), original_context); + assert!( + api.requests().is_empty(), + "Core apply consulted the managed service" + ); } let child = temp.path().join("nested"); fs::create_dir(&child).unwrap(); diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs index f6695d4f6..a787324a4 100644 --- a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -312,20 +312,30 @@ fn live_apply_changes_schema_queries_and_adds_graph_without_restart(storage_root // Reboot from the durable projection: no source apply is allowed to hide // an activation-only change that was never actually persisted. - let boot_deployment = live_policy_handoff_before_restart(temp.path(), &server); + let boot_receipt = live_policy_handoff_before_restart(temp.path(), &server); + let boot_deployment = boot_receipt["result"]["id"].as_str().unwrap(); server.stop(); unlock(); let server = spawn_server_with_cluster_env(temp.path(), &server_env); - let boot_status = parse_stdout_json(&output_success(live_policy_cli("next-token").args([ - "cluster", - "status", - "--server", - &server.base_url, - "--deployment-id", - &boot_deployment, - "--json", - ]))); + // The initiating actor still observes its exact receipt after its + // management grant is revoked and the original server process is gone. + let boot_status = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--deployment-id", + boot_deployment, + "--wait", + "--timeout", + "20", + "--json", + ]), + )); assert_eq!(boot_status["active"], true, "{boot_status}"); + assert_eq!(boot_status["deployment"], boot_receipt); + assert!(boot_status.get("status").is_none()); live_policy_state_survives_restart(temp.path(), &server); assert_live_graph_absent(&server, "retired"); assert_eq!(live_snapshot(&server, "tools"), retained); @@ -1261,7 +1271,10 @@ fn live_policy_grants_revocations_and_management_handoff( assert_eq!(server.base_url, original_url); } -fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &TestServer) -> String { +fn live_policy_handoff_before_restart( + config_dir: &std::path::Path, + server: &TestServer, +) -> serde_json::Value { let management_path = config_dir.join("live.policy.yaml"); let current = fs::read_to_string(&management_path).unwrap(); let handoff = current.replace("[act-live]", "[act-next]"); @@ -1271,6 +1284,24 @@ fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &Tes ); fs::write(management_path, handoff).unwrap(); let result = live_apply(config_dir, server, "live-deployment-token"); + let receipt = parse_stdout_json(&output_success( + live_policy_cli("live-deployment-token").args([ + "cluster", + "status", + "--server", + &server.base_url, + "--deployment-id", + result["deployment"]["result"]["id"].as_str().unwrap(), + "--wait", + "--timeout", + "20", + "--json", + ]), + )); + assert_eq!(receipt["active"], true, "{receipt}"); + assert_eq!(receipt["deployment"], result["deployment"]); + assert!(receipt.get("status").is_none()); + let current = live_policy_status(server, "next-token"); live_policy_expect_forbidden(live_policy_cli("live-deployment-token").args([ "cluster", "status", @@ -1278,11 +1309,14 @@ fn live_policy_handoff_before_restart(config_dir: &std::path::Path, server: &Tes &server.base_url, "--json", ])); - live_policy_status(server, "next-token"); - result["deployment"]["result"]["id"] - .as_str() - .unwrap() - .to_owned() + live_policy_expect_forbidden( + live_policy_cli("live-deployment-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json"), + ); + assert_eq!(live_policy_status(server, "next-token"), current); + result["deployment"].clone() } fn live_policy_state_survives_restart(config_dir: &std::path::Path, server: &TestServer) { @@ -1297,7 +1331,14 @@ fn live_policy_state_survives_restart(config_dir: &std::path::Path, server: &Tes &server.base_url, "--json", ])); - live_policy_status(server, "next-token"); + let current = live_policy_status(server, "next-token"); + live_policy_expect_forbidden( + live_policy_cli("live-deployment-token") + .args(["cluster", "apply", "--server", &server.base_url, "--config"]) + .arg(config_dir) + .arg("--json"), + ); + assert_eq!(live_policy_status(server, "next-token"), current); let management_path = config_dir.join("live.policy.yaml"); let handoff = fs::read_to_string(&management_path).unwrap(); let restored = handoff.replace("[act-next]", "[act-live]"); diff --git a/crates/omnigraph-cli/tests/support/managed_http.rs b/crates/omnigraph-cli/tests/support/managed_http.rs index f913d6a85..6b431303a 100644 --- a/crates/omnigraph-cli/tests/support/managed_http.rs +++ b/crates/omnigraph-cli/tests/support/managed_http.rs @@ -12,10 +12,10 @@ pub struct IntentApiFixture { requests: std::sync::Arc>>, stop: std::sync::Arc, thread: Option>, - reply_count: usize, + reply_count: Option, graph: bool, session: Option>>, - forwarded_merges: std::sync::Arc>>, + forwarded_responses: std::sync::Arc>>, } #[derive(Debug, Clone)] @@ -44,6 +44,28 @@ pub enum MergeDeliveryFault { CallerWait, } +/// Faults on a real deployment submission; all observation requests pass through. +#[derive(Debug, Clone, Copy)] +pub enum DeploymentDeliveryFault { + PassThrough, + DisconnectBeforeAcceptance, + DisconnectAfterAcceptance, + WaitAfterAcceptance, +} + +enum Forwarding { + Merge(String, MergeDeliveryFault), + Deployment(String, DeploymentDeliveryFault), +} + +impl Forwarding { + fn upstream(&self) -> &str { + match self { + Self::Merge(upstream, _) | Self::Deployment(upstream, _) => upstream, + } + } +} + impl IntentReply { pub fn json(status: u16, body: Value) -> Self { Self { @@ -89,7 +111,17 @@ impl IntentApiFixture { None, Duration::ZERO, true, - Some((upstream.to_owned(), fault)), + Some(Forwarding::Merge(upstream.to_owned(), fault)), + ) + } + + pub fn graph_deployment_proxy(upstream: &str, fault: DeploymentDeliveryFault) -> Self { + Self::start_with_origin( + |_| Vec::new(), + None, + Duration::ZERO, + true, + Some(Forwarding::Deployment(upstream.to_owned(), fault)), ) } @@ -112,7 +144,7 @@ impl IntentApiFixture { session: Option, delay: Duration, graph: bool, - forwarding: Option<(String, MergeDeliveryFault)>, + forwarding: Option, ) -> Self { use std::io::Write; use std::sync::atomic::Ordering; @@ -122,10 +154,10 @@ impl IntentApiFixture { listener.set_nonblocking(true).unwrap(); let origin = format!("http://{}", listener.local_addr().unwrap()); let replies = replies(&origin); - let reply_count = if forwarding.is_some() { - 1 - } else { - replies.len() + let reply_count = match &forwarding { + Some(Forwarding::Merge(..)) => Some(1), + Some(Forwarding::Deployment(..)) => None, + None => Some(replies.len()), }; let requests = Arc::new(Mutex::new(Vec::new())); let received = requests.clone(); @@ -133,8 +165,8 @@ impl IntentApiFixture { let stopped = stop.clone(); let session = session.map(|s| Arc::new(Mutex::new(s))); let session_response = session.clone(); - let forwarded_merges = Arc::new(Mutex::new(Vec::new())); - let captured_merges = forwarded_merges.clone(); + let forwarded_responses = Arc::new(Mutex::new(Vec::new())); + let captured_responses = forwarded_responses.clone(); let thread = std::thread::spawn(move || { let mut replies = std::collections::VecDeque::from(replies); let upstream_client = forwarding.as_ref().map(|_| { @@ -204,7 +236,78 @@ impl IntentApiFixture { if !discovery { sleep(delay); } - let mut reply = if let Some((upstream, fault)) = &forwarding { + let mut reply = if let Some(forwarding) = &forwarding { + let upstream = forwarding.upstream(); + let deployment = + request.method == "POST" && request.path == "/cluster/deployments"; + if deployment + && matches!( + forwarding, + Forwarding::Deployment( + _, + DeploymentDeliveryFault::DisconnectBeforeAcceptance + ) + ) + { + // Write a complete real request, then close the upstream + // socket only after the server owns it and is draining. + // The test retains a graph request so acceptance cannot race ahead. + let url = url::Url::parse(upstream).unwrap(); + assert_eq!(url.scheme(), "http"); + let mut upstream_socket = std::net::TcpStream::connect(( + url.host_str().unwrap(), + url.port_or_known_default().unwrap(), + )) + .unwrap(); + upstream_socket + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + write!(upstream_socket, "POST {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nContent-Length: {}\r\n", request.path, &url[url::Position::BeforeHost..url::Position::AfterPort], request.raw_body.len()).unwrap(); + for (name, value) in &request.headers { + if !matches!(name.as_str(), "host" | "connection" | "content-length") { + write!(upstream_socket, "{name}: {value}\r\n").unwrap(); + } + } + upstream_socket.write_all(b"\r\n").unwrap(); + upstream_socket.write_all(&request.raw_body).unwrap(); + let id = request.body["deployment_id"].as_str().unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + assert!( + std::time::Instant::now() < deadline, + "server must own the pre-acceptance request" + ); + let mut status = upstream_client + .as_ref() + .unwrap() + .get(format!("{upstream}/cluster/deployments/{id}")) + .timeout(Duration::from_secs(2)) + .header( + omnigraph_api_types::HTTP_API_CONTRACT_HEADER, + omnigraph_api_types::HTTP_API_CONTRACT, + ); + if let Some(token) = request.headers.get("authorization") { + status = status.header("authorization", token); + } + let status: Value = status + .send() + .unwrap() + .error_for_status() + .unwrap() + .json() + .unwrap(); + if status["in_progress"] == true { + assert_eq!( + status["deployment"]["status"], "not_recorded", + "{status}" + ); + break; + } + sleep(Duration::from_millis(2)); + } + upstream_socket.shutdown(std::net::Shutdown::Both).unwrap(); + continue; + } let mut forwarded = upstream_client.as_ref().unwrap().request( request.method.parse::().unwrap(), format!("{}{}", upstream.trim_end_matches('/'), request.path), @@ -243,12 +346,13 @@ impl IntentApiFixture { headers, body, }; - if request.method == "POST" + if let Forwarding::Merge(_, fault) = forwarding + && request.method == "POST" && (request.path.ends_with("/branches/merge") || request.path.ends_with("/mutate")) { assert_eq!(reply.status, 200, "upstream merge must succeed"); - captured_merges.lock().unwrap().push(reply.clone()); + captured_responses.lock().unwrap().push(reply.clone()); match fault { MergeDeliveryFault::Disconnect => continue, MergeDeliveryFault::Truncate => { @@ -273,6 +377,28 @@ impl IntentApiFixture { } } } + if deployment && let Forwarding::Deployment(_, fault) = forwarding { + assert!( + matches!(reply.status, 200 | 202), + "upstream acceptance: {}", + String::from_utf8_lossy(&reply.body) + ); + captured_responses.lock().unwrap().push(reply.clone()); + match fault { + DeploymentDeliveryFault::DisconnectAfterAcceptance => continue, + DeploymentDeliveryFault::WaitAfterAcceptance => { + let until = std::time::Instant::now() + Duration::from_secs(4); + while !stopped.load(Ordering::SeqCst) + && std::time::Instant::now() < until + { + sleep(Duration::from_millis(2)); + } + continue; + } + DeploymentDeliveryFault::PassThrough => {} + DeploymentDeliveryFault::DisconnectBeforeAcceptance => unreachable!(), + } + } reply } else if discovery { IntentReply::json(200, serde_json::json!({"status":"ok"})) @@ -324,7 +450,7 @@ impl IntentApiFixture { reply_count, graph, session, - forwarded_merges, + forwarded_responses, } } @@ -332,8 +458,8 @@ impl IntentApiFixture { self.requests.lock().unwrap().clone() } - pub fn forwarded_merges(&self) -> Vec { - self.forwarded_merges.lock().unwrap().clone() + pub fn forwarded_responses(&self) -> Vec { + self.forwarded_responses.lock().unwrap().clone() } pub fn workflow_requests(&self) -> Vec { @@ -353,11 +479,23 @@ impl IntentApiFixture { } pub fn assert_complete(&self) { - assert_eq!( - self.workflow_requests().len(), - self.reply_count, - "HTTP fixture request/reply count" - ); + if let Some(expected) = self.reply_count { + assert_eq!( + self.workflow_requests().len(), + expected, + "HTTP fixture request/reply count" + ); + } else { + assert_eq!( + self.requests() + .iter() + .filter(|request| request.method == "POST" + && request.path == "/cluster/deployments") + .count(), + 1, + "a deployment is submitted once, regardless of lost delivery" + ); + } } } diff --git a/crates/omnigraph-cli/tests/system_remote.rs b/crates/omnigraph-cli/tests/system_remote.rs index 67aaa7f1d..7f94b1abe 100644 --- a/crates/omnigraph-cli/tests/system_remote.rs +++ b/crates/omnigraph-cli/tests/system_remote.rs @@ -648,7 +648,7 @@ fn remote_merge_delivery_loss_never_replays_committed_effect() { ); } - let captured = proxy.forwarded_merges(); + let captured = proxy.forwarded_responses(); assert_eq!( captured.len(), 1, @@ -724,6 +724,280 @@ fn remote_merge_delivery_loss_never_replays_committed_effect() { } } +#[test] +fn remote_deployment_delivery_loss_preserves_owned_completion() { + use std::io::{BufRead, BufReader, Write}; + use std::net::{Shutdown, TcpStream}; + use std::time::{Duration, Instant}; + use support::managed_http::{DeploymentDeliveryFault, IntentApiFixture}; + + const TOKEN: &str = "deployment-wire-token"; + let cluster = converged_loaded_cluster(GRAPH_ID, None); + for (file, actions) in [ + ("operator", "config_manage"), + ("graph", "schema_apply, read, change, invoke_query"), + ] { + fs::write(cluster.path().join(format!("{file}.policy.yaml")), format!( + "version: 1\ngroups:\n operators: [act-cluster-test]\nrules:\n - id: operator\n allow:\n actors: {{ group: operators }}\n actions: [{actions}]\n" + )).unwrap(); + } + let config_path = cluster.path().join("cluster.yaml"); + let config = fs::read_to_string(&config_path).unwrap(); + fs::write(&config_path, format!("{config}policies:\n operator:\n file: ./operator.policy.yaml\n applies_to: [cluster]\n graph:\n file: ./graph.policy.yaml\n applies_to: [{GRAPH_ID}]\n")).unwrap(); + apply_cluster_fixture(cluster.path()); + let server = spawn_server_with_cluster_env( + cluster.path(), + &[( + "OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", + r#"{"act-cluster-test":"deployment-wire-token"}"#, + )], + ); + let pid = server.id(); + let client = graph_http_client(); + let get = |path: &str| { + client + .get(format!("{}{path}", server.base_url)) + .bearer_auth(TOKEN) + .timeout(Duration::from_secs(3)) + .send() + .unwrap() + .error_for_status() + .unwrap() + .json::() + .unwrap() + }; + + for (index, (fault, before_acceptance, times_out)) in [ + (DeploymentDeliveryFault::PassThrough, true, true), + ( + DeploymentDeliveryFault::DisconnectBeforeAcceptance, + true, + false, + ), + (DeploymentDeliveryFault::WaitAfterAcceptance, false, true), + ( + DeploymentDeliveryFault::DisconnectAfterAcceptance, + false, + false, + ), + ] + .into_iter() + .enumerate() + { + let before: omnigraph_cluster::DeploymentStatus = + serde_json::from_value(get("/cluster/deployments")["status"].clone()).unwrap(); + let id = before.next_deployment_id(); + let receipt_path = format!("/cluster/deployments/{id}"); + let commits_path = format!("/graphs/{GRAPH_ID}/commits?branch=main"); + let before_commits = get(&commits_path); + let schema_path = cluster.path().join("graph.pg"); + let property = format!("wire_{index}"); + let schema = fs::read_to_string(&schema_path).unwrap().replace( + "node Person {", + &format!("node Person {{\n {property}: String?"), + ); + fs::write(&schema_path, &schema).unwrap(); + + // A real admitted request holds the graph during drain. Hyper sends + // 100 Continue only when admission starts polling this incomplete body; + // no sleep or production test hook decides when deployment may proceed. + let held = before_acceptance.then(|| { + let url = url::Url::parse(&server.base_url).unwrap(); + let mut socket = TcpStream::connect((url.host_str().unwrap(), url.port().unwrap())).unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + socket.set_write_timeout(Some(Duration::from_secs(3))).unwrap(); + write!(socket, "POST /graphs/{GRAPH_ID}/query HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {TOKEN}\r\n{}: {}\r\nContent-Type: application/json\r\nContent-Length: 100\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n", &url[url::Position::BeforeHost..url::Position::AfterPort], omnigraph_api_types::HTTP_API_CONTRACT_HEADER, omnigraph_api_types::HTTP_API_CONTRACT).unwrap(); + let mut reader = BufReader::new(socket.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(line, "HTTP/1.1 100 Continue\r\n", "{fault:?}: {line}"); + line.clear(); + reader.read_line(&mut line).unwrap(); + assert_eq!(line, "\r\n"); + socket + }); + let proxy = IntentApiFixture::graph_deployment_proxy(&server.base_url, fault); + let mut command = cli(); + command + .env("OMNIGRAPH_BEARER_TOKEN", TOKEN) + .args(["cluster", "apply", "--config"]) + .arg(cluster.path()) + .args([ + "--server", + &proxy.origin, + "--deployment-id", + &id, + "--timeout", + if times_out { "3" } else { "15" }, + "--json", + ]) + .timeout(Duration::from_secs(20)); + let output = std::thread::scope(|scope| { + let caller = scope.spawn(move || command.output().unwrap()); + if before_acceptance { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let observation = get(&receipt_path); + if observation["in_progress"] == true { + assert_eq!( + observation["deployment"]["status"], "not_recorded", + "{fault:?}: {observation}" + ); + break; + } + assert!( + Instant::now() < deadline, + "{fault:?}: server must own submission; {}", + server.stderr() + ); + std::thread::sleep(Duration::from_millis(5)); + } + let during = get("/cluster/deployments"); + assert_eq!(during["status"]["next_sequence"], before.next_sequence); + assert_eq!(during["status"]["state_revision"], before.state_revision); + assert_eq!(during["status"]["outstanding_id"], serde_json::Value::Null); + if !times_out { + // The proxy has now severed the upstream POST. Require the + // actual CLI to start observing before releasing the graph. + while !proxy + .requests() + .iter() + .any(|request| request.method == "GET" && request.path == receipt_path) + { + assert!( + Instant::now() < deadline, + "CLI must observe its disconnected submission" + ); + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(get(&receipt_path)["deployment"]["status"], "not_recorded"); + } + } + if times_out { + if !before_acceptance { + let deadline = Instant::now() + Duration::from_secs(3); + while proxy.forwarded_responses().is_empty() { + assert!( + !caller.is_finished(), + "caller must still wait when durable acceptance is confirmed" + ); + assert!( + Instant::now() < deadline, + "upstream must acknowledge before caller timeout" + ); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(!caller.is_finished(), "acceptance must precede timeout"); + } + let output = caller.join().unwrap(); + assert_eq!(output.status.code(), Some(5), "{fault:?}: {output:?}"); + let timeout = parse_stdout_json(&output); + assert_eq!(timeout["outcome"], "wait_timeout"); + assert_eq!(timeout["deployment_id"], id); + if before_acceptance { + assert_eq!(get(&receipt_path)["deployment"]["status"], "not_recorded"); + } + if let Some(held) = held { + held.shutdown(Shutdown::Both).unwrap(); + } + output + } else { + if let Some(held) = held { + held.shutdown(Shutdown::Both).unwrap(); + } + let output = caller.join().unwrap(); + assert!( + output.status.success(), + "{fault:?}: {output:?}; {}", + server.stderr() + ); + output + } + }); + let receipt = if times_out { + parse_stdout_json(&output_success( + cli() + .env("OMNIGRAPH_BEARER_TOKEN", TOKEN) + .args([ + "cluster", + "status", + "--server", + &proxy.origin, + "--deployment-id", + &id, + "--wait", + "--timeout", + "15", + "--json", + ]) + .timeout(Duration::from_secs(20)), + )) + } else { + parse_stdout_json(&output) + }; + assert_eq!(receipt["active"], true, "{fault:?}: {receipt}"); + assert_eq!(receipt["deployment"]["result"]["id"], id); + // Activation can be observed before the owner's final drop. Wait for + // settlement without mistaking that harmless window for a second run. + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let settled = get(&receipt_path); + assert_eq!(settled["deployment"], receipt["deployment"]); + assert_eq!(settled["active"], true); + if settled["in_progress"] == false { + break; + } + assert!(Instant::now() < deadline, "owner must finish: {settled}"); + std::thread::sleep(Duration::from_millis(5)); + } + let after = get("/cluster/deployments"); + assert_eq!(after["status"]["next_sequence"], before.next_sequence + 1); + assert_eq!( + after["status"]["result_revision"], + before.result_revision + 1 + ); + assert_eq!(after["status"]["outstanding_id"], serde_json::Value::Null); + assert_eq!(server.id(), pid); + assert_eq!( + get(&format!("/graphs/{GRAPH_ID}/schema"))["schema_source"], + schema + ); + assert_eq!( + get(&commits_path)["commits"].as_array().unwrap().len(), + before_commits["commits"].as_array().unwrap().len() + 1 + ); + let response = client.post(format!("{}/graphs/{GRAPH_ID}/query", server.base_url)) + .timeout(Duration::from_secs(3)) + .bearer_auth(TOKEN).json(&json!({"query": format!("query inspect() {{ match {{ $p: Person {{ name: \"Alice\" }} }} return {{ $p.name, $p.{property} }} }}")})) + .send().unwrap(); + let status = response.status(); + let rows = response.json::().unwrap(); + assert!(status.is_success(), "{fault:?}: {rows}"); + assert_eq!(rows["row_count"], 1); + assert_eq!(rows["rows"][0]["p.name"], "Alice"); + assert_eq!( + rows["rows"][0][format!("p.{property}")], + serde_json::Value::Null + ); + proxy.assert_complete(); + for request in proxy.workflow_requests() { + assert!( + request.method == "POST" && request.path == "/cluster/deployments" + || request.method == "GET" + && (request.path == "/cluster/deployments" || request.path == receipt_path), + "unexpected request: {request:?}" + ); + } + if !before_acceptance { + assert_eq!( + proxy.forwarded_responses().len(), + 1, + "fault must follow real durable acceptance" + ); + } + } +} + #[test] fn remote_branch_delete_removes_branch() { let cluster = converged_loaded_cluster(GRAPH_ID, None); diff --git a/crates/omnigraph-server/src/deployment.rs b/crates/omnigraph-server/src/deployment.rs index 44a393b38..ecd18e3f7 100644 --- a/crates/omnigraph-server/src/deployment.rs +++ b/crates/omnigraph-server/src/deployment.rs @@ -3,6 +3,7 @@ //! only request lifetime, graph admission and activation of serving bindings. use std::collections::{BTreeMap, HashMap}; +use std::future::Future; use std::sync::{Arc, Mutex as SyncMutex, PoisonError}; use axum::{ @@ -50,6 +51,27 @@ impl DeploymentRuntime { ) } + async fn observe( + &self, + id: Option<&str>, + mut read: impl FnMut() -> F, + ) -> Result<(T, bool), ApiError> + where + F: Future>, + { + for _ in 0..3 { + let (generation, _) = self.observation(id); + let observed = read().await?; + let (observed_generation, in_progress) = self.observation(id); + if generation == observed_generation { + return Ok((observed, in_progress)); + } + // An owner starting or finishing during the read can make an old + // ledger snapshot appear ownerless. Retry this read, never apply. + } + Err(observation_changed()) + } + // The deployment gate is already held; this is a derived process observation, // never durable execution permission or a second deployment queue. fn own(self: &Arc, id: String) -> RunningDeployment { @@ -289,25 +311,19 @@ pub(crate) async fn lookup( ) -> Result, ApiError> { let owner = admission(&state)?; let identity = IdentityAuthorization::authenticated(actor.actor_id_str()).map_err(refusal)?; - for _ in 0..3 { - let (generation, _) = state.deployments.observation(Some(&id)); - let (deployment, revision) = + let ((deployment, revision), in_progress) = state + .deployments + .observe(Some(&id), || async { omnigraph_cluster::deployment_receipt(owner.canonical_root(), &id, &identity) .await - .map_err(refusal)?; - let (observed_generation, in_progress) = state.deployments.observation(Some(&id)); - if generation != observed_generation { - // An owner starting or finishing during the read can make an old - // ledger snapshot appear ownerless. Retry this read, never apply. - continue; - } - return Ok(Json(DeploymentResponse { - active: active_result(&state, Some(&deployment), revision), - deployment, - in_progress, - })); - } - Err(observation_changed()) + .map_err(refusal) + }) + .await?; + Ok(Json(DeploymentResponse { + active: active_result(&state, Some(&deployment), revision), + deployment, + in_progress, + })) } async fn lookup_status( @@ -317,23 +333,20 @@ async fn lookup_status( ) -> Result { let caller = caller(state, actor)?; let owner = admission(state)?; - for _ in 0..3 { - let (generation, _) = state.deployments.observation(id); - let status = omnigraph_cluster::deployment_status(owner.canonical_root(), id, &caller) - .await - .map_err(refusal)?; - let (observed_generation, in_progress) = state.deployments.observation(id); - if generation != observed_generation { - continue; - } - let active = active_result(state, status.lookup.as_ref(), status.result_revision); - return Ok(DeploymentStatusResponse { - status, - active, - in_progress, - }); - } - Err(observation_changed()) + let (status, in_progress) = state + .deployments + .observe(id, || async { + omnigraph_cluster::deployment_status(owner.canonical_root(), id, &caller) + .await + .map_err(refusal) + }) + .await?; + let active = active_result(state, status.lookup.as_ref(), status.result_revision); + Ok(DeploymentStatusResponse { + status, + active, + in_progress, + }) } #[utoipa::path( @@ -804,3 +817,126 @@ fn abort_before_effects( )), } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::response::IntoResponse; + + // The same observer serves aggregate status and exact receipts. Suspend its + // read after capturing old ledger data, then cross real owner boundaries. + #[tokio::test] + async fn deployment_observation_rechecks_owner_start_finish_and_complete_turnover() { + for change in ["start", "finish", "start_and_finish"] { + for exact in [false, true] { + let runtime = Arc::new(DeploymentRuntime::default()); + let _gate = runtime.gate.clone().try_lock_owned().unwrap(); + let mut owner = (change == "finish").then(|| runtime.own("original".into())); + let (reading, read_started) = oneshot::channel(); + let (release, released) = oneshot::channel(); + let mut pause = Some((reading, released)); + let mut reads = 0; + let stale = if change == "finish" { + "outstanding" + } else { + "not_recorded" + }; + let fresh = if change == "start" { + "outstanding" + } else { + "complete" + }; + let observe = runtime.observe(exact.then_some("original"), || { + reads += 1; + let pause = pause.take(); + let captured = if pause.is_some() { stale } else { fresh }; + async move { + if let Some((reading, released)) = pause { + reading.send(()).unwrap(); + released.await.unwrap(); + } + Ok(captured) + } + }); + let transition = async { + read_started.await.unwrap(); + match change { + "start" => owner = Some(runtime.own("original".into())), + "finish" => drop(owner.take()), + "start_and_finish" => drop(runtime.own("original".into())), + _ => unreachable!(), + } + release.send(()).unwrap(); + }; + let (observed, ()) = tokio::join!(observe, transition); + let (observed, in_progress) = observed.unwrap(); + assert_eq!(reads, 2, "{change}, exact={exact}"); + assert_eq!(observed, fresh, "a stale ledger read must never escape"); + assert_eq!(in_progress, change == "start"); + if change == "start" { + for (scope, expected) in [ + (None, true), + (Some("original"), true), + (Some("other"), false), + ] { + let (receipt, in_progress) = runtime + .observe(scope, || std::future::ready(Ok("retained receipt"))) + .await + .unwrap(); + assert_eq!(receipt, "retained receipt"); + assert_eq!(in_progress, expected, "scope={scope:?}"); + } + } + drop(owner); + } + } + } + + #[tokio::test] + async fn deployment_observation_refuses_after_three_owner_changes_without_terminal_receipt() { + for id in [None, Some("original")] { + let runtime = Arc::new(DeploymentRuntime::default()); + let _gate = runtime.gate.clone().try_lock_owned().unwrap(); + let mut reads = 0; + let result = runtime + .observe(id, || { + reads += 1; + drop(runtime.own("original".into())); + std::future::ready(Ok("stale terminal receipt")) + }) + .await; + let error = result.expect_err("churn must not produce a terminal observation"); + assert_eq!(reads, 3, "no fourth storage read is permitted"); + assert!( + !error.completion_uncertain, + "a refused read cannot poison write admission" + ); + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + body["error"] + .as_str() + .unwrap() + .contains("repeat this status request") + ); + assert!(body.get("deployment").is_none()); + assert!(body.get("active").is_none()); + assert!(body.get("in_progress").is_none()); + + let mut reads = 0; + let stable = runtime + .observe(id, || { + reads += 1; + std::future::ready(Ok("fresh receipt")) + }) + .await + .unwrap(); + assert_eq!(stable, ("fresh receipt", false)); + assert_eq!(reads, 1, "a later stable observation remains usable"); + } + } +} diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index 09afadca5..b47dfac9b 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -2351,6 +2351,87 @@ async fn live_deployment_fixture(mode: &str) { }) .await .unwrap(); + // A schema plan observes the accepted contract while this merge owns + // the shared schema gate. It must not queue an exclusive graph open. + let schema_path = temp.path().join("people.pg"); + let original_source = fs::read_to_string(&schema_path).unwrap(); + fs::write( + &schema_path, + "node Person { name: String @key bio: String? }\n", + ) + .unwrap(); + let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); + let owners_before_plan = state.operation_runtime().snapshot(); + let plan_request = Request::post("/cluster/plan") + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .header("authorization", "Bearer operator-token") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "deployment": candidate, + })) + .unwrap(), + )) + .unwrap(); + let (code, plan) = + tokio::time::timeout(Duration::from_secs(5), json_response(&app, plan_request)) + .await + .expect("schema planning must finish while the merge remains held"); + assert_eq!(code, StatusCode::OK, "{plan}"); + assert_eq!(plan["input_digest"], candidate.input_digest().unwrap()); + assert_eq!( + plan["ok"], false, + "the live feature branch still prevents schema apply" + ); + assert!( + plan["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| { + diagnostic["code"] == "schema_preflight_failed" + && diagnostic["message"] + .as_str() + .unwrap() + .contains("requires only main") + }), + "{plan}" + ); + assert!( + plan["changes"].as_array().unwrap().iter().any(|change| { + change["resource"] == "schema.knowledge" && change["migration"].is_object() + }), + "{plan}" + ); + assert!( + !merger.is_finished(), + "planning must not release the parked writer" + ); + assert!(!hold.timed_out()); + assert_eq!(state.operation_runtime().snapshot(), owners_before_plan); + assert_eq!( + fs::read(temp.path().join("__cluster/state.json")).unwrap(), + before + ); + assert_eq!(original_engine.schema_contract_digest(), original_contract); + let RegistryLookup::Ready(after_plan) = state.routing().registry.get(&key) else { + panic!("planning must leave graph admission ready"); + }; + assert!( + Arc::ptr_eq(&original, &after_plan), + "planning must preserve the installed binding" + ); + let (code, _) = tokio::time::timeout( + Duration::from_secs(5), + json_response( + &app, + get_request("/graphs/knowledge/snapshot", "operator-token"), + ), + ) + .await + .expect("graph reads stay admitted during planning and the held merge"); + assert_eq!(code, StatusCode::OK); + fs::write(&schema_path, original_source).unwrap(); fs::write(temp.path().join("people.gq"), "// query-only deployment\nquery find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }\n").unwrap(); let deployment = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); let deploy_app = app.clone(); diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 7ca194fb5..3677e04fa 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -125,7 +125,11 @@ The guards pin only substrate behavior OmniGraph actually depends on: version an CLI `system_remote` runs the actual CLI, server and fault proxy in the ordinary workspace gate; its required-cell checks reject removed or ignored cases. The lost-delivery matrix covers disconnect, truncated response, proxy 504 and caller -timeout without replaying a committed merge. +timeout without replaying a committed merge. Its deployment matrix holds a real +admitted request through drain, disconnects or times out the caller before +durable acceptance, and loses replies after acceptance. Exact-ID observation +must finish with one ledger result, one schema publication and the same server +PID; neither apply nor recovery polling may resubmit. Server suites are organized by public route: `auth_policy`, `data_routes`, `schema_routes`, `stored_queries`, `multi_graph`, `boot_settings`, object-store coverage in `s3`, and the generated contract in `openapi`. @@ -139,10 +143,17 @@ availability. The same owners cover coherent schema/query batch activation; thes are not generic native settlement. Server `boot_settings` exercises authenticated submission, parked requests, caller disconnect, pre-effect refusal, durable acceptance before completion, activation-in-progress observation and exact receipt -access after management handoff. CLI `cli_cluster` owns submit-once polling, -lost-response observation, terminal outcomes and caller timeout without replay. +access after management handoff. It also holds a merge across served schema +planning to prove preview does not wait for the graph gate. In-source +`deployment` tests suspend observer read futures across owner start, finish and complete +turnover, exercising bounded re-observation for aggregate and exact status. +CLI `cli_cluster` owns submit-once polling, transient 429/503 and truncated-body +retries, malformed-receipt refusal, terminal outcomes and caller timeout without +replay. Its managed fixtures cover status/history scope and filters, while +direct apply must ignore both valid and malformed managed folder context. CLI `cli_cluster_e2e` proves one PID/listener survives schema/query -replacement, graph addition, policy grant/revocation and management handoff. +replacement, graph addition, policy grant/revocation and management handoff; +the original submitter retains only its exact receipt access after restart. Extend that same journey for graph deletion, proving target storage/history removal, peer preservation and unchanged PID/listener, including a served deletion preview followed by `--no-wait` submission and exact-ID `status --wait`. It checks diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index 21e2fce74..31babbc3b 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -131,7 +131,9 @@ cluster status retains its aggregate `status` object. An authenticated submitter can read its own durable receipt even after its deployment removes its management permission; general status and later deployments still require current permission. Lost submission responses are followed only by original-ID reads, never automatic -resubmission. Expired receipts and unknown outcomes require investigation. +resubmission. While waiting, observation retries transient HTTP 429/503 responses +and interrupted response bodies within the same budget; malformed receipts fail immediately. +Expired receipts and unknown outcomes require investigation. Each graph publishes atomically; deployment across multiple graphs is not one transaction. Query-only changes create no graph From 1cd99ec8ac746bd24c511d832255f99ee0a57021 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 6 Oct 2026 23:16:04 +0300 Subject: [PATCH 10/12] refactor(cli): select managed cluster operations with explicit flag --- .../deployment-observation-retries.fixed.md | 2 +- changelog.d/explicit-managed-cli.breaking.md | 2 +- crates/omnigraph-cli/src/cli.rs | 113 ++++------- crates/omnigraph-cli/src/cluster_remote.rs | 19 +- crates/omnigraph-cli/src/main.rs | 108 ++++++++++- crates/omnigraph-cli/src/managed.rs | 83 ++++---- crates/omnigraph-cli/src/managed/data.rs | 37 ++-- .../omnigraph-cli/src/managed/data/tests.rs | 16 +- crates/omnigraph-cli/src/managed/lifecycle.rs | 57 +++--- .../src/managed/lifecycle/pending.rs | 2 +- crates/omnigraph-cli/src/managed/tests.rs | 171 +++++++++++++--- crates/omnigraph-cli/src/planes.rs | 29 ++- crates/omnigraph-cli/tests/cli_cluster.rs | 183 +++++++++++++++--- crates/omnigraph-cli/tests/cli_cluster_e2e.rs | 2 +- crates/omnigraph-cli/tests/support/mod.rs | 2 +- docs/dev/control-plane.md | 5 + docs/dev/testing.md | 5 +- docs/rfcs/0052-managed-control-plane-cli.md | 58 ++++-- .../0053-offline-data-token-verification.md | 9 +- docs/rfcs/0061-managed-cluster-lifecycle.md | 28 +-- ...identity-credentials-and-applied-policy.md | 11 +- ...29-server-runtime-and-online-deployment.md | 16 +- docs/user/cli/index.md | 15 +- docs/user/cli/managed-data.md | 10 +- docs/user/cli/managed-lifecycle.md | 20 +- docs/user/cli/reference.md | 36 ++-- skills/omnigraph/SKILL.md | 16 +- skills/omnigraph/references/cluster.md | 29 +-- skills/omnigraph/references/commands.md | 30 +-- 29 files changed, 753 insertions(+), 361 deletions(-) diff --git a/changelog.d/deployment-observation-retries.fixed.md b/changelog.d/deployment-observation-retries.fixed.md index fca89077a..8c840df8c 100644 --- a/changelog.d/deployment-observation-retries.fixed.md +++ b/changelog.d/deployment-observation-retries.fixed.md @@ -1 +1 @@ -- Served deployment observation retries truncated HTTP response bodies within the caller's wait budget, without resubmitting the deployment. Malformed receipts still fail immediately. Managed wait timeouts now point to `managed status` for recovery. +- Served deployment observation retries truncated HTTP response bodies within the caller's wait budget, without resubmitting the deployment. Malformed receipts still fail immediately. Managed wait timeouts now point to `cluster status --managed` for recovery. diff --git a/changelog.d/explicit-managed-cli.breaking.md b/changelog.d/explicit-managed-cli.breaking.md index 823e332bd..a4d8a08f9 100644 --- a/changelog.d/explicit-managed-cli.breaking.md +++ b/changelog.d/explicit-managed-cli.breaking.md @@ -1,3 +1,3 @@ -- Managed service commands now use `omnigraph managed …`; self-hosted `cluster …` commands always ignore managed folder context. Use `managed operation ID` for service lifecycle observation and `managed status [RUN_ID]` for run status. The former managed `cluster` spellings are rejected; data-command context and credential behavior are unchanged. See the [CLI reference][explicit-managed-cli]. +- Managed service commands now require `omnigraph cluster --managed`; without that flag, cluster commands ignore managed folder context. Use `cluster operation --managed ID` for service lifecycle observation and `cluster status --managed [RUN_ID]` for run status. The top-level `managed` command is removed; data-command context and credential behavior are unchanged. See the [CLI reference][explicit-managed-cli]. [explicit-managed-cli]: ../docs/user/cli/reference.md#managed-cluster-commands diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 28713e0e2..e2dee2172 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -20,9 +20,9 @@ served — require a server: graphs (registry scope).\n \ direct — direct storage access; reject --server (init, upgrade, optimize, rebuild-full-text-indexes, \ repair, cleanup, schema plan/apply, lint).\n \ control — manage or inspect a cluster (--config for bundles; explicit --cluster roots for \ -self-hosted deployment/recovery; policy & queries via --cluster).\n \ +self-hosted deployment/recovery; --managed for the managed service; policy & queries via --cluster).\n \ local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\ -MANAGED FOLDERS: managed commands use .omnigraph/context; data commands acquire identity credentials automatically.\n\ +MANAGED FOLDERS: cluster --managed uses .omnigraph/context; data commands acquire identity credentials automatically.\n\ Implicit query, mutate, load and commit list/show use folder context and require --graph.\n\ Explicit target selectors retain ordinary addressing; competing ambient targets refuse.\n\ --direct selects ordinary addressing, including operator profiles and defaults.\n\ @@ -365,16 +365,15 @@ pub(crate) enum Command { }, // ── Control plane ── manage a cluster directory (--config ). - /// Deploy and inspect a self-hosted cluster through its server or storage root. + /// Deploy and inspect a cluster; --managed selects its managed service API. Cluster { + /// Use the managed service and the selected folder's .omnigraph/context. + /// Never inferred from context; incompatible with other target selectors. + #[arg(long, global = true)] + managed: bool, #[command(subcommand)] command: ClusterCommand, }, - /// Manage the selected folder's cluster through its managed service API. - Managed { - #[command(subcommand)] - command: ManagedCommand, - }, /// Policy administration and diagnostics against a cluster's applied bundles Policy { @@ -530,18 +529,25 @@ pub(crate) enum ClusterCommand { #[arg(long)] json: bool, }, - /// Produce a read-only plan against applied state, locally or through --server. + /// Preview local configuration against storage or --server; with --managed, + /// plan a pushed revision (--rev, or the bound head). Plan { - /// Directory containing cluster.yaml. + /// Directory containing cluster.yaml or the managed folder context. #[arg(long, default_value = ".")] config: PathBuf, /// Emit JSON. #[arg(long)] json: bool, + /// With --managed, select an exact pushed revision. + #[arg(long = "rev")] + revision: Option, + #[command(flatten)] + run: ClusterRunArgs, }, /// Apply a captured bundle. --server submits and waits for live activation; /// direct storage apply requires stopped serving. With --cluster ROOT and /// --deployment-id, reconcile the original deployment without source files. + /// With --managed, apply an exact saved --plan instead. Apply { /// Directory containing cluster.yaml. #[arg(long, default_value = ".")] @@ -555,17 +561,18 @@ pub(crate) enum ClusterCommand { /// Attest prior writers and accepted graph/control I/O are quiescent. #[arg(long, requires = "deployment_id")] writers_stopped: bool, - /// With --server, return after durable acceptance without waiting for completion. + /// With --managed, the exact saved plan run to apply (required). #[arg(long)] - no_wait: bool, - /// With --server, bound the local wait (default 300s, maximum 3600s). - /// Expiry does not cancel accepted work. - #[arg(long, value_parser = clap::value_parser!(u64).range(1..=3600))] - timeout: Option, + plan: Option, + #[command(flatten)] + run: ClusterRunArgs, }, /// Read deployment status without scanning graphs. --deployment-id selects /// an exact durable receipt through --server or --cluster ROOT. + /// With --managed, read cluster projections or a positional RUN_ID. Status { + /// With --managed, inspect this exact run instead of cluster projections. + run_id: Option, #[arg(long)] deployment_id: Option, /// With --server, wait for this deployment's outcome. @@ -612,11 +619,7 @@ pub(crate) enum ClusterCommand { #[arg(long)] json: bool, }, -} - -#[derive(Debug, Subcommand)] -pub(crate) enum ManagedCommand { - /// Create an empty managed cluster and bind an unbound folder to its identity. + /// With --managed, create an empty cluster and bind an unbound folder to its identity. Create { name: String, #[arg(long)] @@ -628,9 +631,9 @@ pub(crate) enum ManagedCommand { #[arg(long)] json: bool, #[command(flatten)] - managed: ManagedRunArgs, + run: ClusterRunArgs, }, - /// Upload only referenced configuration files to the managed repository. + /// With --managed, upload only referenced configuration files to the managed repository. Push { #[arg(long)] expected_revision: String, @@ -643,7 +646,7 @@ pub(crate) enum ManagedCommand { #[arg(long)] json: bool, }, - /// Delete this exact managed incarnation; nonzero retention waits to tombstone. + /// With --managed, delete this exact incarnation; nonzero retention waits to tombstone. Delete { #[arg(long)] incarnation: String, @@ -656,9 +659,9 @@ pub(crate) enum ManagedCommand { #[arg(long)] json: bool, #[command(flatten)] - managed: ManagedRunArgs, + run: ClusterRunArgs, }, - /// Undo an exact retained deletion through the ordinary managed bootstrap. + /// With --managed, undo an exact retained deletion through the ordinary managed bootstrap. UndoDelete { #[arg(long)] incarnation: String, @@ -671,9 +674,9 @@ pub(crate) enum ManagedCommand { #[arg(long)] json: bool, #[command(flatten)] - managed: ManagedRunArgs, + run: ClusterRunArgs, }, - /// Cache an identity credential for this cluster, or forget it locally. + /// With --managed, cache an identity credential for this cluster, or forget it locally. Token { /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] @@ -688,46 +691,7 @@ pub(crate) enum ManagedCommand { #[arg(long)] clear: bool, }, - /// Plan an exact pushed revision through the managed service. - Plan { - /// Configuration folder containing the managed context. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - /// Select a pushed revision; omission uses the bound head. - #[arg(long = "rev")] - revision: Option, - #[command(flatten)] - managed: ManagedRunArgs, - }, - /// Apply an exact saved plan run through the managed service. - Apply { - /// Configuration folder containing the managed context. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - /// Exact saved plan run to apply. - #[arg(long)] - plan: String, - #[command(flatten)] - managed: ManagedRunArgs, - }, - /// Read managed cluster projections or inspect an exact run. - Status { - /// Inspect this run instead of the cluster projections. - run_id: Option, - /// Configuration folder containing the managed context. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Inspect or wait for a managed service lifecycle operation. + /// With --managed, inspect or wait for a service lifecycle operation. Operation { /// Exact service lifecycle operation. operation_id: String, @@ -747,7 +711,7 @@ pub(crate) enum ManagedCommand { #[arg(long)] json: bool, }, - /// Read managed run history with its provenance and outcomes. + /// With --managed, read run history with its provenance and outcomes. History { /// Configuration folder containing the managed context. #[arg(long, default_value = ".")] @@ -761,7 +725,7 @@ pub(crate) enum ManagedCommand { #[arg(long)] since: Option, }, - /// Cancel a pending managed run, or abandon an unused saved plan. + /// With --managed, cancel a pending run, or abandon an unused saved plan. Cancel { run_id: String, /// Configuration folder containing the managed context. @@ -774,14 +738,15 @@ pub(crate) enum ManagedCommand { } #[derive(Debug, Default, Args)] -pub(crate) struct ManagedRunArgs { - /// Return the accepted managed run without waiting for its outcome. +pub(crate) struct ClusterRunArgs { + /// For served apply or managed operations, return after acceptance without waiting for completion. #[arg(long)] pub(crate) no_wait: bool, - /// Managed wait deadline in seconds (default 300, maximum 3600). + /// For served apply or managed operations, bound the local wait in seconds (default 300, maximum 3600). + /// Expiry does not cancel accepted work. #[arg(long, value_parser = clap::value_parser!(u64).range(1..=3600))] pub(crate) timeout: Option, - /// Reuse this key to safely replay the same managed request. + /// With --managed, reuse this key to safely replay the same request. #[arg(long)] pub(crate) idempotency_key: Option, } diff --git a/crates/omnigraph-cli/src/cluster_remote.rs b/crates/omnigraph-cli/src/cluster_remote.rs index bf72a29e5..5fad1d332 100644 --- a/crates/omnigraph-cli/src/cluster_remote.rs +++ b/crates/omnigraph-cli/src/cluster_remote.rs @@ -387,7 +387,11 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { let Some(server) = cli.server.as_deref() else { return Ok(false); }; - let Command::Cluster { command } = &cli.command else { + let Command::Cluster { + managed: false, + command, + } = &cli.command + else { return Ok(false); }; if !matches!( @@ -416,17 +420,22 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { } let remote = RemoteCluster::new(server)?; match command { - ClusterCommand::Plan { config, json } => remote.plan(config, *json).await?, + ClusterCommand::Plan { config, json, .. } => remote.plan(config, *json).await?, ClusterCommand::Apply { config, deployment_id, json, - no_wait, - timeout, + run, .. } => { remote - .apply(config, deployment_id.as_deref(), *json, *no_wait, *timeout) + .apply( + config, + deployment_id.as_deref(), + *json, + run.no_wait, + run.timeout, + ) .await? } ClusterCommand::Status { diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 92b418676..0508efe44 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -181,7 +181,7 @@ async fn main() -> Result<()> { None }; let cli = Cli::from_arg_matches(&matches)?; - if let Err(error) = validate_core_root_arguments(&cli, command_matches) { + if let Err(error) = validate_cluster_arguments(&cli, command_matches) { error.exit(); } (cli, machine) @@ -256,22 +256,105 @@ async fn main() -> Result<()> { /// Cross-level argument relations must be checked after Clap propagates global /// values: subcommand validation cannot see `--cluster` given before `cluster`. -fn validate_core_root_arguments( +fn validate_cluster_arguments( cli: &Cli, command_matches: &clap::ArgMatches, ) -> std::result::Result<(), clap::Error> { - let Command::Cluster { command } = &cli.command else { + let Command::Cluster { managed, command } = &cli.command else { return Ok(()); }; + let conflict = + |message| Cli::command().error(clap::error::ErrorKind::ArgumentConflict, message); + if *managed { + match command { + ClusterCommand::Validate { .. } + | ClusterCommand::Observe { .. } + | ClusterCommand::ForceUnlock { .. } + | ClusterCommand::UpgradeLedger { .. } => { + return Err(conflict("this command cannot be used with --managed")); + } + ClusterCommand::Apply { + deployment_id, + writers_stopped, + plan, + .. + } => { + if deployment_id.is_some() || *writers_stopped { + return Err(conflict( + "--deployment-id and --writers-stopped cannot be used with --managed", + )); + } + if plan.is_none() { + return Err(Cli::command().error( + clap::error::ErrorKind::MissingRequiredArgument, + "cluster apply --managed requires --plan ", + )); + } + } + ClusterCommand::Status { + deployment_id, + wait, + timeout, + .. + } if deployment_id.is_some() || *wait || timeout.is_some() => { + return Err(conflict( + "--managed status accepts a positional RUN_ID; --deployment-id, --wait and --timeout apply only to server deployments", + )); + } + _ => {} + } + // The managed dispatcher rejects competing target selectors before + // reading context, preserving its structured scope diagnostics. + return Ok(()); + } + match command { + ClusterCommand::Create { .. } + | ClusterCommand::Push { .. } + | ClusterCommand::Delete { .. } + | ClusterCommand::UndoDelete { .. } + | ClusterCommand::Token { .. } + | ClusterCommand::Operation { .. } + | ClusterCommand::History { .. } + | ClusterCommand::Cancel { .. } => { + return Err(conflict("this command requires --managed")); + } + ClusterCommand::Plan { revision, run, .. } + if revision.is_some() + || run.no_wait + || run.timeout.is_some() + || run.idempotency_key.is_some() => + { + return Err(conflict( + "plan options --rev, --no-wait, --timeout and --idempotency-key require --managed", + )); + } + ClusterCommand::Apply { plan, run, .. } + if plan.is_some() || run.idempotency_key.is_some() => + { + return Err(conflict("--plan and --idempotency-key require --managed")); + } + ClusterCommand::Status { + run_id: Some(_), .. + } => { + return Err(conflict( + "a positional RUN_ID requires --managed; use --deployment-id for a server or storage deployment", + )); + } + _ => {} + } if cli.server.is_none() && matches!( command, - ClusterCommand::Apply { no_wait: true, .. } - | ClusterCommand::Apply { + ClusterCommand::Apply { + run: ClusterRunArgs { no_wait: true, .. }, + .. + } | ClusterCommand::Apply { + run: ClusterRunArgs { timeout: Some(_), .. - } - | ClusterCommand::Status { wait: true, .. } + }, + .. + } | ClusterCommand::Status { wait: true, .. } ) { return Err(Cli::command().error( @@ -1987,8 +2070,17 @@ async fn run(cli: Cli) -> Result<()> { finish_cluster_force_unlock(&output, json)?; } } + ClusterCommand::Create { .. } + | ClusterCommand::Push { .. } + | ClusterCommand::Delete { .. } + | ClusterCommand::UndoDelete { .. } + | ClusterCommand::Token { .. } + | ClusterCommand::Operation { .. } + | ClusterCommand::History { .. } + | ClusterCommand::Cancel { .. } => { + unreachable!("managed cluster commands dispatch first") + } }, - Command::Managed { .. } => unreachable!("managed commands dispatch before Core"), Command::Graphs { command } => match command { GraphsCommand::List { json, discovery } => { let (client, discovery) = if let Some(client) = managed_data { diff --git a/crates/omnigraph-cli/src/managed.rs b/crates/omnigraph-cli/src/managed.rs index 1f9d73a8b..ed6279bb8 100644 --- a/crates/omnigraph-cli/src/managed.rs +++ b/crates/omnigraph-cli/src/managed.rs @@ -1,7 +1,7 @@ //! Managed control routing (RFC 0052) and offline data credentials (RFC 0053). -//! Context selects authority, never caches a ledger; errors never fall back to Core. +//! Explicit --managed selects this routing; context supplies its bound authority. -use crate::cli::{Cli, Command, ManagedCommand, ManagedRunArgs}; +use crate::cli::{Cli, ClusterCommand, ClusterRunArgs, Command}; use reqwest::{Client, Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -438,7 +438,7 @@ async fn wait_run( api: &Api, context: &Context, mut body: Value, - options: &ManagedRunArgs, + options: &ClusterRunArgs, deadline: Instant, ) -> Result<(Value, i32)> { run_matches(&body, &context.cluster, None)?; @@ -460,7 +460,9 @@ async fn wait_run( let next = Instant::now() + POLL_INTERVAL; if next >= deadline { tokio::time::sleep_until(deadline).await; - eprintln!("wait deadline reached; run {id} continues; inspect `managed status {id}`"); + eprintln!( + "wait deadline reached; run {id} continues; inspect `cluster status --managed {id}`" + ); return Ok((body, 5)); } tokio::time::sleep_until(next).await; @@ -492,19 +494,20 @@ fn idempotency_key(value: Option<&str>) -> Result { Ok(key) } -fn config_and_json(command: &ManagedCommand) -> (&Path, bool) { +fn config_and_json(command: &ClusterCommand) -> (&Path, bool) { match command { - ManagedCommand::Plan { config, json, .. } - | ManagedCommand::Apply { config, json, .. } - | ManagedCommand::Status { config, json, .. } - | ManagedCommand::Operation { config, json, .. } - | ManagedCommand::History { config, json, .. } - | ManagedCommand::Cancel { config, json, .. } - | ManagedCommand::Token { config, json, .. } - | ManagedCommand::Create { config, json, .. } - | ManagedCommand::Delete { config, json, .. } - | ManagedCommand::UndoDelete { config, json, .. } - | ManagedCommand::Push { config, json, .. } => (config, *json), + ClusterCommand::Plan { config, json, .. } + | ClusterCommand::Apply { config, json, .. } + | ClusterCommand::Status { config, json, .. } + | ClusterCommand::Operation { config, json, .. } + | ClusterCommand::History { config, json, .. } + | ClusterCommand::Cancel { config, json, .. } + | ClusterCommand::Token { config, json, .. } + | ClusterCommand::Create { config, json, .. } + | ClusterCommand::Delete { config, json, .. } + | ClusterCommand::UndoDelete { config, json, .. } + | ClusterCommand::Push { config, json, .. } => (config, *json), + _ => unreachable!("local-only commands cannot select --managed"), } } @@ -518,7 +521,7 @@ fn reject_scope(cli: &Cli) -> Result<()> { { return Err(Failure::refused( "managed_scope_conflict", - "managed commands use folder context and authenticated identity; --as, --server, --graph, --profile, --store, and --cluster do not apply", + "cluster --managed uses folder context and authenticated identity; --as, --server, --graph, --profile, --store, and --cluster do not apply", )); } Ok(()) @@ -527,21 +530,20 @@ fn reject_scope(cli: &Cli) -> Result<()> { async fn cluster_command( cli: &Cli, context: &Context, - command: &ManagedCommand, + command: &ClusterCommand, ) -> Result<(Value, i32)> { - if let ManagedCommand::Token { ttl, clear, .. } = command { + if let ClusterCommand::Token { ttl, clear, .. } = command { return data::token(cli, context, *ttl, *clear) .await .map(|body| (body, 0)); } - reject_scope(cli)?; let api = Api::authenticated(context.api.clone())?; let base = format!("/v1/clusters/{}", context.cluster); match command { - ManagedCommand::Plan { managed, .. } | ManagedCommand::Apply { managed, .. } => { - let deadline = Instant::now() + Duration::from_secs(managed.timeout.unwrap_or(300)); + ClusterCommand::Plan { run, .. } | ClusterCommand::Apply { run, .. } => { + let deadline = Instant::now() + Duration::from_secs(run.timeout.unwrap_or(300)); let body = match command { - ManagedCommand::Plan { revision, .. } => { + ClusterCommand::Plan { revision, .. } => { if revision.as_ref().is_some_and(|r| { r.is_empty() || r.len() > 1024 || r.chars().any(char::is_control) }) { @@ -555,25 +557,28 @@ async fn cluster_command( None => json!({"kind":"plan"}), } } - ManagedCommand::Apply { plan, .. } => { + ClusterCommand::Apply { plan, .. } => { + let plan = plan.as_deref().ok_or_else(|| { + Failure::refused("plan_required", "cluster apply --managed requires --plan") + })?; identifier(plan)?; json!({"kind":"apply","plan_run":plan}) } _ => unreachable!(), }; - let key = idempotency_key(managed.idempotency_key.as_deref())?; + let key = idempotency_key(run.idempotency_key.as_deref())?; let path = format!("{base}/runs"); let submission = api.request(Method::POST, &path, Some(&body), Some(&key)); - let response = if managed.no_wait { + let response = if run.no_wait { submission.await? } else { tokio::time::timeout_at(deadline, submission).await.map_err(|_| { Failure::new("wait_timeout", "the local wait deadline was reached during submission; the run may exist, so replay the same idempotency key", 5) })?? }; - wait_run(&api, context, response, managed, deadline).await + wait_run(&api, context, response, run, deadline).await } - ManagedCommand::Status { run_id, .. } => { + ClusterCommand::Status { run_id, .. } => { let body = if let Some(id) = run_id { identifier(id)?; let body = api @@ -590,7 +595,7 @@ async fn cluster_command( }; Ok((body, 0)) } - ManagedCommand::History { limit, since, .. } => { + ClusterCommand::History { limit, since, .. } => { let mut url = Url::parse(&format!("{}{base}/history", context.api)) .map_err(|_| Failure::protocol())?; url.query_pairs_mut() @@ -608,7 +613,7 @@ async fn cluster_command( cluster_matches(&body, &context.cluster)?; Ok((body, 0)) } - ManagedCommand::Cancel { run_id, .. } => { + ClusterCommand::Cancel { run_id, .. } => { identifier(run_id)?; let before = api .request(Method::GET, &format!("/v1/runs/{run_id}"), None, None) @@ -698,20 +703,30 @@ pub(crate) async fn dispatch(cli: &Cli) -> Option { .await; Some(Output::from_result(result, *json, 0)) } - Command::Managed { command } => { + Command::Cluster { + managed: true, + command, + } => { let (config, json) = config_and_json(command); if cli.direct { return Some(Output::from_result( Err(Failure::refused( "managed_scope_conflict", - "managed commands cannot be used with --direct; use cluster for self-hosted deployment", + "--managed and --direct cannot be used together", )), json, 2, )); } + let scope = match command { + ClusterCommand::Token { ttl, clear, .. } => data::token_scope(cli, *ttl, *clear), + _ => reject_scope(cli), + }; + if let Err(err) = scope { + return Some(Output::from_result(Err(err), json, 2)); + } if lifecycle::handles(command) { - return Some(match lifecycle::dispatch(cli, command).await { + return Some(match lifecycle::dispatch(command).await { Ok((body, exit)) => Output::from_result(Ok(body), json, exit), Err(err) => Output::from_result(Err(err), json, 1), }); @@ -720,7 +735,7 @@ pub(crate) async fn dispatch(cli: &Cli) -> Option { let context = read_context(config)?.ok_or_else(|| { Failure::refused( "managed_context_required", - "managed commands require the selected folder's .omnigraph/context", + "cluster --managed requires the selected folder's .omnigraph/context", ) })?; cluster_command(cli, &context, command).await diff --git a/crates/omnigraph-cli/src/managed/data.rs b/crates/omnigraph-cli/src/managed/data.rs index 0798d6014..9a327e87f 100644 --- a/crates/omnigraph-cli/src/managed/data.rs +++ b/crates/omnigraph-cli/src/managed/data.rs @@ -53,7 +53,7 @@ fn key(context: &Context) -> String { fn invalid() -> Failure { Failure::refused( "data_credential_invalid", - "the cached data credential is invalid; mint a new managed token", + "the cached data credential is invalid; run cluster token --managed", ) } @@ -135,7 +135,7 @@ impl Credential { if expires <= now { return Err(Failure::refused( "data_credential_expired", - "the data credential has expired; mint a new managed token", + "the data credential has expired; run cluster token --managed", )); } Ok(()) @@ -260,28 +260,33 @@ fn clear(store: &impl Store, context: &Context) -> Result { ) } +pub(super) fn token_scope(cli: &Cli, ttl: Option, clear: bool) -> Result<()> { + scope(cli)?; + if clear && (cli.graph.is_some() || ttl.is_some()) { + return Err(Failure::refused( + "token_clear_conflict", + "--clear forgets the whole cached cluster credential and cannot select a graph or TTL", + )); + } + if cli.graph.is_some() { + return Err(Failure::refused( + "token_profile_conflict", + "identity credentials do not select a graph; use --graph on the graph operation", + )); + } + Ok(()) +} + pub(super) async fn token( cli: &Cli, context: &Context, ttl: Option, clear: bool, ) -> Result { - scope(cli)?; + token_scope(cli, ttl, clear)?; if clear { - if cli.graph.is_some() || ttl.is_some() { - return Err(Failure::refused( - "token_clear_conflict", - "--clear forgets the whole cached cluster credential and cannot select a graph or TTL", - )); - } return self::clear(&auth::DATA_STORE, context); } - if cli.graph.is_some() { - return Err(Failure::refused( - "token_profile_conflict", - "identity credentials do not select a graph; use --graph on the graph operation", - )); - } let api = Api::authenticated(context.api.clone())?; mint(&auth::DATA_STORE, context, &api, ttl.unwrap_or(3600)).await } @@ -290,7 +295,7 @@ fn load_credential(store: &impl Store, context: &Context) -> Result let raw = store.get(&key(context))?.ok_or_else(|| { Failure::refused( "data_credential_required", - "no data credential is cached for this cluster; run managed token", + "no data credential is cached for this cluster; run cluster token --managed", ) })?; if raw.len() > MAX_CREDENTIAL { diff --git a/crates/omnigraph-cli/src/managed/data/tests.rs b/crates/omnigraph-cli/src/managed/data/tests.rs index 74a60c194..26632a4dc 100644 --- a/crates/omnigraph-cli/src/managed/data/tests.rs +++ b/crates/omnigraph-cli/src/managed/data/tests.rs @@ -322,9 +322,19 @@ fn token_arguments_bound_lifetime_and_refuse_removed_actions() { ] { assert!(graph_id(bad).is_err()); } - assert!(Cli::try_parse_from(["omnigraph", "managed", "token", "--clear"]).is_ok()); - assert!(Cli::try_parse_from(["omnigraph", "managed", "token"]).is_ok()); - assert!(Cli::try_parse_from(["omnigraph", "managed", "token", "--actions", "read"]).is_err()); + assert!(Cli::try_parse_from(["omnigraph", "cluster", "token", "--managed", "--clear"]).is_ok()); + assert!(Cli::try_parse_from(["omnigraph", "cluster", "token", "--managed"]).is_ok()); + assert!( + Cli::try_parse_from([ + "omnigraph", + "cluster", + "token", + "--managed", + "--actions", + "read" + ]) + .is_err() + ); assert!( Cli::try_parse_from(["omnigraph", "cluster", "status", "--direct"]) .unwrap() diff --git a/crates/omnigraph-cli/src/managed/lifecycle.rs b/crates/omnigraph-cli/src/managed/lifecycle.rs index 2e876895b..4e29a22e3 100644 --- a/crates/omnigraph-cli/src/managed/lifecycle.rs +++ b/crates/omnigraph-cli/src/managed/lifecycle.rs @@ -4,14 +4,14 @@ use super::*; mod capture; mod pending; -pub(super) fn handles(command: &ManagedCommand) -> bool { +pub(super) fn handles(command: &ClusterCommand) -> bool { matches!( command, - ManagedCommand::Create { .. } - | ManagedCommand::Delete { .. } - | ManagedCommand::UndoDelete { .. } - | ManagedCommand::Push { .. } - | ManagedCommand::Operation { .. } + ClusterCommand::Create { .. } + | ClusterCommand::Delete { .. } + | ClusterCommand::UndoDelete { .. } + | ClusterCommand::Push { .. } + | ClusterCommand::Operation { .. } ) } @@ -28,13 +28,10 @@ fn context_required(config: &Path) -> Result { }) } -pub(super) async fn dispatch(cli: &Cli, command: &ManagedCommand) -> Result<(Value, i32)> { - reject_scope(cli)?; +pub(super) async fn dispatch(command: &ClusterCommand) -> Result<(Value, i32)> { let (config, _) = config_and_json(command); match command { - ManagedCommand::Create { - name, api, managed, .. - } => { + ClusterCommand::Create { name, api, run, .. } => { if name.is_empty() || name.len() > 64 || name.trim() != name @@ -56,33 +53,29 @@ pub(super) async fn dispatch(cli: &Cli, command: &ManagedCommand) -> Result<(Val kind: "create", context: read_context(config)?, incarnation: None, - managed, + run, tombstone: false, }, ) .await } - ManagedCommand::Delete { - incarnation, - managed, - .. + ClusterCommand::Delete { + incarnation, run, .. } - | ManagedCommand::UndoDelete { - incarnation, - managed, - .. + | ClusterCommand::UndoDelete { + incarnation, run, .. } => { identifier(incarnation)?; let context = context_required(config)?; let (kind, body, tombstone) = match command { - ManagedCommand::Delete { + ClusterCommand::Delete { retention_seconds, .. } => ( "delete", json!({"incarnation":incarnation,"retention_seconds":retention_seconds}), *retention_seconds > 0, ), - ManagedCommand::UndoDelete { deletion_id, .. } => { + ClusterCommand::UndoDelete { deletion_id, .. } => { identifier(deletion_id)?; ( "undo", @@ -102,13 +95,13 @@ pub(super) async fn dispatch(cli: &Cli, command: &ManagedCommand) -> Result<(Val kind, context: Some(context), incarnation: Some(incarnation), - managed, + run, tombstone, }, ) .await } - ManagedCommand::Operation { + ClusterCommand::Operation { operation_id: id, api, wait, @@ -137,7 +130,7 @@ pub(super) async fn dispatch(cli: &Cli, command: &ManagedCommand) -> Result<(Val } }; let api = client(&origin)?; - let options = ManagedRunArgs { + let options = ClusterRunArgs { no_wait: !wait, timeout: *timeout, idempotency_key: None, @@ -164,7 +157,7 @@ pub(super) async fn dispatch(cli: &Cli, command: &ManagedCommand) -> Result<(Val identity.matches(context.as_ref(), None, Some(id))?; wait_operation(&api, body, &identity, &options, deadline, false).await } - ManagedCommand::Push { + ClusterCommand::Push { expected_revision, message, .. @@ -274,7 +267,7 @@ struct Submission<'a> { kind: &'a str, context: Option, incarnation: Option<&'a str>, - managed: &'a ManagedRunArgs, + run: &'a ClusterRunArgs, tombstone: bool, } @@ -369,7 +362,7 @@ async fn submit(api: &Api, mut intent: Submission<'_>) -> Result<(Value, i32)> { "the pending create uses another API origin", )); } - let deadline = Instant::now() + Duration::from_secs(intent.managed.timeout.unwrap_or(300)); + let deadline = Instant::now() + Duration::from_secs(intent.run.timeout.unwrap_or(300)); // The service scopes idempotency to the principal. The same captured bearer // performs this read and the submission, including after a session renewal. let principal = principal(api, deadline).await?; @@ -379,7 +372,7 @@ async fn submit(api: &Api, mut intent: Submission<'_>) -> Result<(Value, i32)> { &intent.path, &intent.body, &principal, - intent.managed.idempotency_key.as_deref(), + intent.run.idempotency_key.as_deref(), intent.kind == "create" && intent.context.is_some(), )?; let response = tokio::time::timeout_at( @@ -444,7 +437,7 @@ async fn submit(api: &Api, mut intent: Submission<'_>) -> Result<(Value, i32)> { api, response, &identity, - intent.managed, + intent.run, deadline, intent.tombstone, ) @@ -455,7 +448,7 @@ async fn wait_operation( api: &Api, mut body: Value, identity: &Identity, - options: &ManagedRunArgs, + options: &ClusterRunArgs, deadline: Instant, tombstone: bool, ) -> Result<(Value, i32)> { @@ -488,7 +481,7 @@ async fn wait_operation( if next >= deadline { tokio::time::sleep_until(deadline).await; eprintln!( - "wait deadline reached; operation {} continues; use managed operation {}", + "wait deadline reached; operation {} continues; use cluster operation --managed {}", identity.operation_id, identity.operation_id ); return Ok((body, 5)); diff --git a/crates/omnigraph-cli/src/managed/lifecycle/pending.rs b/crates/omnigraph-cli/src/managed/lifecycle/pending.rs index 95e485cee..b24f311d7 100644 --- a/crates/omnigraph-cli/src/managed/lifecycle/pending.rs +++ b/crates/omnigraph-cli/src/managed/lifecycle/pending.rs @@ -127,7 +127,7 @@ pub(super) fn prepare( if bound_create { return Err(Failure::refused( "context_already_bound", - "managed create requires an unbound folder; its existing context was preserved", + "cluster create --managed requires an unbound folder; its existing context was preserved", )); } let pending = Pending { diff --git a/crates/omnigraph-cli/src/managed/tests.rs b/crates/omnigraph-cli/src/managed/tests.rs index 87d4e0b48..09cea7ff1 100644 --- a/crates/omnigraph-cli/src/managed/tests.rs +++ b/crates/omnigraph-cli/src/managed/tests.rs @@ -1,5 +1,5 @@ use super::*; -use clap::Parser; +use clap::{CommandFactory, FromArgMatches, Parser}; #[test] fn origins_are_canonical_and_credentials_cannot_change_destination() { @@ -68,7 +68,17 @@ fn legacy_login_and_managed_login_are_exclusive() { assert!(Cli::try_parse_from(std::iter::once("omnigraph").chain(args)).is_err()); } for value in ["0", "3601"] { - assert!(Cli::try_parse_from(["omnigraph", "managed", "plan", "--timeout", value]).is_err()); + assert!( + Cli::try_parse_from([ + "omnigraph", + "cluster", + "plan", + "--managed", + "--timeout", + value + ]) + .is_err() + ); } } @@ -96,36 +106,99 @@ fn contexts_are_exact_and_cannot_hide_unknown_authority() { assert!(read_context(dir.path()).is_err()); } +fn parse_cluster(args: &[&str]) -> std::result::Result { + let matches = Cli::command().try_get_matches_from(args)?; + let cli = Cli::from_arg_matches(&matches)?; + let mut command_matches = &matches; + while let Some((_, sub_matches)) = command_matches.subcommand() { + command_matches = sub_matches; + } + crate::validate_cluster_arguments(&cli, command_matches)?; + Ok(cli) +} + #[tokio::test] -async fn managed_namespace_is_explicit_and_core_ignores_context() { +async fn managed_flag_is_explicit_and_local_commands_ignore_context() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join(".omnigraph")).unwrap(); std::fs::write(dir.path().join(".omnigraph/context"), "malformed").unwrap(); let config = dir.path().to_str().unwrap(); - let managed = Cli::try_parse_from([ - "omnigraph", - "managed", - "status", - "--config", - config, - "--json", - ]) - .unwrap(); - assert_eq!(dispatch(&managed).await.unwrap().exit, 2); + for args in [ + vec![ + "omnigraph", + "cluster", + "status", + "--managed", + "--config", + config, + ], + vec![ + "omnigraph", + "cluster", + "--managed", + "status", + "--config", + config, + ], + ] { + let managed = parse_cluster(&args).unwrap(); + let output = dispatch(&managed).await.unwrap(); + assert_eq!(output.exit, 2); + assert_eq!(output.body["type"], "context_invalid"); + } for verb in ["validate", "plan", "apply", "status", "observe"] { - let core = Cli::try_parse_from(["omnigraph", "cluster", verb, "--config", config]).unwrap(); - assert!(dispatch(&core).await.is_none(), "{verb}"); + let local = parse_cluster(&["omnigraph", "cluster", verb, "--config", config]).unwrap(); + assert!(dispatch(&local).await.is_none(), "{verb}"); + } + // Conflicting selectors refuse before the deliberately malformed context is read. + for extra in [ + vec!["--direct"], + vec!["--as", "actor"], + vec!["--server", "https://data.example"], + vec!["--graph", "knowledge"], + vec!["--profile", "prod"], + vec!["--store", "file:///unused-store"], + vec!["--cluster", "file:///unused-cluster"], + ] { + let args = [ + "omnigraph", + "cluster", + "status", + "--managed", + "--config", + config, + ] + .into_iter() + .chain(extra.iter().copied()) + .collect::>(); + let cli = parse_cluster(&args).unwrap(); + let output = dispatch(&cli).await.unwrap(); + assert_eq!(output.exit, 2, "{extra:?}"); + assert_eq!(output.body["type"], "managed_scope_conflict", "{extra:?}"); + } + for (extra, kind) in [ + (vec!["--graph", "knowledge"], "token_profile_conflict"), + ( + vec!["--graph", "knowledge", "--clear"], + "token_clear_conflict", + ), + ] { + let args = [ + "omnigraph", + "cluster", + "token", + "--managed", + "--config", + config, + ] + .into_iter() + .chain(extra) + .collect::>(); + let cli = parse_cluster(&args).unwrap(); + let output = dispatch(&cli).await.unwrap(); + assert_eq!(output.exit, 2); + assert_eq!(output.body["type"], kind); } - let direct = Cli::try_parse_from([ - "omnigraph", - "managed", - "status", - "--config", - config, - "--direct", - ]) - .unwrap(); - assert_eq!(dispatch(&direct).await.unwrap().exit, 2); for args in [ vec![ "cluster", @@ -156,22 +229,56 @@ async fn managed_namespace_is_explicit_and_core_ignores_context() { "delete-one", ], vec!["cluster", "token"], + vec!["cluster", "operation", "op-id"], vec!["cluster", "history"], vec!["cluster", "cancel", "run-id"], - vec!["managed", "apply"], + vec!["cluster", "apply", "--managed"], vec![ - "managed", + "cluster", "apply", + "--managed", "--plan", "saved-plan", "--deployment-id", "id", ], - vec!["managed", "status", "--deployment-id", "id"], - vec!["managed", "status", "--operation", "id"], - vec!["managed", "observe"], + vec!["cluster", "status", "--managed", "--deployment-id", "id"], + vec!["cluster", "status", "--managed", "--operation", "id"], + vec!["cluster", "validate", "--managed"], + vec!["cluster", "observe", "--managed"], + vec!["cluster", "force-unlock", "lock-id", "--managed"], + vec![ + "cluster", + "upgrade-ledger", + "--writers-stopped", + "--managed", + ], + vec!["managed", "plan"], + vec!["managed", "operation", "op-id"], ] { - assert!(Cli::try_parse_from(std::iter::once("omnigraph").chain(args)).is_err()); + let args = std::iter::once("omnigraph").chain(args).collect::>(); + assert!(parse_cluster(&args).is_err(), "accepted {args:?}"); + } + for args in [ + vec!["omnigraph", "cluster", "operation", "op-id", "--managed"], + vec![ + "omnigraph", + "cluster", + "plan", + "--managed", + "--rev", + "revision", + ], + vec![ + "omnigraph", + "cluster", + "apply", + "--managed", + "--plan", + "saved-plan", + ], + vec!["omnigraph", "cluster", "status", "--managed", "run-id"], + ] { + assert!(parse_cluster(&args).is_ok(), "refused {args:?}"); } - assert!(Cli::try_parse_from(["omnigraph", "managed", "operation", "op-id"]).is_ok()); } diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index 85aeacce1..44c4bf583 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -156,7 +156,8 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool Command::Cluster { command: ClusterCommand::Plan { .. } | ClusterCommand::Apply { .. } - | ClusterCommand::Status { .. } + | ClusterCommand::Status { .. }, + managed: false, } ) } @@ -206,7 +207,7 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool command: ClusterCommand::Plan { .. } | ClusterCommand::Apply { .. } | ClusterCommand::UpgradeLedger { .. }, - .. + managed: false, } ), Direct => matches!( @@ -230,7 +231,7 @@ fn flag_applies(flag: ScopeFlag, capability: Capability, cmd: &Command) -> bool ScopeFlag::Profile => match capability { Any | Served => true, Direct => !matches!(cmd, Command::Init { .. }), - Control => !matches!(cmd, Command::Cluster { .. } | Command::Managed { .. }), + Control => !matches!(cmd, Command::Cluster { .. }), Local => false, }, } @@ -291,7 +292,7 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane { | Command::Repair { .. } | Command::Cleanup { .. } | Command::Lint { .. } => Plane::Storage, - Command::Cluster { .. } | Command::Managed { .. } => Plane::Control, + Command::Cluster { .. } => Plane::Control, Command::Alias { .. } | Command::Embed(_) | Command::Login { .. } @@ -347,7 +348,6 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str { Command::Repair { .. } => "repair", Command::Cleanup { .. } => "cleanup", Command::Cluster { .. } => "cluster", - Command::Managed { .. } => "managed", Command::Graphs { command } => match command { GraphsCommand::List { .. } => "graphs list", }, @@ -377,7 +377,7 @@ pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool { | Command::Policy { .. } | Command::Queries { .. } | Command::Cluster { command: ClusterCommand::Apply { .. } | ClusterCommand::Status { .. } - | ClusterCommand::ForceUnlock { .. } | ClusterCommand::UpgradeLedger { .. } } + | ClusterCommand::ForceUnlock { .. } | ClusterCommand::UpgradeLedger { .. }, managed: false } ) } @@ -578,7 +578,22 @@ mod tests { [false, true, false, false, true, false], ), ( - parse(&["omnigraph", "managed", "status"]), + parse(&["omnigraph", "cluster", "status", "--managed"]), + [false, false, false, false, false, false], + ), + ( + parse(&["omnigraph", "cluster", "plan", "--managed"]), + [false, false, false, false, false, false], + ), + ( + parse(&[ + "omnigraph", + "cluster", + "apply", + "--managed", + "--plan", + "plan", + ]), [false, false, false, false, false, false], ), ( diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index f789969da..e4764d23c 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -1,5 +1,4 @@ -//! Cluster command surface: validate/plan/apply/status/sync/force-unlock. -//! Moved verbatim from tests/cli.rs in the modularization. +//! Direct, served, and explicitly selected managed cluster command workflows. use serde_json::Value; use std::fs; @@ -518,8 +517,9 @@ fn managed_lifecycle_uncertain_create_reuses_durable_key_and_preserves_context() .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "create", + "--managed", name, "--api", &api.origin, @@ -613,8 +613,9 @@ fn managed_lifecycle_pending_is_principal_bound_across_session_renewal() { .env("OMNIGRAPH_CONTROL_TOKEN", token) .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "create", + "--managed", "new-name", "--api", &api.origin, @@ -695,8 +696,9 @@ fn managed_lifecycle_definitive_first_refusal_releases_pending_intent() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "create", + "--managed", name, "--api", &api.origin, @@ -762,8 +764,9 @@ fn managed_lifecycle_delete_and_undo_send_exact_authority_targets() { .current_dir(temp.path()) .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) - .arg("managed") + .arg("cluster") .args(args) + .arg("--managed") .args(["--no-wait", "--idempotency-key", "exact-intent", "--json"]), ); assert_eq!(parse_stdout_json(&output)["data"]["kind"], kind); @@ -798,8 +801,9 @@ fn managed_lifecycle_wait_reports_tombstone_and_checks_every_poll_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "delete", + "--managed", "--incarnation", "inc-one", "--timeout", @@ -832,8 +836,9 @@ fn managed_lifecycle_wait_reports_tombstone_and_checks_every_poll_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "operation", + "--managed", "operation-one", "--api", &api.origin, @@ -861,8 +866,9 @@ fn managed_lifecycle_bad_acceptance_and_deadline_keep_recovery_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "create", + "--managed", "new", "--api", &api.origin, @@ -888,8 +894,9 @@ fn managed_lifecycle_bad_acceptance_and_deadline_keep_recovery_identity() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "create", + "--managed", "new", "--api", &api.origin, @@ -938,8 +945,9 @@ fn managed_lifecycle_push_sends_only_complete_referenced_files() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "push", + "--managed", "--expected-revision", &"b".repeat(40), "--message", @@ -989,8 +997,9 @@ fn managed_lifecycle_push_refuses_unsafe_paths_and_oversized_files_before_http() .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "push", + "--managed", "--expected-revision", &"b".repeat(40), "--message", @@ -1024,8 +1033,9 @@ fn managed_lifecycle_local_lock_and_direct_flags_refuse_without_submission() { .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "create", + "--managed", "locked", "--api", &api.origin, @@ -1048,17 +1058,26 @@ fn managed_lifecycle_local_lock_and_direct_flags_refuse_without_submission() { drop(lock); for args in [ vec![ - "managed", + "cluster", "create", + "--managed", "direct", "--api", &api.origin, "--direct", ], - vec!["managed", "delete", "--incarnation", "inc-one", "--direct"], vec![ - "managed", + "cluster", + "delete", + "--managed", + "--incarnation", + "inc-one", + "--direct", + ], + vec![ + "cluster", "undo-delete", + "--managed", "--incarnation", "inc-one", "--deletion-id", @@ -1066,8 +1085,9 @@ fn managed_lifecycle_local_lock_and_direct_flags_refuse_without_submission() { "--direct", ], vec![ - "managed", + "cluster", "operation", + "--managed", "op", "--api", &api.origin, @@ -1106,8 +1126,9 @@ fn managed_lifecycle_capture_and_retry_records_refuse_symlinks_without_reading_t .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "push", + "--managed", "--expected-revision", &"b".repeat(40), "--message", @@ -1128,8 +1149,9 @@ fn managed_lifecycle_capture_and_retry_records_refuse_symlinks_without_reading_t .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") .env("OMNIGRAPH_CONTROL_API", &api.origin) .args([ - "managed", + "cluster", "delete", + "--managed", "--incarnation", "inc-one", "--no-wait", @@ -1163,8 +1185,9 @@ fn managed_data_process_refuses_missing_graph_and_actor_override_before_keychain "--json", ], vec![ - "managed", + "cluster", "token", + "--managed", "--clear", "--graph", "knowledge", @@ -1822,10 +1845,10 @@ fn managed_plan_polls_the_accepted_run_and_timeout_does_not_cancel_it() { if timeout { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("inspect `managed status run-one`"), + stderr.contains("inspect `cluster status --managed run-one`"), "{stderr}" ); - assert!(!stderr.contains("cluster status"), "{stderr}"); + assert!(!stderr.contains("inspect `managed status"), "{stderr}"); } let requests = api.requests(); assert_eq!(requests.len(), if timeout { 1 } else { 2 }); @@ -1967,7 +1990,7 @@ fn managed_context_links_and_fifo_refuse_without_blocking_or_core_effects() { } #[test] -fn managed_namespace_is_explicit_and_core_ignores_folder_context() { +fn managed_mode_is_explicit_and_direct_commands_ignore_folder_context() { let temp = tempdir().unwrap(); let api = IntentApiFixture::new(vec![]); write_managed_context(temp.path(), &api.origin); @@ -2025,6 +2048,120 @@ fn managed_namespace_is_explicit_and_core_ignores_folder_context() { "Core apply consulted the managed service" ); } + // Neither a selected folder nor an invalid context may change the meaning + // of a command. Reject the spelling/flag conflict before reading context, + // opening storage, or submitting anything to either HTTP API. + let rejected_root = temp.path().join("rejected"); + fs::create_dir(&rejected_root).unwrap(); + write_cluster_config_fixture(&rejected_root); + write_managed_context(&rejected_root, &api.origin); + let rejected_context = rejected_root.join(".omnigraph/context"); + fs::write(&rejected_context, "{\n").unwrap(); + let mut rejected_arguments = vec![ + vec!["managed", "status"], // Removed spelling has no compatibility alias. + vec!["cluster", "create", "new", "--api", &api.origin], + vec![ + "cluster", + "push", + "--expected-revision", + "old", + "--message", + "new", + ], + vec!["cluster", "delete", "--incarnation", "inc-one"], + vec![ + "cluster", + "undo-delete", + "--incarnation", + "inc-one", + "--deletion-id", + "op", + ], + vec!["cluster", "token"], + vec!["cluster", "operation", "op"], + vec!["cluster", "history"], + vec!["cluster", "cancel", "run"], + vec!["cluster", "plan", "--rev", "revision"], + vec!["cluster", "plan", "--no-wait"], + vec!["cluster", "plan", "--timeout", "10"], + vec!["cluster", "plan", "--idempotency-key", "key"], + vec!["cluster", "apply", "--plan", "plan"], + vec!["cluster", "apply", "--idempotency-key", "key"], + vec!["cluster", "status", "run"], + vec!["cluster", "apply", "--managed"], // A managed apply requires a plan. + vec![ + "cluster", + "apply", + "--managed", + "--plan", + "plan", + "--deployment-id", + "id", + ], + vec![ + "cluster", + "apply", + "--managed", + "--plan", + "plan", + "--writers-stopped", + ], + vec!["cluster", "status", "--managed", "--deployment-id", "id"], + vec!["cluster", "status", "--managed", "run", "--wait"], + vec!["cluster", "status", "--managed", "run", "--timeout", "10"], + vec!["cluster", "validate", "--managed"], + vec!["cluster", "observe", "--managed"], + vec!["cluster", "force-unlock", "lock", "--managed"], + vec![ + "cluster", + "upgrade-ledger", + "--writers-stopped", + "--managed", + ], + ]; + for selector in [ + vec!["--server", api.origin.as_str()], + vec!["--cluster", "file:///must-not-open"], + vec!["--store", "file:///must-not-open"], + vec!["--profile", "must-not-load"], + vec!["--graph", "knowledge"], + vec!["--as", "forged"], + vec!["--direct"], + ] { + let mut arguments = vec!["cluster", "apply", "--managed", "--plan", "plan"]; + arguments.extend(selector); + rejected_arguments.push(arguments); + } + for arguments in rejected_arguments { + let output = cli() + .current_dir(&rejected_root) + .env("OMNIGRAPH_CONTROL_TOKEN", "og_fixture_control") + .env("OMNIGRAPH_CONTROL_API", &api.origin) + .args(&arguments) + .arg("--json") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{arguments:?}: {output:?}"); + let diagnostics = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !diagnostics.contains("context_invalid") + && !diagnostics.contains("managed_context_required"), + "argument refusal must precede context access: {arguments:?}: {diagnostics}" + ); + assert!(api.requests().is_empty(), "{arguments:?}"); + assert_no_core_effects(&rejected_root); + assert_eq!(fs::read(&rejected_context).unwrap(), b"{\n"); + assert!(!rejected_root.join(".omnigraph/lifecycle.lock").exists()); + assert!( + !rejected_root + .join(".omnigraph/pending-lifecycle.json") + .exists() + ); + } let child = temp.path().join("nested"); fs::create_dir(&child).unwrap(); let missing = managed_cli(&child, &api.origin) diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs index a787324a4..88fa85c06 100644 --- a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -546,7 +546,7 @@ fn cluster_e2e_offline_deployment_has_root_only_receipts_and_explicit_unlock() { ); assert_eq!(output.status.code(), Some(2)); let expected = if unsupported_flag[0] == "--idempotency-key" { - "unexpected argument" + "--plan and --idempotency-key require --managed" } else { "deployment wait options require --server" }; diff --git a/crates/omnigraph-cli/tests/support/mod.rs b/crates/omnigraph-cli/tests/support/mod.rs index 84ee44dd7..7e9f85778 100644 --- a/crates/omnigraph-cli/tests/support/mod.rs +++ b/crates/omnigraph-cli/tests/support/mod.rs @@ -83,7 +83,7 @@ pub fn managed_cli(config: &Path, origin: &str) -> Command { .env("OMNIGRAPH_CONTROL_API", origin) .env("OMNIGRAPH_TOKEN", "data-token-must-not-be-used") .current_dir(config) - .arg("managed") + .args(["cluster", "--managed"]) .timeout(Duration::from_secs(15)); command } diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index 7771c84fd..cacf49520 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -23,6 +23,11 @@ roots are derived as `graphs/.omni` beneath that root. ## Managed CLI sessions +Cluster control selects the service only with explicit `--managed`; folder +context supplies identity after selection and never redirects direct/server +deployment. The CLI rejects conflicting targets and wrong-mode flags before +context, credentials, HTTP, or storage access. Data-command context is separate. + The CLI's managed HTTP adapter obtains current access before each new request, including status polls. A rotating credential pair lives only in the OS keychain, independently of configuration directories and graph credentials. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 3677e04fa..3fe6ff2a6 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -149,8 +149,9 @@ planning to prove preview does not wait for the graph gate. In-source turnover, exercising bounded re-observation for aggregate and exact status. CLI `cli_cluster` owns submit-once polling, transient 429/503 and truncated-body retries, malformed-receipt refusal, terminal outcomes and caller timeout without -replay. Its managed fixtures cover status/history scope and filters, while -direct apply must ignore both valid and malformed managed folder context. +replay. Its managed fixtures cover status/history scope and filters, explicit +`--managed` selection, and wrong-mode refusals before context or external access. +Direct apply must ignore both valid and malformed managed folder context. CLI `cli_cluster_e2e` proves one PID/listener survives schema/query replacement, graph addition, policy grant/revocation and management handoff; the original submitter retains only its exact receipt access after restart. diff --git a/docs/rfcs/0052-managed-control-plane-cli.md b/docs/rfcs/0052-managed-control-plane-cli.md index 4fde2f3ad..61f893b10 100644 --- a/docs/rfcs/0052-managed-control-plane-cli.md +++ b/docs/rfcs/0052-managed-control-plane-cli.md @@ -23,9 +23,9 @@ blocked_on: [] ## Summary -The explicit `managed` command family addresses the managed Intent API. A -folder's `.omnigraph/context` selects the service and cluster for bound -operations. Core `cluster` commands never inspect that context. +The explicit `cluster --managed` mode addresses the managed Intent +API. A folder's `.omnigraph/context` selects the service and cluster only after +that mode is chosen. Without `--managed`, cluster commands never inspect it. Managed authentication establishes identity, and current permissions authorize execution of an exact saved plan. There is no separate managed approval workflow. Engine behavior and storage formats @@ -45,11 +45,11 @@ rather than a local command refactor. ```sh omnigraph login --api https://control.example omnigraph use CLUSTER_ID --api https://control.example -omnigraph managed plan --config . --json -omnigraph managed apply --config . --plan PLAN_RUN_ID --json -omnigraph managed status --config . --json -omnigraph managed history --config . --json -omnigraph managed cancel RUN_ID --config . --json +omnigraph cluster plan --managed --config . --json +omnigraph cluster apply --managed --config . --plan PLAN_RUN_ID --json +omnigraph cluster status --managed --config . --json +omnigraph cluster history --managed --config . --json +omnigraph cluster cancel --managed RUN_ID --config . --json omnigraph logout --api https://control.example ``` @@ -60,12 +60,23 @@ does not commit files, upload a bundle, silently replan, or ask for another approval. Current permissions, binding, digests, and lease are checked by the API and execution authority is independently checked by the executor. -The command family selects authority before context discovery. `cluster` uses -Core configuration or its explicit server/root target; `managed` uses only the -service API and rejects `--direct`. Malformed context, unknown context version, -provider/API errors and permission refusals fail closed. No managed failure may +The mode flag selects authority before context discovery. `cluster` without +`--managed` uses self-hosted configuration or its explicit server/root target; +with `--managed` it uses only the service API. Managed mode rejects `--direct`, +`--server`, `--cluster`, `--store`, `--profile`, `--graph`, and `--as` before +context, credentials, HTTP, or storage access. Malformed context, unknown context +version, provider/API errors and permission refusals fail closed. No managed failure may invoke Core or consult graph-root credentials. +Service-only `create`, `push`, `delete`, `undo-delete`, `token`, `operation`, +`history`, and `cancel` require `--managed`. Local `validate`, `observe`, +`force-unlock`, and `upgrade-ledger` reject it. Shared `plan`, `apply`, and +`status` validate arguments against the selected mode before dispatch. Managed +plan uses `--rev`, apply requires `--plan`, and status accepts an optional +positional run ID. Self-hosted deployment uses local configuration or exact +`--deployment-id`; served status alone permits deployment `--wait`. No argument +or folder context infers a mode. The flag may precede or follow the subcommand. + `--no-wait` returns the accepted run envelope. Otherwise the CLI polls every two seconds for up to 300 seconds; `--timeout` selects 1–3,600 seconds. Timeout ends the local wait, not the run. A stable `--idempotency-key` permits explicit @@ -133,9 +144,10 @@ fork, cloud-only correctness fix, duplicated lock, or new writer fence. Existing profiles/servers/clusters config and `login SERVER --token` / `logout SERVER` remain intact. The new `--api` mode is mutually exclusive -with named-server login. Core commands retain their own addressing regardless -of folder context. Managed commands and their arguments live only under -`managed`; former managed `cluster` spellings are not aliases. +with named-server login. Self-hosted commands retain their own addressing +regardless of folder context. Managed control requires `cluster --managed`; +the top-level `managed` command is removed, with no alias. Omitting the mode +flag cannot select service execution. No stored graph or server API format changes. Bound managed commands refuse without context; its absence cannot select direct execution. Deploy a compatible @@ -147,8 +159,10 @@ The server SDK and Python SDK gain no new authority or dependencies. - Raw HTTP keeps the current gap in bounded waits, identity storage, and context isolation and makes every script reproduce the managed contract. - Ambient context dispatch makes identical cluster commands select different - protocols and exposes mutually incompatible flags in one parser. Explicit - command families keep that choice visible without another mode flag. + protocols. Explicit `--managed` keeps the choice visible and mode validation + refuses arguments belonging to another protocol before any access. +- Separate top-level command families repeat cluster concepts and workflows. + One `cluster` noun with an explicit mode retains the authority boundary. - Inferring managed mode from storage roots or existing server aliases risks forwarding the wrong credential; explicit context and origin binding win. - Requiring browser sign-in after every short access lifetime interrupts @@ -205,8 +219,10 @@ managed-store editing and provisioning to this original increment, and links RFC 0061 as the owner of those additional client commands. Its authentication, folder context, direct routing and exact-plan execution contract is unchanged. -2026-10-06: The maintainer requested explicit command namespaces. Summary, -User behavior, Design, Compatibility and Alternatives now place service commands -under `managed` and remove ambient interception of Core `cluster` commands. +2026-10-06: The maintainer chose `cluster --managed`. Summary and +User behavior replace command-family routing with explicit mode selection, +early conflicts and shared-command argument rules. Compatibility removes the +top-level `managed` command without an alias. Alternatives replaces separate +command families with one cluster noun while rejecting ambient interception. Managed data-command context and service protocols remain unchanged. Existing -CLI owners cover namespace refusal and Core isolation from malformed context. +CLI owners cover mode refusal and self-hosted isolation from malformed context. diff --git a/docs/rfcs/0053-offline-data-token-verification.md b/docs/rfcs/0053-offline-data-token-verification.md index 5399ffbcc..2ad104cd3 100644 --- a/docs/rfcs/0053-offline-data-token-verification.md +++ b/docs/rfcs/0053-offline-data-token-verification.md @@ -208,7 +208,7 @@ Cedar/default-deny semantics. ### Managed CLI data access The companion issuance API supplies a data endpoint and signed credential. -`omnigraph managed token --config DIR --ttl 1h` requests a version-2 identity +`omnigraph cluster token --managed --config DIR --ttl 1h` requests a version-2 identity credential. TTL defaults to 3,600 seconds and must be 60–86,400 seconds. Issuance adds no policy permissions. `--clear` instead forgets the selected cluster's local data credential. Token command output is @@ -282,7 +282,7 @@ authentication work. No deny-list exception is requested. Existing deployments without trust are unchanged. New credentials do not work on old servers; they cannot degrade into static credentials. Version-1 restricted tokens, their public data types, and CLI `--actions` are -removed. Old caches refuse without replacement; `managed token` explicitly +removed. Old caches refuse without replacement; `cluster token --managed` explicitly obtains a current credential. Removing the trust flag disables signed credentials deliberately. The trust supplier must not leave a token-only deployment in unauthenticated mode after rollback. No graph storage, ledger, manifest, policy-file, or managed-marker format @@ -423,6 +423,7 @@ engine and storage behavior was preserved. The later CLI diagnostic-redaction fix is covered by its focused regression and does not change the successfully qualified data path. -2026-10-06: Credential-administration examples now use `managed token` under -the explicit service namespace from [RFC 0052](0052-managed-control-plane-cli.md). +2026-10-06: Managed CLI data access and Compatibility replace the token command +spelling with `cluster token --managed`, using the explicit service mode from +[RFC 0052](0052-managed-control-plane-cli.md). Credential format, acquisition and authorization are unchanged. diff --git a/docs/rfcs/0061-managed-cluster-lifecycle.md b/docs/rfcs/0061-managed-cluster-lifecycle.md index d450fcfa5..5784bcc8e 100644 --- a/docs/rfcs/0061-managed-cluster-lifecycle.md +++ b/docs/rfcs/0061-managed-cluster-lifecycle.md @@ -37,21 +37,21 @@ operations rather than local storage effects or unbounded polling. ## User and operational behavior -`managed create NAME --api ORIGIN --config DIR` requests an empty managed +`cluster create --managed NAME --api ORIGIN --config DIR` requests an empty managed cluster and binds the config directory to the returned identity. An existing context is never overwritten. `--no-wait` returns the accepted operation; otherwise the command waits up to 300 seconds, configurable from 1–3600 using `--timeout`. Readiness means the API's ordinary plan/apply and serving witness completed; acceptance alone does not mean ready or network reachable. -`managed push --expected-revision COMMIT --message MESSAGE` captures only +`cluster push --managed --expected-revision COMMIT --message MESSAGE` captures only `cluster.yaml` and referenced schema, stored-query and policy files from the selected folder and uploads them with a conditional managed Git revision. -It does not apply. The caller next uses `managed plan --rev COMMIT` and exact -`managed apply --plan RUN`. No Git provider credentials or bucket access are +It does not apply. The caller next uses `cluster plan --managed --rev COMMIT` and exact +`cluster apply --managed --plan RUN`. No Git provider credentials or bucket access are required by the CLI. -`managed delete --incarnation INCARNATION` requires exact targeting and current +`cluster delete --managed --incarnation INCARNATION` requires exact targeting and current scoped delete permission. `--retention-seconds` accepts 0–2,592,000 and defaults to 86,400. With a nonzero interval, ordinary waiting stops at the confirmed tombstone and reports its deadline and operation ID while preserving canonical @@ -59,12 +59,12 @@ state `running`; zero waits toward final purge. `--no-wait` and `--timeout` provide the same bounded alternatives as creation. There is no additional approval or confirmation workflow. -`managed undo-delete --incarnation INCARNATION --deletion-id OPERATION` requests +`cluster undo-delete --managed --incarnation INCARNATION --deletion-id OPERATION` requests undo of that exact deletion. The service requires current delete, plan and apply permissions, checks the retained undo window, and restores through the normal plan/apply workflow. The CLI does not recreate a retired identity. -`managed operation OPERATION` reads a service operation. `--api ORIGIN` +`cluster operation --managed OPERATION` reads a service operation. `--api ORIGIN` allows recovery before any folder context exists; otherwise the exact folder context selects the origin and cluster. `--wait --timeout SECONDS` explicitly polls to a canonical outcome. A local deadline returns exit 5 with the latest @@ -120,9 +120,10 @@ The Lance domain map therefore has no changed substrate surface. ## Compatibility and reversibility -Folder context remains version 1. These commands use the explicit `managed` -namespace; former managed `cluster` spellings are refused. Service refusal -never invokes Core execution. Removing this CLI surface does not cancel any +Folder context remains version 1. These commands require explicit `--managed` +under `cluster`; the top-level `managed` command is removed without an alias. +Omitting the flag refuses service-only verbs before context or HTTP access. +Service refusal never invokes Core execution. Removing this CLI surface does not cancel any accepted service operation. Private routing still needs its explicit connection; public routing and a console are outside this RFC. @@ -171,7 +172,8 @@ CLI contract and compatibility. regressions. Managed upload is qualified on Unix; other platforms refuse it. The companion service owns cell qualification and cloud rollout separately. -2026-10-06: The maintainer requested the explicit `managed` namespace. User -behavior and Compatibility now use those commands and a separate `managed -operation ID` for lifecycle observation. Service APIs, identity checks, pending +2026-10-06: The maintainer chose `cluster --managed`. User behavior +now requires that mode on every lifecycle command and uses `cluster operation +--managed ID` for lifecycle observation. Compatibility replaces the top-level +namespace with an explicit mode and no alias. Service APIs, identity checks, pending request custody and bounded waits are unchanged. diff --git a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md index 5849500d0..e48c09ae3 100644 --- a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md +++ b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md @@ -48,7 +48,7 @@ returned by the existing graph catalog. Normal credential acquisition requests version 2 without graph/actions: ```text -omnigraph managed token --ttl 3600 +omnigraph cluster token --managed --ttl 3600 omnigraph --graph knowledge query list_people omnigraph graphs list ``` @@ -60,7 +60,7 @@ select the new minimal endpoint with No JWT-shaped static token is reinterpreted to select a different command. CLI `--actions` is removed. Unsupported caches refuse without automatic -replacement; `managed token` explicitly requests a current identity credential. +replacement; `cluster token --managed` explicitly requests a current identity credential. Permission denial never switches credentials or storage addressing. `GET /graphs/discovery` returns only @@ -176,7 +176,7 @@ documented trust boundary, never an automatic fallback from identity denial. ## Compatibility and reversibility Version-1 tokens, cache entries and public claim/grant types are unsupported; -CLI `--actions` is removed. A new explicit `managed token` request replaces an +CLI `--actions` is removed. A new explicit `cluster token --managed` request replaces an old cache. Existing static/unauthenticated modes and direct APIs retain their behavior. Authorized entry points remain additive. The public exhaustive policy enums gain `ConfigManage`/`Cluster` variants; @@ -243,8 +243,9 @@ of a particular deployment remain separate from the accepted library contract. ## Decision log -- 2026-10-06: Credential-administration examples now use `managed token` under - the explicit service namespace from [RFC 0052](0052-managed-control-plane-cli.md). +- 2026-10-06: User behavior and Compatibility replace the token command spelling + with `cluster token --managed`, using the explicit service mode from + [RFC 0052](0052-managed-control-plane-cli.md). Credential format, acquisition and authorization are unchanged. - 2026-10-05: The maintainer requested removal of compatibility interfaces. diff --git a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md index e3c5e11ad..d9e48057a 100644 --- a/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md +++ b/docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md @@ -575,10 +575,12 @@ fence and does not replace lock admission or Azure's wrapper. Conversion and reconciliation require it; read-only status does not. Routine apply does not need manual status-and-copy, and may not allocate a new ID when resolving a previous unknown result. Missing/expired original lookup never creates a replacement. -Hosted service workflows use the explicit `managed` command family and retain -their separate control-plane API. Folder context never redirects `cluster` -commands to that API. These flags do not manufacture a remotely authenticated -identity. Status needs no lock and cannot authorize a writer. +Hosted service workflows use `cluster --managed` and retain their +separate control-plane API. The flag is mutually exclusive with server or direct +storage selectors; folder context never selects it. Shared commands validate +mode-specific flags before context or external access, and the removed top-level +`managed` command has no alias. These flags do not manufacture a remotely +authenticated identity. Status needs no lock and cannot authorize a writer. Conversion preserves graph data, native identities, branches/history, applied resource digests, policy/provider bindings and existing audit/approval facts. @@ -1272,8 +1274,10 @@ Before enabling an affected increment, its owners must implement and qualify: ## Decision log - 2026-10-06: The maintainer approved served observational planning, durable - acceptance followed by bounded CLI observation, and explicit `managed` command - routing. Online deployment and CLI sections now define exact receipt reads for + acceptance followed by bounded CLI observation, and explicit `cluster + --managed` mode selection. The CLI section replaces command-family routing with + explicit mode and pre-access argument validation. Online deployment and CLI + sections now define exact receipt reads for initiating actors, process-owned progress, caller wait outcomes, and the shared observed-plan limits. No new durable job authority is added. diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index 18fa823e2..10a50fdea 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -204,21 +204,22 @@ your config directory: ```bash omnigraph login --api https://control.example omnigraph use CLUSTER_ID --api https://control.example --config ./company-brain -omnigraph managed plan --config ./company-brain --json > plan.json -omnigraph managed apply --config ./company-brain --plan "$(jq -r .data.run_id plan.json)" --json -omnigraph managed status --config ./company-brain --json -omnigraph managed history --config ./company-brain --json +omnigraph cluster plan --managed --config ./company-brain --json > plan.json +omnigraph cluster apply --managed --config ./company-brain --plan "$(jq -r .data.run_id plan.json)" --json +omnigraph cluster status --managed --config ./company-brain --json +omnigraph cluster history --managed --config ./company-brain --json omnigraph logout --api https://control.example ``` Commit and push external configuration before planning. The API plans its bound head, or the pushed revision selected with `--rev`. Apply uses the exact saved plan and your current permissions. To release an unused plan, run -`omnigraph managed cancel PLAN_RUN_ID --config ./company-brain`; its result +`omnigraph cluster cancel --managed PLAN_RUN_ID --config ./company-brain`; its result remains in history and cannot be applied afterward. -The explicit `managed` namespace uses the folder's `.omnigraph/context` to select -the API. Missing or malformed context and unavailable APIs refuse without +The explicit `--managed` flag uses the folder's `.omnigraph/context` to select +the API and cluster. Without it, cluster commands ignore that context. +Missing or malformed context and unavailable APIs refuse without falling back to local deployment. See [Managed cluster commands](reference.md#managed-cluster-commands) for credential storage, automation, bounded waits, and exit codes. diff --git a/docs/user/cli/managed-data.md b/docs/user/cli/managed-data.md index 1c47bf852..2a974c333 100644 --- a/docs/user/cli/managed-data.md +++ b/docs/user/cli/managed-data.md @@ -18,7 +18,7 @@ needed. A valid cached graph credential for your cached sign-in avoids an unrelated API session check; an explicit automation token verifies its own principal before reusing that cache. Authentication refresh never replays a submitted mutation. Explicit -`managed token --ttl 1h` remains available for credential administration. +`cluster token --managed --ttl 1h` remains available for credential administration. The issuer must support identity credentials and admit your principal to the selected cluster. The credential proves who you are and which cluster you @@ -76,7 +76,7 @@ keychain; unattended clients needing a raw token use the issuance API directly. Managed `query`, `mutate`, `load`, `commit list`/`show`, and `graphs list` read `.omnigraph/context` only in the current directory; graph-specific commands require `--graph`. After -`managed token --config DIR`, run these commands from `DIR`; no parent +`cluster token --managed --config DIR`, run these commands from `DIR`; no parent directory is searched. Ordinary data requests go directly to the cached endpoint without contacting the control API. They keep working during an API outage until the token expires or its @@ -120,12 +120,12 @@ omnigraph query find_person --profile staging --graph knowledge --json Ordinary token settings never supply managed authority. Global `--direct` continues to select ordinary addressing and credentials, including when the -context is malformed. Core `cluster` commands never read managed context. Without -managed context, existing data commands retain their behavior. +context is malformed. `cluster` commands without `--managed` never read managed +context. Without managed context, existing data commands retain their behavior. ## Clear a credential -`managed token --clear [--config DIR]` forgets that cluster's local data +`cluster token --managed --clear [--config DIR]` forgets that cluster's local data entry, independently of the control-plane session. Do not combine `--clear` with `--graph` or `--ttl`. Clearing is not server revocation: copies remain usable until expiry or signing-key retirement. `logout --api` diff --git a/docs/user/cli/managed-lifecycle.md b/docs/user/cli/managed-lifecycle.md index 82eb91a82..5da7dc65f 100644 --- a/docs/user/cli/managed-lifecycle.md +++ b/docs/user/cli/managed-lifecycle.md @@ -3,6 +3,8 @@ These commands use your managed service identity and permissions. They do not open graph storage or require cloud credentials. The service must support cluster creation and lifecycle operations; an older service returns a refusal. +Every command below requires explicit `--managed`; folder context alone never +selects the service for a cluster command. ## Create an empty cluster @@ -11,7 +13,7 @@ Log in to your control API, then use an unbound configuration directory: ```sh omnigraph login --api https://control.example mkdir customer-demo -omnigraph managed create customer-demo --api https://control.example \ +omnigraph cluster create --managed customer-demo --api https://control.example \ --config customer-demo --no-wait --json ``` @@ -28,8 +30,8 @@ sets a local 1–3600-second deadline; the default is 300. A timeout does not cancel creation. Follow the saved operation explicitly: ```sh -omnigraph managed operation OPERATION_ID --config customer-demo --json -omnigraph managed operation OPERATION_ID --api https://control.example \ +omnigraph cluster operation --managed OPERATION_ID --config customer-demo --json +omnigraph cluster operation --managed OPERATION_ID --api https://control.example \ --wait --timeout 600 --json ``` @@ -56,14 +58,14 @@ Create `customer-demo/catalog.pg` with your graph schema, then read the current managed revision before submitting the edit: ```sh -omnigraph managed status --config customer-demo --json +omnigraph cluster status --managed --config customer-demo --json # Use data.requested.revision from that response. -omnigraph managed push --config customer-demo --expected-revision COMMIT \ +omnigraph cluster push --managed --config customer-demo --expected-revision COMMIT \ --message "Add the knowledge graph" --json # Use data.revision returned by push. -omnigraph managed plan --config customer-demo --rev NEW_COMMIT --json +omnigraph cluster plan --managed --config customer-demo --rev NEW_COMMIT --json # Use data.run_id from the converged plan. -omnigraph managed apply --config customer-demo --plan PLAN_RUN_ID --json +omnigraph cluster apply --managed --config customer-demo --plan PLAN_RUN_ID --json ``` `push` uploads only `cluster.yaml`, referenced schemas, stored queries and @@ -86,7 +88,7 @@ permission and never executes a plan; `apply` requires its separate permission. Use the exact incarnation from your cluster's response: ```sh -omnigraph managed delete --config customer-demo --incarnation INCARNATION \ +omnigraph cluster delete --managed --config customer-demo --incarnation INCARNATION \ --idempotency-key customer-demo-delete --json ``` @@ -105,7 +107,7 @@ Before the deadline and irreversible retirement, request undo of the exact deletion operation: ```sh -omnigraph managed undo-delete --config customer-demo --incarnation INCARNATION \ +omnigraph cluster undo-delete --managed --config customer-demo --incarnation INCARNATION \ --deletion-id DELETE_OPERATION_ID --json ``` diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index b7bd7f35c..cafc2c48a 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -62,7 +62,7 @@ server resolves the actor from the bearer token. Drop it, or use `--store ` | `graphs list` | List graph metadata or minimal identity discovery | served | | `queries list/validate` | Inspect or validate a cluster query registry | cluster | | `cluster validate/plan/apply/...` | Operate self-hosted declarative state; [deployment/recovery flags](../clusters/index.md) | config, server, explicit root | -| `managed create/push/plan/apply/...` | Operate a managed service cluster | managed API and folder context | +| `cluster --managed` | Operate a managed service cluster | managed API and folder context | | `policy validate/test/explain` | Validate or evaluate applied policy | cluster | | `embed` | Generate, clean, or refresh seed embeddings | local tooling | | `login`, `logout` | Manage a named server credential or a managed API session | local or managed API | @@ -229,6 +229,10 @@ invocation. ## Managed cluster commands +Add `--managed` to select the managed service explicitly. Without it, `cluster` +uses self-hosted configuration or an explicit server/root and ignores managed +folder context. The flag can appear before or after the subcommand. + `omnigraph login --api ORIGIN` reuses valid cached access or prints a WorkOS AuthKit verification URL and user code. The OS keychain holds provider-bound access and rotating refresh credentials; old opaque sessions are not reused. @@ -261,11 +265,11 @@ query, or fragment. HTTPS is required except for exact localhost, | Managed command | Behavior | |---|---| -| `managed plan [--rev REVISION]` | Plan the pushed revision, or the bound head when omitted | -| `managed apply --plan PLAN_RUN_ID` | Apply exactly that saved plan with current permissions | -| `managed status [RUN_ID]` | Read the cluster projections, or one run belonging to that cluster | -| `managed history [--limit N] [--since RFC3339]` | Read up to N runs, default 100, maximum 1000 | -| `managed cancel RUN_ID` | Cancel a pending run; abandon a converged unused plan and release its lease | +| `cluster plan --managed [--rev REVISION]` | Plan the pushed revision, or the bound head when omitted | +| `cluster apply --managed --plan PLAN_RUN_ID` | Apply exactly that saved plan with current permissions | +| `cluster status --managed [RUN_ID]` | Read the cluster projections, or one run belonging to that cluster | +| `cluster history --managed [--limit N] [--since RFC3339]` | Read up to N runs, default 100, maximum 1000 | +| `cluster cancel --managed RUN_ID` | Cancel a pending run; abandon a converged unused plan and release its lease | See [managed lifecycle](managed-lifecycle.md) for creation, upload, deletion, undo and operation status. @@ -276,12 +280,12 @@ submission. Reuse that key with the same body to recover from an uncertain response; changing the body under a key is refused by the API. Retry cancellation or abandonment using the same run id. Plan and apply do not upload local files or infer a revision from uncommitted -changes; `managed push` explicitly prepares managed source. A saved plan retains the service's change lease +changes; `cluster push --managed` explicitly prepares managed source. A saved plan retains the service's change lease until it is applied, abandoned, or expires under the API's rules. Plan and apply normally poll every two seconds for up to 300 seconds. `--timeout` accepts 1–3600 seconds. Reaching the deadline stops only the local -wait; inspect `managed status RUN_ID` to continue following the run. +wait; inspect `cluster status --managed RUN_ID` to continue following the run. `--no-wait` prints the accepted run and exits 0. Every HTTP request has a 10-second deadline and an 8 MiB response limit; redirects are refused. `--json` prints one API envelope to stdout with its provenance and @@ -310,18 +314,22 @@ its API origin together: ```bash export OMNIGRAPH_CONTROL_API=https://control.example # Supply OMNIGRAPH_CONTROL_TOKEN through your CI secret mechanism. -omnigraph managed apply --plan PLAN_RUN_ID --idempotency-key DEPLOYMENT_KEY --json +omnigraph cluster apply --managed --plan PLAN_RUN_ID --idempotency-key DEPLOYMENT_KEY --json ``` The canonical `OMNIGRAPH_CONTROL_API` must match the selected context. A missing or mismatched pair refuses before any request. These credentials are separate from `OMNIGRAPH_BEARER_TOKEN`, named servers, and operator profiles. -Self-hosted `cluster` commands always ignore managed context. The `managed` family -requires its own context, except creation and explicit-origin operation lookup; -`--direct` is rejected. Managed API failures never trigger local deployment. -Use `managed operation OPERATION_ID [--wait]` for service lifecycle observation; -`managed status [RUN_ID]` reads cluster projections or a managed run. Self-hosted +`--managed` requires its own context, except creation and explicit-origin +operation lookup; `--direct` is rejected. Managed API failures never trigger +local deployment. Service-only `create`, `push`, `delete`, `undo-delete`, `token`, +`operation`, `history`, and `cancel` require `--managed`. Local `validate`, +`observe`, `force-unlock`, and `upgrade-ledger` reject it. Managed `plan` takes +`--rev` and managed `apply` requires `--plan`; self-hosted deployment flags cannot +be combined with them. +Use `cluster operation --managed OPERATION_ID [--wait]` for service lifecycle observation; +`cluster status --managed [RUN_ID]` reads cluster projections or a managed run. Self-hosted `cluster status --deployment-id ID` addresses a separate deployment receipt. ## Managed data access diff --git a/skills/omnigraph/SKILL.md b/skills/omnigraph/SKILL.md index c36b6bf03..1a422749b 100644 --- a/skills/omnigraph/SKILL.md +++ b/skills/omnigraph/SKILL.md @@ -49,7 +49,7 @@ See [traversal](../../docs/user/queries/traversal.md) and 1. **Lint before commit** — `omnigraph lint --schema schema.pg --query queries/foo.gq` validates both sides against each other. No running repo required. 2. **Plan before apply** — never run `schema apply` without a successful `schema plan` first. Apply is destructive; plan is free. (Cluster mode has the same rule with different verbs: `cluster plan` before `cluster apply` — the plan embeds the engine's real migration steps.) -3. **Branches are for data; apply is for schema** — review bulk data loads on a feature branch then merge. Schema changes go straight to `main`: in cluster mode edit the `.pg` and run `cluster apply` (`--server ` deploys to the running server without a restart; without it serving must be stopped first; a direct `schema apply` **refuses** a cluster-managed graph); for the managed service commit, `managed push`, `managed plan --rev `, then `managed apply --plan `; `schema plan`/`apply` is for a non-cluster store. +3. **Branches are for data; apply is for schema** — review bulk data loads on a feature branch then merge. Schema changes go straight to `main`: in cluster mode edit the `.pg` and run `cluster apply` (`--server ` deploys to the running server without a restart; without it serving must be stopped first; a direct `schema apply` **refuses** a cluster-managed graph); for the managed service commit, `cluster push --managed`, `cluster plan --managed --rev `, then `cluster apply --managed --plan `; `schema plan`/`apply` is for a non-cluster store. 4. **Pick the right write command** — `mutate` for edits (typechecked, parameterized); `load` for bulk JSONL, local **or** remote, with a **required** `--mode` (`merge` upsert · `append` strict-insert · `overwrite` replaces only the node/edge types represented in the batch). `load --from ` forks a review branch in one shot; bare `load` needs an existing target branch. 5. **Parameterize everything** — never string-interpolate values into `.gq` bodies or `--params`. Declare `$var: Type` and pass via `--params`. 6. **Expose agent reads as aliases** — aliases decouple a read operation name @@ -158,7 +158,7 @@ single source of truth. Notation: `` required · `[x]` optional · `` choice · `…` repeatable. -**Global addressing flags**: `--as ` (direct-engine writes, `rebuild-full-text-indexes`, and `cluster apply`/`upgrade-ledger`; a served write **refuses** `--as` because the server resolves the actor from the bearer token, and read verbs reject it), `--server `, `--cluster ` (cluster-managed storage, primarily for maintenance), `--graph ` (selects within a `--server` or `--cluster` scope; required for managed queries, mutations, loads and commit reads), `--profile ` (`$OMNIGRAPH_PROFILE`), `--store `, `--direct` (ignore managed context for data commands). Commands with an open positional slot also accept `file://`, `s3://`, or preview `az://` directly. `--config ` selects source/context folders for `cluster`, `managed`, and `use`. Output: `--json`, or read queries take `--format ` (`arrow` is listed but always fails in 0.12.0). **Write guards:** `--yes` skips non-local confirmation for destructive writes; `--quiet` suppresses the resolved-target echo. +**Global addressing flags**: `--as ` (direct-engine writes, `rebuild-full-text-indexes`, and `cluster apply`/`upgrade-ledger`; a served write **refuses** `--as` because the server resolves the actor from the bearer token, and read verbs reject it), `--server `, `--cluster ` (cluster-managed storage, primarily for maintenance), `--graph ` (selects within a `--server` or `--cluster` scope; required for managed queries, mutations, loads and commit reads), `--profile ` (`$OMNIGRAPH_PROFILE`), `--store `, `--direct` (ignore managed context for data commands). Commands with an open positional slot also accept `file://`, `s3://`, or preview `az://` directly. `--config ` selects source/context folders for `cluster` and `use`. Output: `--json`, or read queries take `--format ` (`arrow` is listed but always fails in 0.12.0). **Write guards:** `--yes` skips non-local confirmation for destructive writes; `--quiet` suppresses the resolved-target echo. **Data plane** — `any` (served via `--server`/`--profile`, or direct via `--store`/URI): - `query` `` — a **served stored query** by name (via `--server`/`--profile`); or ad-hoc `[] (--query | -e '')` where `` picks which query in the source. `[--params | --params-file

] [--branch | --snapshot ] [--format | --json]`. No positional URI — address via `--server`/`--store`/`--profile`. @@ -196,9 +196,9 @@ accept `--cluster --graph `: - `rebuild-full-text-indexes [--branch ] [--json]` — replace full-text indexes on one branch with default English analysis; custom tokenizer settings are replaced. Stop overlapping writers and retain a whole-store backup for upgrades. `--as` records attribution; direct access does not load server policy. See [maintenance commands](references/commands.md#rebuild-full-text-indexes--explicit-analyzer-upgrade). **Control plane**: -- `cluster [--config

] [--json]` — always self-hosted, independent of managed context. `plan` and `observe` are read-only. `plan`, `apply`, and `status` accept `--server `. Served `apply` waits for activation; `--no-wait` returns after durable acceptance, and `--timeout <1..3600>` bounds caller waiting. Resume observation with `status --deployment-id --wait [--timeout ]`; timeout never cancels execution. Direct recovery uses the original `--deployment-id` with `--writers-stopped`. +- `cluster [--config ] [--json]` — self-hosted unless `--managed` is explicit; folder context never selects the mode. `plan` and `observe` are read-only. `plan`, `apply`, and `status` accept `--server `. Served `apply` waits for activation; `--no-wait` returns after durable acceptance, and `--timeout <1..3600>` bounds caller waiting. Resume observation with `status --deployment-id --wait [--timeout ]`; timeout never cancels execution. Direct recovery uses the original `--deployment-id` with `--writers-stopped`. - `cluster force-unlock [--config ] [--json]` · `--cluster cluster upgrade-ledger --writers-stopped [--json]` (explicit conversion of a supported legacy ledger, with serving and writers stopped) -- Managed service: `use --api ` · `managed --api | push --expected-revision --message | plan [--rev ] | apply --plan | status [RUN_ID] | operation [--wait] | history | cancel | token ([--ttl 1h] | --clear) | delete --incarnation | undo-delete --incarnation --deletion-id >` — see [managed clusters](references/cluster.md#managed-clusters) +- Managed service: `use --api ` · `cluster --managed` for `create --api `, `push --expected-revision --message `, `plan [--rev ]`, `apply --plan `, `status [RUN_ID]`, `operation [--wait]`, `history`, `cancel `, `token ([--ttl 1h] | --clear)`, `delete --incarnation `, or `undo-delete --incarnation --deletion-id `. The mode rejects ordinary target/actor flags and `--direct`; see [managed clusters](references/cluster.md#managed-clusters). - `policy | explain --actor --action [--branch | --target-branch ]> --cluster [--graph ]` - `queries --cluster [--graph ] [--json]` @@ -209,7 +209,7 @@ accept `--cluster --graph `: Managed folders can select an Intent API with `login --api` and `use`; implicit `query`, `mutate`, `load`, `commit list`/`show` and `graphs list` then acquire -and cache a separate identity credential themselves (`managed token` remains +and cache a separate identity credential themselves (`cluster token --managed` remains for credential administration). Global `--direct` selects ordinary addressing. See [managed routing](references/cluster.md#managed-clusters) before using ambient targets or credentials. @@ -261,7 +261,7 @@ Keep development credentials in a git-ignored `.env.omni` and source it before C set -a && source .env.omni && set +a ``` -Direct `init`/`load` and **`cluster apply`** write storage without an HTTP server. A served `load` is different: the CLI sends it to `omnigraph-server`, which performs the graph write. A direct `cluster apply` reaches the cluster ledger and graph datasets directly, so its host needs storage credentials (`managed apply --plan` is executed by the Intent API). A serving process also needs read-write access for served data-plane writes. Validate with `curl http://127.0.0.1:8080/readyz` (HTTP 200, `ready: true`; `/healthz` only proves the process is alive), then `omnigraph snapshot --server --graph --json`. +Direct `init`/`load` and **`cluster apply`** write storage without an HTTP server. A served `load` is different: the CLI sends it to `omnigraph-server`, which performs the graph write. A direct `cluster apply` reaches the cluster ledger and graph datasets directly, so its host needs storage credentials (`cluster apply --managed --plan` is executed by the Intent API). A serving process also needs read-write access for served data-plane writes. Validate with `curl http://127.0.0.1:8080/readyz` (HTTP 200, `ready: true`; `/healthz` only proves the process is alive), then `omnigraph snapshot --server --graph --json`. ## Project Layout @@ -308,7 +308,7 @@ aliases: # personal bindings to TEAM stored queries (see ref triage: { server: intel-dev, graph: spike, query: weekly_triage, args: [since] } ``` -The operator config and credentials are **auto-discovered — no flag points at them**: the CLI reads `$OMNIGRAPH_HOME/config.yaml` (default `~/.omnigraph/config.yaml`), and an absent file is just an empty layer (zero-config). `$OMNIGRAPH_HOME` relocates the *directory* only, not a specific file. `--config` selects source/context folders for `cluster`, `managed`, and `use`. +The operator config and credentials are **auto-discovered — no flag points at them**: the CLI reads `$OMNIGRAPH_HOME/config.yaml` (default `~/.omnigraph/config.yaml`), and an absent file is just an empty layer (zero-config). `$OMNIGRAPH_HOME` relocates the *directory* only, not a specific file. `--config` selects source/context folders for `cluster` and `use`. Credentials live outside config: `echo $TOKEN | omnigraph login intel-dev` writes `~/.omnigraph/credentials` (`0600`); the matching token resolves via @@ -357,7 +357,7 @@ These are the traps most likely to bite. Scan this table before debugging any pa | `$p.id`, `{ id: $v }`, `where id =` from a v0.10 query | ``T6``/``T2``/``T11``: ``type `Person` has no property `id`; the system identity is `$p.@id` `` (a failing stored-query registry quarantines its graph) | Use `$p.@id`, `$e.@src`/`@dst`, `where @id =`; lint every `.gq` before restarting on v0.11 or later | | Long-lived feature branches | merge conflicts, schema apply blocked | Merge promptly; delete when done | | `mutation { ... }` wrapper in `.gq` | `parse error: expected query_file` at line 1 | Use `query (...) { insert T { ... } }`; there is no top-level `mutation` keyword | -| `--config` on a data/schema command | `unexpected argument '--config' found` | Use it for `cluster`, `managed`, and `use`; use `--server`/`--graph`, `--store`, or `--profile` elsewhere | +| `--config` on a data/schema command | `unexpected argument '--config' found` | Use it for `cluster` and `use`; use `--server`/`--graph`, `--store`, or `--profile` elsewhere | | `--as` on a served write | `` `--as` is not allowed on a served write `` | Drop it; the bearer token selects the actor | | Reading a large schema via stdout-capped tool | Truncated, garbled, or duplicated output | `omnigraph schema show --server --graph > /tmp/schema.pg`, then read the file in chunks | | `omnigraph load` without `--mode` | `the following required arguments were not provided: --mode ` | Pass `--mode merge\|append\|overwrite` — there is no default (overwrite is destructive, so it is never implicit). Address direct storage or a served graph | diff --git a/skills/omnigraph/references/cluster.md b/skills/omnigraph/references/cluster.md index cab92b9b9..383f5d3b1 100644 --- a/skills/omnigraph/references/cluster.md +++ b/skills/omnigraph/references/cluster.md @@ -161,8 +161,9 @@ Deployment across graphs is not one transaction. | `~/.omnigraph/config.yaml` | per-operator: identity (`operator.actor`), named `servers:`, output defaults, personal aliases | data-plane CLI commands (tokens live in `~/.omnigraph/credentials` via `omnigraph login`) | Direct cluster commands use the operator actor default when `--as` is omitted -(`--as` > `operator.actor`). `cluster` always selects self-hosted deployment, -independent of managed context; `managed` selects the service API described below. +(`--as` > `operator.actor`). `cluster` selects self-hosted deployment unless +`--managed` explicitly selects the service API described below. Folder context +never selects the mode. A `--cluster` server never reads the operator file: its configuration comes from applied cluster state. Address a cluster-managed graph's data via `--server`/aliases against the @@ -223,8 +224,8 @@ An Intent API can own the control plane while the same CLI operates it: ```bash omnigraph login --api https://control.example omnigraph use CLUSTER_ID --api https://control.example --config . -omnigraph managed plan --config . --json -omnigraph managed apply --plan PLAN_RUN_ID --config . --json +omnigraph cluster plan --managed --config . --json +omnigraph cluster apply --managed --plan PLAN_RUN_ID --config . --json omnigraph query find_person --graph knowledge --params '{"name":"Alice"}' --json ``` @@ -240,27 +241,29 @@ ordinary routing; global `--direct` selects ordinary ambient defaults. Missing or malformed managed authority refuses without fallback, and competing ambient targets require an explicit choice. -Managed creation, config upload, deletion and undo use `managed create`, `push`, -`delete` and `undo-delete`. `managed status [RUN_ID]` reads cluster projections or -one run; `managed operation ID [--wait]` observes a lifecycle operation. Durable +Managed creation, config upload, deletion and undo use `cluster create`, `push`, +`delete` and `undo-delete`, each with `--managed`. +`cluster status --managed [RUN_ID]` reads cluster projections or one run; +`cluster operation --managed ID [--wait]` observes a lifecycle operation. Durable operation records bind uncertain submissions to their exact identity. Reconcile the existing operation before issuing another. -- **Schema/config changes**: commit, `managed push --expected-revision - --message …`, `managed plan --rev `, then `managed apply --plan `. +- **Schema/config changes**: commit, `cluster push --managed --expected-revision + --message …`, `cluster plan --managed --rev `, then `cluster apply --managed --plan `. The Intent API executes the apply; there is no local `--as` or approval step. -- **Explicit namespace**: `managed` requires context, except creation and - explicit-origin operation lookup. `cluster` ignores context and needs no +- **Explicit mode**: `--managed` requires context, except creation and + explicit-origin operation lookup. Without it, `cluster` ignores context and needs no `--direct` escape. Managed operations reject `--direct`, `--as`, `--server`, `--profile`, `--graph`, `--store`, and `--cluster`; failures never fall back to - local deployment. + local deployment. Service-only verbs require `--managed`; `validate`, + `observe`, `force-unlock`, and `upgrade-ledger` reject it. - **Sessions** from `login --api` hold an access credential of at most 15 minutes that renews silently for up to eight hours after sign-in; then log in again. For unattended runs, set `OMNIGRAPH_CONTROL_API` and `OMNIGRAPH_CONTROL_TOKEN` together; reuse the same `--idempotency-key` after an uncertain response. - **Data credentials**: graph commands acquire an identity credential on - their own; `managed token [--ttl 1h]` issues one explicitly (TTL 60s–24h, + their own; `cluster token --managed [--ttl 1h]` issues one explicitly (TTL 60s–24h, default 1h) and `--clear` forgets the local copy without revoking it. Only identity credentials are supported: they carry no graph or action grants, and the applied Cedar policy must permit the authenticated actor. diff --git a/skills/omnigraph/references/commands.md b/skills/omnigraph/references/commands.md index b091458b8..9b622a2cd 100644 --- a/skills/omnigraph/references/commands.md +++ b/skills/omnigraph/references/commands.md @@ -282,7 +282,7 @@ omnigraph login --api # managed Intent API session (OS keyc omnigraph logout --api # revoke that session and remove its keychain entry ``` -The operator config and `~/.omnigraph/credentials` are **auto-discovered — there is no flag to point at them.** `$OMNIGRAPH_HOME` relocates the `~/.omnigraph` directory, and an absent file is an empty layer. `--config` selects source/context folders for `cluster`, `managed`, and `use`. +The operator config and `~/.omnigraph/credentials` are **auto-discovered — there is no flag to point at them.** `$OMNIGRAPH_HOME` relocates the `~/.omnigraph` directory, and an absent file is an empty layer. `--config` selects source/context folders for `cluster` and `use`. ## Addressing a Graph @@ -304,7 +304,7 @@ operator default server/store competes, they refuse with `managed_target_ambiguous`. Global `--direct` restores the ordinary resolution above. Other data commands are unaffected. -`cluster` and `managed` use `--config ` for source/context folders; policy +`cluster` uses `--config ` for source/context folders; policy and stored-query control-plane commands use `--cluster `. Maintenance against a cluster-managed graph uses `--cluster --graph `. Each command declares a **capability** — `any` / `served` / `direct` / @@ -388,22 +388,22 @@ The 0.11 verbs `import`, `refresh` and `approve` are removed. A fresh direct its admission lock after it completes: once the owner and its I/O have settled, release that exact lock id with `force-unlock` before starting the server. -Self-hosted `cluster` commands always ignore managed context. The managed -service has its own namespace (see [`cluster.md`](cluster.md#managed-clusters)): +`cluster` commands select the managed service only with `--managed`; otherwise +they ignore managed context (see [`cluster.md`](cluster.md#managed-clusters)): ```bash omnigraph use --api [--config ] -omnigraph managed create --api -omnigraph managed push --expected-revision --message -omnigraph managed plan [--rev ] -omnigraph managed apply --plan -omnigraph managed status [RUN_ID] -omnigraph managed operation [--api ] [--wait] [--timeout 1800] -omnigraph managed history [--limit N] [--since ] -omnigraph managed cancel -omnigraph managed token ([--ttl 1h] | --clear) # identity only; applied Cedar policy decides access -omnigraph managed delete --incarnation [--retention-seconds 86400] -omnigraph managed undo-delete --incarnation --deletion-id +omnigraph cluster create --managed --api +omnigraph cluster push --managed --expected-revision --message +omnigraph cluster plan --managed [--rev ] +omnigraph cluster apply --managed --plan +omnigraph cluster status --managed [RUN_ID] +omnigraph cluster operation --managed [--api ] [--wait] [--timeout 1800] +omnigraph cluster history --managed [--limit N] [--since ] +omnigraph cluster cancel --managed +omnigraph cluster token --managed ([--ttl 1h] | --clear) # identity only; applied Cedar policy decides access +omnigraph cluster delete --managed --incarnation [--retention-seconds 86400] +omnigraph cluster undo-delete --managed --incarnation --deletion-id ``` Managed plan, apply, create, delete, and undo-delete take `--no-wait`, From 9e725bf94af7b5004d44c0d749a9eceeec6f52af Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 6 Oct 2026 23:53:58 +0300 Subject: [PATCH 11/12] test: settle plan observers and register root-marker deletion --- .../omnigraph-server/tests/boot_settings.rs | 20 +++++++++++++++++-- crates/omnigraph/tests/forbidden_apis.rs | 4 +++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index b47dfac9b..e4b3155c5 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -2361,7 +2361,23 @@ async fn live_deployment_fixture(mode: &str) { ) .unwrap(); let candidate = omnigraph_cluster::capture_deployment(temp.path()).unwrap(); - let owners_before_plan = state.operation_runtime().snapshot(); + // Response delivery can precede the producer's final observer drop on + // another worker. Settle read owners before comparing stable snapshots; + // the held merge's write and response owners must remain charged. + let settled_owners = || async { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let owners = state.operation_runtime().snapshot(); + if owners.active_reads == 0 { + return owners; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("completed read producers must release their observers") + }; + let owners_before_plan = settled_owners().await; let plan_request = Request::post("/cluster/plan") .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) .header("authorization", "Bearer operator-token") @@ -2403,12 +2419,12 @@ async fn live_deployment_fixture(mode: &str) { }), "{plan}" ); + assert_eq!(settled_owners().await, owners_before_plan); assert!( !merger.is_finished(), "planning must not release the parked writer" ); assert!(!hold.timed_out()); - assert_eq!(state.operation_runtime().snapshot(), owners_before_plan); assert_eq!( fs::read(temp.path().join("__cluster/state.json")).unwrap(), before diff --git a/crates/omnigraph/tests/forbidden_apis.rs b/crates/omnigraph/tests/forbidden_apis.rs index 58d0802f8..86a664b27 100644 --- a/crates/omnigraph/tests/forbidden_apis.rs +++ b/crates/omnigraph/tests/forbidden_apis.rs @@ -871,7 +871,9 @@ durable_calls! { ("omnigraph-core/storage.rs", ".rename_text(", 1, WriteProtocol::Composed("engine storage compatibility forwarding")), ("omnigraph-core/storage.rs", ".delete(", 1, WriteProtocol::Composed("engine storage compatibility forwarding")), ("omnigraph-core/storage.rs", ".delete_prefix(", 1, WriteProtocol::Composed("engine storage compatibility forwarding")), - ("omnigraph-storage/lib.rs", ".delete(", 3, WriteProtocol::Composed("storage adapter primitive, including Azure rename source retirement")), + // Prefix deletion also removes the exact root marker after descendants; + // callers supply deletion authority and keep writers excluded throughout. + ("omnigraph-storage/lib.rs", ".delete(", 4, WriteProtocol::Composed("ordinary object deletion, Azure rename source retirement, prefix descendants and exact root marker removal")), // One `.put(` beyond upstream's Azure set: the binary `write_bytes` // primitive (graph-index artifact), same atomic-visibility PUT contract // as `write_text`. From ba72e5ca2354d4b839d460f9e5a72a5570b49ba5 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Wed, 7 Oct 2026 00:44:35 +0300 Subject: [PATCH 12/12] test: retire vocabulary inventory for removed legacy response --- .../graph-vocabulary-inventory.tsv | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv b/tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv index 33650b14c..de0704374 100644 --- a/tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv +++ b/tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv @@ -1,9 +1,4 @@ schema_version occurrence_id surface source_path boundary site_kind current_term target_term classification compatibility_treatment rollout_phase test_owner rationale -2 openapi:%2Fcomponents%2Fschemas%2FLegacyReadOutput%2Fproperties%2Fcolumns:property_name:0e624a1a135e4441e96ebbd5f95bc3df19c3c138df98c61ce684ef311d6e8b1a:columns:1 openapi openapi.json /components/schemas/LegacyReadOutput/properties/columns property_name columns retain result_projection retain exact query-result name none omnigraph-vocabulary-guard::openapi_inventory_matches Query-result rows and columns are canonical tabular projection vocabulary and are intentionally unchanged. -2 openapi:%2Fcomponents%2Fschemas%2FLegacyReadOutput%2Fproperties%2Frow_count:property_name:d5a76110022e4737009e0cfd33d38eb9a57de63480648a62b474c248b071abc8:row:1 openapi openapi.json /components/schemas/LegacyReadOutput/properties/row_count property_name row retain result_projection retain exact query-result name none omnigraph-vocabulary-guard::openapi_inventory_matches Query-result rows and columns are canonical tabular projection vocabulary and are intentionally unchanged. -2 openapi:%2Fcomponents%2Fschemas%2FLegacyReadOutput%2Fproperties%2Frows:property_name:bc51e9e65d79e1c90ba544ab2eae1044ccdf8c1af7a559ba375cba4276f7d99e:rows:1 openapi openapi.json /components/schemas/LegacyReadOutput/properties/rows property_name rows retain result_projection retain exact query-result name none omnigraph-vocabulary-guard::openapi_inventory_matches Query-result rows and columns are canonical tabular projection vocabulary and are intentionally unchanged. -2 openapi:%2Fcomponents%2Fschemas%2FLegacyReadOutput%2Frequired%2F2:required_name:d5a76110022e4737009e0cfd33d38eb9a57de63480648a62b474c248b071abc8:row:1 openapi openapi.json /components/schemas/LegacyReadOutput/required/2 required_name row retain result_projection retain exact query-result name none omnigraph-vocabulary-guard::openapi_inventory_matches Query-result rows and columns are canonical tabular projection vocabulary and are intentionally unchanged. -2 openapi:%2Fcomponents%2Fschemas%2FLegacyReadOutput%2Frequired%2F3:required_name:bc51e9e65d79e1c90ba544ab2eae1044ccdf8c1af7a559ba375cba4276f7d99e:rows:1 openapi openapi.json /components/schemas/LegacyReadOutput/required/3 required_name rows retain result_projection retain exact query-result name none omnigraph-vocabulary-guard::openapi_inventory_matches Query-result rows and columns are canonical tabular projection vocabulary and are intentionally unchanged. 2 openapi:%2Fcomponents%2Fschemas%2FPositionOutput%2Fdescription:prose:388f0164fc5740082dfd4138cdd20acf055873de69aa3f659ab7e53e21667071:column:1 openapi openapi.json /components/schemas/PositionOutput/description prose column retain renderer retain exact source-position name none omnigraph-vocabulary-guard::openapi_inventory_matches A source position's column is the character offset in a line, the diagnostics contract's coordinate, not a tabular or physical column. 2 openapi:%2Fcomponents%2Fschemas%2FPositionOutput%2Fproperties%2Fcolumn:property_name:881d5fb60090f59aabba4f287d7596317ca7091d00c2180b6cbc8ab018b9febd:column:1 openapi openapi.json /components/schemas/PositionOutput/properties/column property_name column retain renderer retain exact source-position name none omnigraph-vocabulary-guard::openapi_inventory_matches A source position's column is the character offset in a line, the diagnostics contract's coordinate, not a tabular or physical column. 2 openapi:%2Fcomponents%2Fschemas%2FPositionOutput%2Frequired%2F1:required_name:881d5fb60090f59aabba4f287d7596317ca7091d00c2180b6cbc8ab018b9febd:column:1 openapi openapi.json /components/schemas/PositionOutput/required/1 required_name column retain renderer retain exact source-position name none omnigraph-vocabulary-guard::openapi_inventory_matches A source position's column is the character offset in a line, the diagnostics contract's coordinate, not a tabular or physical column. @@ -21,12 +16,9 @@ schema_version occurrence_id surface source_path boundary site_kind current_term 2 openapi:%2Fpaths%2F~1graphs~1%7Bgraph_id%7D~1commits%2Fget%2Fdescription:prose:9e573b911906035e5b66f45f17a952ce0870a38f7a23f690b83f8b62ab972ae0:manifest%20version:1 openapi openapi.json /paths/~1graphs~1{graph_id}~1commits/get/description prose manifest version retain physical_storage reviewed_semantic_reclassification presentation omnigraph-vocabulary-guard::openapi_inventory_matches The revised prose explicitly qualifies this as the graph-manifest version. 2 openapi:%2Fpaths%2F~1graphs~1%7Bgraph_id%7D~1commits~1%7Bcommit_id%7D%2Fget%2Fdescription:prose:455b78caaa908e2ec6c4c22a461858ec8b2e6d16a224b9a9332cbe7da1ab5ec1:manifest%20version:1 openapi openapi.json /paths/~1graphs~1{graph_id}~1commits~1{commit_id}/get/description prose manifest version retain physical_storage reviewed_semantic_reclassification presentation omnigraph-vocabulary-guard::openapi_inventory_matches The revised prose explicitly qualifies this as the graph-manifest version. 2 openapi:%2Fpaths%2F~1graphs~1%7Bgraph_id%7D~1snapshot%2Fget%2Fdescription:prose:da3fc24dc381d981d21eaf8ae2fe809750334d40ed5cfd822e66c65698412629:manifest%20version:1 openapi openapi.json /paths/~1graphs~1{graph_id}~1snapshot/get/description prose manifest version retain physical_storage reviewed_semantic_reclassification presentation omnigraph-vocabulary-guard::openapi_inventory_matches The revised prose explicitly qualifies this as the graph-manifest version. -2 public_rust:omnigraph-api-types:0542e981eddf3dd394d56fdbaaef8ccc5697323547b527a4617b16a202a314c5:row:1:1 public_rust crates/omnigraph-api-types/Cargo.toml pub omnigraph_api_types::LegacyReadOutput::row_count: usize public_signature row retain result_projection retain tabular query-result API none omnigraph-vocabulary-guard::public_rust_inventory_matches Rows and columns are accurate for this tabular query/result projection. 2 public_rust:omnigraph-api-types:182b9743f8ba4bfa78effeb3eca5e20db91d7dd595529977a06dfd464dca08ad:rows:1:1 public_rust crates/omnigraph-api-types/Cargo.toml pub omnigraph_api_types::ReadOutput::rows: serde_json::value::Value public_signature rows retain result_projection retain tabular query-result API none omnigraph-vocabulary-guard::public_rust_inventory_matches Rows and columns are accurate for this tabular query/result projection. -2 public_rust:omnigraph-api-types:27eb1709ae92e5d8ec19a9f3e755dfaad0283c854ef407f3b61a4e66155a004f:rows:1:1 public_rust crates/omnigraph-api-types/Cargo.toml pub omnigraph_api_types::LegacyReadOutput::rows: serde_json::value::Value public_signature rows retain result_projection retain tabular query-result API none omnigraph-vocabulary-guard::public_rust_inventory_matches Rows and columns are accurate for this tabular query/result projection. 2 public_rust:omnigraph-api-types:2ba22fbca1034b3851a00def96be95dba6a02cfcd1b03a665e809d5059fa4534:row:1:1 public_rust crates/omnigraph-api-types/Cargo.toml pub omnigraph_api_types::ReadOutput::row_count: usize public_signature row retain result_projection retain tabular query-result API none omnigraph-vocabulary-guard::public_rust_inventory_matches Rows and columns are accurate for this tabular query/result projection. 2 public_rust:omnigraph-api-types:9c70908c9dc2ff567a4328d10c1139e1520a71923c2d4d3c93988d4d3c9250fd:columns:1:1 public_rust crates/omnigraph-api-types/Cargo.toml pub omnigraph_api_types::ReadOutput::columns: alloc::vec::Vec public_signature columns retain result_projection retain tabular query-result API none omnigraph-vocabulary-guard::public_rust_inventory_matches Rows and columns are accurate for this tabular query/result projection. -2 public_rust:omnigraph-api-types:f11e9e006d7457f45384e98f7abd91e1cce1c875e0e1c9167915d01ed2e2e473:columns:1:1 public_rust crates/omnigraph-api-types/Cargo.toml pub omnigraph_api_types::LegacyReadOutput::columns: alloc::vec::Vec public_signature columns retain result_projection retain tabular query-result API none omnigraph-vocabulary-guard::public_rust_inventory_matches Rows and columns are accurate for this tabular query/result projection. 2 public_rust:omnigraph-compiler:0d00c158b4364038662c5499b64a5d2f80a54d341c3b88257b696650e626ae6e:Table:1:1 public_rust crates/omnigraph-compiler/Cargo.toml impl core::fmt::Display for omnigraph_compiler::catalog::schema_ir::TableIncarnationId public_signature Table retain physical_storage retain persisted physical identity name none omnigraph-vocabulary-guard::public_rust_inventory_matches This is an exact persisted schema/storage identity name, not graph-facing table vocabulary. 2 public_rust:omnigraph-compiler:0d00c158b4364038662c5499b64a5d2f80a54d341c3b88257b696650e626ae6e:Table:2:1 public_rust crates/omnigraph-compiler/Cargo.toml impl core::fmt::Display for omnigraph_compiler::catalog::schema_ir::TableIncarnationId public_signature Table retain physical_storage retain persisted physical identity name none omnigraph-vocabulary-guard::public_rust_inventory_matches This is an exact persisted schema/storage identity name, not graph-facing table vocabulary. 2 public_rust:omnigraph-compiler:10ce74e76c69f659e55673b77aab239f20ce6a948b5b173c10e4a7186cbd13f0:Table:1:1 public_rust crates/omnigraph-compiler/Cargo.toml pub fn omnigraph_compiler::catalog::Catalog::edge_table_incarnation_id(&self, name: &str) -> core::option::Option public_signature Table retain physical_storage retain persisted physical identity name none omnigraph-vocabulary-guard::public_rust_inventory_matches This is an exact persisted schema/storage identity name, not graph-facing table vocabulary.