From 1bc0ea6b51b8bf62e3ee2aff3df392142db95e70 Mon Sep 17 00:00:00 2001 From: Andrew Altshuler Date: Mon, 15 Jun 2026 15:48:29 +0300 Subject: [PATCH] feat(cli): no-default-graph errors list candidate graphs (RFC-011 D7) (#245) When a server/cluster scope resolves with no --graph and no default_graph, the CLI auto-uses a sole graph (cluster) or errors listing the candidate graph ids (cluster catalog; multi-graph server via best-effort GET /graphs), never a silent pick. GraphClient::resolve becomes async; flat/single-graph servers and happy paths are unaffected. --- crates/omnigraph-cli/src/client.rs | 46 ++++++++++++- crates/omnigraph-cli/src/helpers.rs | 37 +++++++++-- crates/omnigraph-cli/src/main.rs | 45 ++++++++----- crates/omnigraph-cli/src/scope.rs | 26 ++++---- crates/omnigraph-cli/tests/cli_cluster.rs | 73 +++++++++++++++++++-- crates/omnigraph-cli/tests/system_remote.rs | 22 +++++++ crates/omnigraph-cluster/src/lib.rs | 2 +- crates/omnigraph-cluster/src/serve.rs | 61 ++++++++++++----- docs/user/cli/reference.md | 8 +++ docs/user/clusters/index.md | 4 ++ 10 files changed, 262 insertions(+), 62 deletions(-) diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 81d3934..41e01ff 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -42,7 +42,7 @@ use crate::helpers::{ ResolvedCliGraph, apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri, legacy_change_request_body, open_local_db_with_policy, query_params_from_json, remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token, - select_named_query, + resolve_server_flag, select_named_query, }; use crate::output::{LoadOutput, load_output_from_result, load_output_from_tables}; use omnigraph_server::config::OmnigraphConfig; @@ -66,6 +66,44 @@ pub(crate) enum GraphClient { }, } +/// RFC-011 Decision 7: a server scope that selects no graph (no `--graph`, no +/// `default_graph`) must not silently fall through to the bare server URL when +/// the server is multi-graph. Best-effort probe `GET /graphs`: a populated list +/// forces `--graph` (listing the candidates); a single-graph/flat server (405), +/// a policy-gated `/graphs`, or an unreachable server all proceed — the bare URL +/// is then correct, or the real request surfaces the failure. Only fires on the +/// no-graph path, so a `--graph`/`default_graph` happy path does no extra I/O. +async fn require_graph_for_multi_graph_server( + config: &OmnigraphConfig, + scope: &crate::scope::ResolvedScope, +) -> Result<()> { + let (Some(server), None) = (scope.server.as_deref(), scope.graph.as_deref()) else { + return Ok(()); + }; + let Some(base) = resolve_server_flag(Some(server), None)? else { + return Ok(()); + }; + let token = resolve_remote_bearer_token(config, Some(&base))?; + let probe = GraphClient::Remote { + http: build_http_client()?, + base_url: base, + token, + }; + if let Ok(resp) = probe.list_graphs().await { + if !resp.graphs.is_empty() { + let ids: Vec<&str> = resp.graphs.iter().map(|g| g.graph_id.as_str()).collect(); + bail!( + "server scope '{server}' has {} {}: [{}]; pass --graph to select one \ + (or set `default_graph` in your operator config)", + ids.len(), + if ids.len() == 1 { "graph" } else { "graphs" }, + ids.join(", ") + ); + } + } + Ok(()) +} + /// A remote graph must be addressed with `--server` (RFC-011): a positional or /// `--uri` `http(s)://` URL no longer auto-dispatches to a server. A remote URL /// produced by a server scope (`via_server`) is fine. @@ -86,7 +124,7 @@ impl GraphClient { /// fork. Mirrors the read verbs' current preamble (`resolve_uri` /// path, not the policy-bearing `resolve_cli_graph`). Used by reads /// and `query` (which opens without policy, like the reads). - pub(crate) fn resolve( + pub(crate) async fn resolve( config: &OmnigraphConfig, server: Option<&str>, graph: Option<&str>, @@ -102,6 +140,7 @@ impl GraphClient { crate::planes::Capability::Any, crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri }, )?; + require_graph_for_multi_graph_server(config, &scope).await?; let (server, graph, uri) = ( scope.server.as_deref(), scope.graph.as_deref(), @@ -133,7 +172,7 @@ impl GraphClient { /// resolved up front. The embedded arm then opens WITH policy. The /// resolution order matches the write arms exactly: server flag → /// bearer token → graph. - pub(crate) fn resolve_with_policy( + pub(crate) async fn resolve_with_policy( config: &OmnigraphConfig, server: Option<&str>, graph: Option<&str>, @@ -149,6 +188,7 @@ impl GraphClient { crate::planes::Capability::Any, crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri }, )?; + require_graph_for_multi_graph_server(config, &scope).await?; let (server, graph, uri) = ( scope.server.as_deref(), scope.graph.as_deref(), diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index a8bcab8..1683ef2 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -632,11 +632,12 @@ pub(crate) async fn resolve_maintenance_uri( } /// Map a resolved direct address to a storage URI: a cluster scope -/// (`--cluster --graph `, or a `--profile` cluster binding) -/// resolves the graph's storage URI from the **served cluster state** (the -/// truth a `--cluster` server serves); otherwise the ordinary positional-URI -/// path. The scope resolver guarantees a cluster scope always carries a graph, -/// so the mismatched arm is defensive. +/// (`--cluster --graph `, or a `--profile` cluster binding) resolves +/// the graph's storage URI from the **served cluster state**; otherwise the +/// ordinary positional-URI path. When a cluster scope carries no graph +/// selection (RFC-011 D7), enumerate the catalog: a sole graph is used +/// automatically, otherwise error and list the candidates so the operator can +/// pass `--graph `. pub(crate) async fn resolve_storage_uri( config: &OmnigraphConfig, cli_uri: Option, @@ -646,8 +647,32 @@ pub(crate) async fn resolve_storage_uri( ) -> Result { match (cluster, cluster_graph) { (Some(cluster), Some(graph_id)) => resolve_cluster_graph_uri(cluster, graph_id).await, + (Some(cluster), None) => { + let graph_id = resolve_sole_cluster_graph(cluster).await?; + resolve_cluster_graph_uri(cluster, &graph_id).await + } (None, None) => resolve_local_uri(config, cli_uri, operation), - _ => bail!("internal error: a cluster scope was resolved without a graph id"), + (None, Some(_)) => { + bail!("internal error: a graph was selected without a cluster scope") + } + } +} + +/// Pick the graph for a cluster scope that has no `--graph`/`default_graph` +/// (RFC-011 D7): exactly one applied graph → use it; zero → error; more than +/// one → error and list the candidates. Never auto-picks among several. +async fn resolve_sole_cluster_graph(cluster: &str) -> Result { + let ids = omnigraph_cluster::cluster_graph_ids(cluster) + .await + .map_err(|diagnostic| color_eyre::eyre::eyre!("{}", diagnostic.message))?; + match ids.as_slice() { + [only] => Ok(only.clone()), + [] => bail!("cluster `{cluster}` has no applied graphs; run `cluster apply` first"), + many => bail!( + "cluster `{cluster}` has {} graphs: [{}]; pass --graph to select one", + many.len(), + many.join(", ") + ), } } diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index a2c30c5..074a1ee 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -186,7 +186,8 @@ async fn main() -> Result<()> { cli.as_actor.as_deref(), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let branch = resolve_branch(&config, branch, None, "main"); if matches!(mode, CliLoadMode::Overwrite) { confirm_destructive("load --mode overwrite", client.uri(), cli.yes, json)?; @@ -224,7 +225,8 @@ async fn main() -> Result<()> { cli.as_actor.as_deref(), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let branch = resolve_branch(&config, branch, None, "main"); let from = resolve_branch(&config, from, None, "main"); echo_write_target(cli.quiet, "ingest", client.uri(), client.is_remote()); @@ -254,7 +256,8 @@ async fn main() -> Result<()> { cli.as_actor.as_deref(), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let from = resolve_branch(&config, from, None, "main"); echo_write_target(cli.quiet, "branch create", client.uri(), client.is_remote()); let payload = client.branch_create_from(&from, &name).await?; @@ -277,7 +280,8 @@ async fn main() -> Result<()> { uri, cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let payload = client.branch_list().await?; if json { print_json(&payload)?; @@ -302,7 +306,8 @@ async fn main() -> Result<()> { cli.as_actor.as_deref(), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; confirm_destructive("branch delete", client.uri(), cli.yes, json)?; echo_write_target(cli.quiet, "branch delete", client.uri(), client.is_remote()); let payload = client.branch_delete(&name).await?; @@ -328,7 +333,8 @@ async fn main() -> Result<()> { cli.as_actor.as_deref(), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let into = resolve_branch(&config, into, None, "main"); echo_write_target(cli.quiet, "branch merge", client.uri(), client.is_remote()); let payload = client.branch_merge(&source, &into).await?; @@ -359,7 +365,8 @@ async fn main() -> Result<()> { uri, cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let payload = client.list_commits(branch.as_deref()).await?; if json { print_json(&payload)?; @@ -381,7 +388,8 @@ async fn main() -> Result<()> { uri, cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let commit = client.get_commit(&commit_id).await?; if json { print_json(&commit)?; @@ -436,7 +444,8 @@ async fn main() -> Result<()> { cli.as_actor.as_deref(), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let schema_source = fs::read_to_string(&schema)?; // The stored-query registry check is an embedded-only concern // (the remote arm ignores the validator — the server runs its @@ -477,7 +486,8 @@ async fn main() -> Result<()> { uri, cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let output = client.schema_source().await?; if json { print_json(&output)?; @@ -528,7 +538,8 @@ async fn main() -> Result<()> { uri, cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let branch = resolve_branch(&config, branch, None, "main"); let payload = client.snapshot(&branch).await?; if json { @@ -553,7 +564,8 @@ async fn main() -> Result<()> { uri, cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let branch = resolve_branch(&config, branch, None, "main"); if jsonl { eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL"); @@ -590,7 +602,8 @@ async fn main() -> Result<()> { uri.or(legacy_uri), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let query_source = resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?; let params_json = load_params_json(¶ms)?; @@ -625,7 +638,8 @@ async fn main() -> Result<()> { cli.as_actor.as_deref(), cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let query_source = resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?; let params_json = load_params_json(¶ms)?; @@ -1005,7 +1019,8 @@ async fn main() -> Result<()> { uri, cli.profile.as_deref(), cli.store.as_deref(), - )?; + ) + .await?; let payload = client.list_graphs().await?; if json { print_json(&payload)?; diff --git a/crates/omnigraph-cli/src/scope.rs b/crates/omnigraph-cli/src/scope.rs index 1adcc07..9d7cf4a 100644 --- a/crates/omnigraph-cli/src/scope.rs +++ b/crates/omnigraph-cli/src/scope.rs @@ -189,15 +189,13 @@ fn scope_from_binding( .map(str::to_string) .unwrap_or(cluster); // A cluster holds many graphs; maintenance addresses one at a time. - let Some(graph) = graph else { - bail!( - "{source} resolves a cluster scope; pass --graph to select which \ - graph to maintain" - ); - }; + // When no `--graph`/`default_graph` is given, leave `cluster_graph` + // empty and defer to the async storage-URI resolver (RFC-011 D7), + // which enumerates the catalog: auto-use a sole graph, else error + // and list the candidates. Ok(ResolvedScope { cluster: Some(root), - cluster_graph: Some(graph), + cluster_graph: graph, ..Default::default() }) } @@ -334,9 +332,13 @@ mod tests { } #[test] - fn cluster_scope_without_a_graph_is_a_loud_error() { + fn cluster_scope_without_a_graph_defers_to_catalog_enumeration() { + // RFC-011 D7: with no `--graph`/`default_graph`, resolution no longer + // bails here — it resolves the cluster root and leaves `cluster_graph` + // empty, deferring to the async storage-URI resolver (which enumerates + // the catalog: auto-use a sole graph, else error listing candidates). let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n"); - let err = resolve_scope( + let scope = resolve_scope( &op, Capability::Direct, ScopeFlags { @@ -344,9 +346,9 @@ mod tests { ..flags() }, ) - .unwrap_err() - .to_string(); - assert!(err.contains("--graph "), "{err}"); + .unwrap(); + assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain")); + assert_eq!(scope.cluster_graph, None); } #[test] diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index 29a08f8..0b0a22b 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -1006,21 +1006,80 @@ fn optimize_unknown_cluster_graph_id_errors() { } #[test] -fn cluster_without_graph_demands_a_graph_selector() { - // A cluster holds many graphs; `--cluster` alone can't pick one. The scope - // resolver demands `--graph ` (replacing the old `--cluster-graph` - // requirement) before it ever touches cluster state. +fn optimize_auto_uses_the_sole_cluster_graph() { + // RFC-011 D7: a cluster with exactly one applied graph needs no --graph — + // the resolver enumerates the catalog and uses the only candidate. + let temp = applied_knowledge_cluster(); + let out = output_success( + cli() + .arg("optimize") + .arg("--cluster") + .arg(temp.path()) + .arg("--json"), + ); + assert!( + parse_stdout_json(&out)["tables"].as_array().is_some(), + "optimize should auto-resolve the sole cluster graph" + ); +} + +/// Stand up an applied cluster with two graphs (`knowledge`, `archive`). +fn applied_two_graph_cluster() -> tempfile::TempDir { + let temp = tempdir().unwrap(); + let root = temp.path(); + fs::write( + root.join("people.pg"), + "node Person {\n name: String @key\n age: I32?\n}\n", + ) + .unwrap(); + fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap(); + fs::write( + root.join("cluster.yaml"), + r#" +version: 1 +metadata: + name: two-graph +state: + backend: cluster + lock: true +graphs: + knowledge: + schema: ./people.pg + archive: + schema: ./people.pg +policies: + base: + file: ./base.policy.yaml + applies_to: [knowledge, archive] +"#, + ) + .unwrap(); + init_named_cluster_graph(root, "knowledge", "people.pg"); + init_named_cluster_graph(root, "archive", "people.pg"); + assert_eq!(cluster_json(root, "import")["ok"], true); + assert_eq!(cluster_json(root, "apply")["converged"], true); + temp +} + +#[test] +fn optimize_on_multi_graph_cluster_without_graph_lists_candidates() { + // RFC-011 D7: >1 graph and no --graph → error naming every candidate, + // never an auto-pick. + let temp = applied_two_graph_cluster(); let out = output_failure( cli() .arg("optimize") .arg("--cluster") - .arg(".") + .arg(temp.path()) .arg("--json"), ); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("--graph "), - "expected --cluster to demand --graph; got: {stderr}" + stderr.contains("2 graphs") + && stderr.contains("archive") + && stderr.contains("knowledge") + && stderr.contains("--graph "), + "expected a candidate-listing error; got: {stderr}" ); } diff --git a/crates/omnigraph-cli/tests/system_remote.rs b/crates/omnigraph-cli/tests/system_remote.rs index cb04735..615e4e1 100644 --- a/crates/omnigraph-cli/tests/system_remote.rs +++ b/crates/omnigraph-cli/tests/system_remote.rs @@ -1136,5 +1136,27 @@ auth: .collect(); assert_eq!(ids, vec!["alpha"]); + // RFC-011 D7: addressing the multi-graph server via `--server ` with no + // `--graph` errors and lists the candidate graphs (the resolver probes + // GET /graphs; the default-env token authorizes it). + let no_graph = cli() + .env("OMNIGRAPH_BEARER_TOKEN", "admin-token") + .arg("query") + .arg("--server") + .arg(&server.base_url) + .arg("-e") + .arg("query q { match { $p: Person { name: \"x\" } } return { $p.name } }") + .output() + .unwrap(); + assert!( + !no_graph.status.success(), + "multi-graph server with no --graph must error" + ); + let stderr = String::from_utf8_lossy(&no_graph.stderr); + assert!( + stderr.contains("alpha") && stderr.contains("--graph "), + "expected a candidate-listing error naming alpha; got: {stderr}" + ); + drop(server); } diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index 0c0f4e6..32abe66 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -28,7 +28,7 @@ mod store; use store::{ClusterStore, StateLockGuard, StateSnapshot}; pub use types::*; use types::*; -pub use serve::{ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage, resolve_graph_storage_uri}; +pub use serve::{ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_graph_ids, cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage, resolve_graph_storage_uri}; use config::{QueriesDecl, observe_declared_graphs, validate_cluster_header, future_field_diagnostics, initial_import_state, observe_live_graph, preview_schema_migration, state_resource_digests, graph_address, policy_address, query_address, schema_address, load_desired, normalize_policy_target, parse_cluster_config, resolve_config_path, resolve_query_decls, validate_id, validate_query_source}; use diff::{FailedGraphOrigin, ResourceKind, append_policy_binding_changes, approved_resources, classify_changes, compute_approvals, compute_blast_radius, demote_dependents_of_failed_graphs, diff_resources, resource_kind}; use sweep::{mark_approvals_consumed, record_approval_consumed, sweep_recovery_sidecars, tombstone_graph_subtree, warn_pending_recovery_sidecars}; diff --git a/crates/omnigraph-cluster/src/serve.rs b/crates/omnigraph-cluster/src/serve.rs index 241ab41..d0c67b4 100644 --- a/crates/omnigraph-cluster/src/serve.rs +++ b/crates/omnigraph-cluster/src/serve.rs @@ -112,28 +112,13 @@ pub async fn cluster_root_for_graph_uri(graph_uri: &str) -> Option { /// `cluster` is a config directory or a storage-root URI (`s3://…`, config-free), /// mirroring the server's `--cluster` dispatch. pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result { - let backend = if cluster.contains("://") { - ClusterStore::for_storage_root(cluster)? - } else { - ClusterStore::for_config_dir(Path::new(cluster)) - }; + let backend = open_cluster_backend(cluster)?; let mut observations = backend.observations(); let snapshot = backend.read_state(&mut observations).await?; - let state = snapshot.state.ok_or_else(|| { - Diagnostic::error( - "cluster_state_missing", - CLUSTER_STATE_FILE, - format!("cluster `{cluster}` has no applied state; run `cluster apply` first"), - ) - })?; + let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?; let address = format!("graph.{graph_id}"); if !state.applied_revision.resources.contains_key(&address) { - let applied: Vec<&str> = state - .applied_revision - .resources - .keys() - .filter_map(|a| a.strip_prefix("graph.")) - .collect(); + let applied = applied_graph_ids(&state); return Err(Diagnostic::error( "graph_not_applied", address, @@ -147,6 +132,46 @@ pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result< Ok(backend.graph_root(graph_id)) } +/// List the graph ids applied in a cluster's served state (sorted). Reads the +/// ledger only — no catalog validation — like `resolve_graph_storage_uri`, so +/// it works on a degraded cluster. Used to enumerate candidates when no +/// `--graph` is selected (RFC-011 Decision 7). +pub async fn cluster_graph_ids(cluster: &str) -> Result, Diagnostic> { + let backend = open_cluster_backend(cluster)?; + let mut observations = backend.observations(); + let snapshot = backend.read_state(&mut observations).await?; + let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?; + Ok(applied_graph_ids(&state)) +} + +fn open_cluster_backend(cluster: &str) -> Result { + if cluster.contains("://") { + ClusterStore::for_storage_root(cluster) + } else { + Ok(ClusterStore::for_config_dir(Path::new(cluster))) + } +} + +fn missing_state_diagnostic(cluster: &str) -> Diagnostic { + Diagnostic::error( + "cluster_state_missing", + CLUSTER_STATE_FILE, + format!("cluster `{cluster}` has no applied state; run `cluster apply` first"), + ) +} + +fn applied_graph_ids(state: &crate::types::ClusterState) -> Vec { + let mut ids: Vec = state + .applied_revision + .resources + .keys() + .filter_map(|a| a.strip_prefix("graph.")) + .map(str::to_string) + .collect(); + ids.sort(); + ids +} + /// Split `/graphs/.omni` → ``, gating on the exact cluster /// graph-layout shape (a single `` segment, no nested path). `None` for /// anything else — no I/O is done for non-cluster-shaped URIs. diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 711a59d..9881315 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -115,6 +115,14 @@ resolves its scope fresh, there is no sticky "current" mode. `--cluster --graph `. A `--graph` flag overrides the profile's default. - A `server`-bound scope on a maintenance verb, or a `cluster`-bound scope on a data verb, is rejected with a message pointing at the right addressing. +- **No graph selected (RFC-011 D7).** When a scope has no `--graph` and no + `default_graph`, the CLI never silently picks: + - **Cluster scope** — exactly **one** applied graph is used automatically; + **several** errors and lists the candidates (from the served catalog). + - **Server scope** — a multi-graph server (any non-empty `GET /graphs`, even a + single entry) errors and lists the candidates: you must pass `--graph `. + A single-graph / flat server (405 on `/graphs`), or one whose `/graphs` is + policy-gated or unreachable, uses its bare URL as before. `--target`, `--cluster-graph`, and the positional-`http(s)://`→remote dispatch have been **removed** (`--graph` is now the one graph selector across server and diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index 9485833..d5c744a 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -271,6 +271,10 @@ not resolvable. Run these from a host with storage access — there are no serve routes for them. Conversely, **`init` refuses** a cluster-managed path: graphs in a cluster are created by `cluster apply`, not by hand. +If the cluster has exactly **one** applied graph you can omit `--graph` — it is +used automatically. With **several**, omitting `--graph` errors and lists the +candidates (RFC-011 D7); it never picks one for you. + Against an **`s3://`-backed cluster** the resolved graph storage is non-local, so a destructive `cleanup` additionally requires **`--yes`** (an interactive prompt otherwise, refusal without a TTY) on top of `--confirm` — see [cli-reference.md](../cli/reference.md)'s