diff --git a/AGENTS.md b/AGENTS.md
index b4453be..a4ad21c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,8 +33,8 @@ OmniGraph is a typed property-graph engine built as a coordination layer over ma
- **Multi-modal querying**: vector ANN (`nearest`), full-text (`search`/`fuzzy`/`match_text`/`bm25`), Reciprocal Rank Fusion (`rrf`), and graph traversal (`Expand`, anti-join `not { … }`) in one runtime.
- **Branches and commits across the whole graph**: Git-style — every successful publish appends to a commit DAG; merges are three-way at the row level.
- **Atomic per-query writes**: `mutate_as` and `load` accumulate insert/update batches into an in-memory `MutationStaging.pending` per touched table; one `stage_*` + `commit_staged` per table runs at end-of-query, then `ManifestBatchPublisher::publish` commits the manifest atomically with per-table `expected_table_versions` CAS. A mid-query failure leaves Lance HEAD untouched on staged tables — no drift, no run state machine, no staging branches. Deletes still inline-commit; D₂ at parse time prevents inserts/updates and deletes from coexisting in one query.
-- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager). Cedar policy enforcement is engine-wide — every `_as` writer calls `Omnigraph::enforce(action, scope, actor)`, so HTTP, CLI, and embedded SDK consumers all hit the same gate. **Two modes** (v0.6.0+): single-graph (legacy flat routes) and multi-graph (`/graphs/{graph_id}/...` cluster routes + read-only `GET /graphs` enumeration). Per-graph + server-level Cedar policies. Multi-graph mode boots from a cluster directory (`--cluster
`, RFC-005) or the legacy `omnigraph.yaml` `graphs:` map. Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`) is not exposed — operators run `cluster apply` (or edit the legacy file) and restart.
-- **CLI** with two-surface config (RFC-008): the team-owned cluster directory (`cluster.yaml`) plus the per-operator `~/.omnigraph/config.yaml` (servers, credentials, actor, aliases). The legacy combined `omnigraph.yaml` still loads with per-key deprecation warnings — `config migrate` proposes the split, `OMNIGRAPH_NO_LEGACY_CONFIG=1` enforces strict mode. **Never extend `omnigraph.yaml`.** Multi-format output (json/jsonl/csv/kv/table).
+- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager). Cedar policy enforcement is engine-wide — every `_as` writer calls `Omnigraph::enforce(action, scope, actor)`, so HTTP, CLI, and embedded SDK consumers all hit the same gate. **Cluster-only boot** (RFC-011): the server always boots from a cluster directory (`--cluster `, RFC-005) and serves N graphs (N ≥ 1) under multi-graph routes (`/graphs/{graph_id}/...` + read-only `GET /graphs` enumeration); there are no single-graph flat routes and no positional-URI boot. Per-graph + server-level Cedar policies. Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`) is not exposed — operators run `cluster apply` and restart.
+- **CLI** with two-surface config (RFC-007/008): the team-owned cluster directory (`cluster.yaml`) plus the per-operator `~/.omnigraph/config.yaml` (servers, clusters, credentials, actor, profiles, aliases, defaults). Graphs are addressed via `--store`/`--server`/`--cluster`/`--profile`/operator defaults (RFC-011). Multi-format output (json/jsonl/csv/kv/table).
Throughout the docs, capabilities are split into **L1 — Inherited from Lance** vs **L2 — Added by OmniGraph**.
@@ -96,7 +96,7 @@ Full diagram and concurrency model: [docs/dev/architecture.md](docs/dev/architec
| Cedar policy actions, scopes, CLI | [docs/user/operations/policy.md](docs/user/operations/policy.md) |
| HTTP server endpoints, auth, error model, body limits | [docs/user/operations/server.md](docs/user/operations/server.md) |
| CLI quick-start | [docs/user/cli/index.md](docs/user/cli/index.md) |
-| CLI command surface and config schemas (`~/.omnigraph/config.yaml`, legacy `omnigraph.yaml`) | [docs/user/cli/reference.md](docs/user/cli/reference.md) |
+| CLI command surface and config schema (`~/.omnigraph/config.yaml`) | [docs/user/cli/reference.md](docs/user/cli/reference.md) |
| Audit / actor tracking | [docs/user/operations/audit.md](docs/user/operations/audit.md) |
| Error taxonomy and result serialization | [docs/user/operations/errors.md](docs/user/operations/errors.md) |
| Install (binary / Homebrew / source / channels) | [docs/user/install.md](docs/user/install.md) |
@@ -265,8 +265,8 @@ omnigraph policy explain --actor act-alice --action change --branch main
| Three-way row-level merge | — | `OrderedTableCursor` + `StagedTableWriter`, structured `MergeConflictKind` |
| Change feeds | — | `diff_between` / `diff_commits` with manifest fast path + ID streaming |
| Cedar policy | — | Per-graph actions plus server-scoped actions (see [docs/user/operations/policy.md](docs/user/operations/policy.md) for the current list), branch / target_branch / protected scopes, validate/test/explain CLI. **Engine-wide enforcement** (MR-722): every `_as` writer (`apply_schema_as`, `mutate_as`, `load_as` — the deprecated `ingest_as` shims route through it — `branch_create_as` / `branch_create_from_as`, `branch_delete_as`, `branch_merge_as`) calls `Omnigraph::enforce(action, scope, actor)` — HTTP, CLI, embedded SDK all hit the same gate. |
-| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **multi-graph mode (v0.6.0+) with cluster routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Multi-graph boots from a cluster directory (`--cluster`) or the legacy `omnigraph.yaml`; add/remove graphs via `cluster apply` (or by editing the legacy file) and restarting.** |
-| CLI with config | — | two-surface config (team `cluster.yaml` dir + per-operator `~/.omnigraph/config.yaml`; legacy `omnigraph.yaml` deprecated per RFC-008), aliases, multi-format output (json/jsonl/csv/kv/table) |
+| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **cluster-only boot (RFC-011): always `--cluster `, serving N graphs (N ≥ 1) under multi-graph routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Add/remove graphs via `cluster apply` and restart.** |
+| CLI with config | — | two-surface config (team `cluster.yaml` dir + per-operator `~/.omnigraph/config.yaml`), scope addressing (`--store`/`--server`/`--cluster`/`--profile`/defaults, RFC-011), aliases, multi-format output (json/jsonl/csv/kv/table) |
| Audit / actor tracking | — | `_as` write APIs + actor map in commit graph |
| Local RustFS bootstrap | — | `scripts/local-rustfs-bootstrap.sh` one-shot S3-backed dev environment |
diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs
index ae33d14..81b330b 100644
--- a/crates/omnigraph-cli/src/cli.rs
+++ b/crates/omnigraph-cli/src/cli.rs
@@ -352,12 +352,6 @@ pub(crate) enum Command {
#[arg(long)]
json: bool,
},
- /// Legacy-config tooling (RFC-008): split omnigraph.yaml into its
- /// two destinations.
- Config {
- #[command(subcommand)]
- command: ConfigCommand,
- },
/// Print the CLI version
Version,
}
@@ -661,20 +655,3 @@ impl CliLoadMode {
}
}
-#[derive(Debug, Subcommand)]
-pub(crate) enum ConfigCommand {
- /// Propose (and with --write, apply) the RFC-008 split of a legacy
- /// omnigraph.yaml: team half -> a ready-to-review cluster.yaml,
- /// personal half -> ~/.omnigraph/config.yaml (key-level merge,
- /// existing entries always win). Touches nothing without --write.
- Migrate {
- /// Path to the legacy omnigraph.yaml (default: ./omnigraph.yaml)
- #[arg(long)]
- config: Option,
- /// Apply the split instead of only printing it
- #[arg(long)]
- write: bool,
- #[arg(long)]
- json: bool,
- },
-}
diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs
index 5d06b2a..ac4f5c2 100644
--- a/crates/omnigraph-cli/src/helpers.rs
+++ b/crates/omnigraph-cli/src/helpers.rs
@@ -119,6 +119,16 @@ pub(crate) fn bearer_token_from_env(var_name: &str) -> Option {
normalize_bearer_token(std::env::var(var_name).ok())
}
+/// The Cedar resource id for a graph selection: the explicit graph name when one
+/// is given, else the normalized URI (the anonymous fallback). Used by the
+/// `policy` tooling to address a graph's bundle.
+pub(crate) fn graph_resource_id_for_selection(
+ selected_graph: Option<&str>,
+ normalized_uri: &str,
+) -> String {
+ selected_graph.unwrap_or(normalized_uri).to_string()
+}
+
#[derive(Debug, Clone)]
pub(crate) struct ResolvedCliGraph {
pub(crate) uri: String,
@@ -991,6 +1001,18 @@ pub(crate) fn rewrite_deprecated_argv(args: Vec) -> Vec {
mod tests {
use super::*;
+ #[test]
+ fn graph_resource_id_for_selection_uses_name_or_anonymous_uri() {
+ assert_eq!(
+ graph_resource_id_for_selection(Some("local"), "/tmp/graph.omni"),
+ "local"
+ );
+ assert_eq!(
+ graph_resource_id_for_selection(None, "/tmp/graph.omni"),
+ "/tmp/graph.omni"
+ );
+ }
+
// RFC-011 Decision 9: locality classifier for the destructive-confirm gate.
#[test]
fn uri_is_local_truth_table() {
diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs
index 1628816..d9c5720 100644
--- a/crates/omnigraph-cli/src/main.rs
+++ b/crates/omnigraph-cli/src/main.rs
@@ -24,8 +24,7 @@ use omnigraph_api_types::{
};
use omnigraph_server::queries::{QueryRegistry, check};
use omnigraph_server::{
- PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest,
- PolicyTestConfig, ReadOutputFormat, graph_resource_id_for_selection, load_config,
+ PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, PolicyTestConfig,
};
use reqwest::Method;
use reqwest::header::AUTHORIZATION;
@@ -34,12 +33,11 @@ use serde::de::DeserializeOwned;
use serde_json::Value;
mod embed;
-mod migrate;
mod operator;
mod read_format;
use embed::{EmbedArgs, EmbedOutput, execute_embed};
-use read_format::{ReadRenderOptions, render_read};
+use read_format::{ReadOutputFormat, ReadRenderOptions, render_read};
mod cli;
mod client;
@@ -73,42 +71,6 @@ async fn main() -> Result<()> {
// before any per-command dispatch.
planes::guard_addressing(&cli)?;
match cli.command {
- Command::Config { command } => match command {
- ConfigCommand::Migrate { config, write, json } => {
- let path = migrate::legacy_config_path(config.as_ref());
- if !path.exists() {
- bail!(
- "no legacy config at '{}' — nothing to migrate",
- path.display()
- );
- }
- let legacy = load_config(Some(&path))?;
- let report = migrate::build_report(&legacy, &path);
- if write {
- let legacy_dir = path
- .parent()
- .filter(|parent| !parent.as_os_str().is_empty())
- .unwrap_or(std::path::Path::new("."))
- .to_path_buf();
- let written = migrate::apply_report(&report, &legacy_dir)?;
- if json {
- print_json(&serde_json::json!({
- "report": report,
- "written": written,
- }))?;
- } else {
- print!("{}", migrate::render_report(&report));
- for line in written {
- println!("wrote: {line}");
- }
- }
- } else if json {
- print_json(&report)?;
- } else {
- print!("{}", migrate::render_report(&report));
- }
- }
- },
Command::Login { name, token, json } => {
let token = match token {
Some(token) => token,
diff --git a/crates/omnigraph-cli/src/migrate.rs b/crates/omnigraph-cli/src/migrate.rs
deleted file mode 100644
index 7410381..0000000
--- a/crates/omnigraph-cli/src/migrate.rs
+++ /dev/null
@@ -1,408 +0,0 @@
-//! `omnigraph config migrate` (RFC-008 stage 2): split a legacy
-//! `omnigraph.yaml` into its two destinations — the team half as a
-//! ready-to-review `cluster.yaml` proposal, the personal half merged into
-//! `~/.omnigraph/config.yaml` — and name what's obsolete. The command is
-//! the completeness test of RFC-008's migration map: any key it cannot
-//! place is a bug in the RFC.
-//!
-//! Touches nothing without `--write`. Referenced `.gq`/policy files are
-//! never moved; manual steps are printed instead.
-
-use std::collections::BTreeMap;
-use std::path::{Path, PathBuf};
-
-use color_eyre::Result;
-use color_eyre::eyre::eyre;
-use omnigraph_server::OmnigraphConfig;
-use serde::Serialize;
-
-use crate::operator;
-
-#[derive(Debug, Serialize)]
-pub(crate) struct MigrateReport {
- pub(crate) source: String,
- /// The ready-to-review cluster.yaml text (None when the legacy file
- /// declares nothing team-shaped).
- pub(crate) cluster_yaml: Option,
- /// Operator keys to merge: dotted key -> YAML value text.
- pub(crate) operator_merge: BTreeMap,
- /// Keys with no destination, and why.
- pub(crate) dropped: Vec,
- /// Steps the command will not do for you.
- pub(crate) manual_steps: Vec,
-}
-
-#[derive(Debug, Serialize)]
-pub(crate) struct DroppedKey {
- pub(crate) key: String,
- pub(crate) reason: String,
-}
-
-/// Classify a parsed legacy config into the report. Pure — no I/O.
-pub(crate) fn build_report(config: &OmnigraphConfig, source: &Path) -> MigrateReport {
- let mut dropped = Vec::new();
- let mut manual_steps = Vec::new();
- let mut operator_merge: BTreeMap = BTreeMap::new();
-
- // ---- personal half ----
- if let Some(actor) = &config.cli.actor {
- operator_merge.insert("operator.actor".into(), actor.clone());
- }
- if let Some(format) = config.cli.output_format {
- operator_merge.insert(
- "defaults.output".into(),
- serde_yaml::to_string(&format).unwrap_or_default().trim().to_string(),
- );
- }
- if let Some(width) = config.cli.table_max_column_width {
- operator_merge.insert("defaults.table_max_column_width".into(), width.to_string());
- }
- if let Some(layout) = config.cli.table_cell_layout {
- operator_merge.insert(
- "defaults.table_cell_layout".into(),
- serde_yaml::to_string(&layout).unwrap_or_default().trim().to_string(),
- );
- }
- if config.cli.graph.is_some() {
- dropped.push(DroppedKey {
- key: "cli.graph".into(),
- reason: "address graphs explicitly via --store/--server, or set defaults.default_graph in the operator config".into(),
- });
- }
- if config.cli.branch.is_some() {
- dropped.push(DroppedKey {
- key: "cli.branch".into(),
- reason: "pass --branch explicitly".into(),
- });
- }
-
- // Remote graphs with a token env become operator servers (the keyed
- // chain replaces invented env-var names).
- for (name, target) in &config.graphs {
- if target.uri.starts_with("http://") || target.uri.starts_with("https://") {
- operator_merge.insert(format!("servers.{name}.url"), target.uri.clone());
- if target.bearer_token_env.is_some() {
- manual_steps.push(format!(
- "store the '{name}' token in the keyed chain: echo $TOKEN | omnigraph login {name} (replaces bearer_token_env)"
- ));
- }
- }
- }
- if config.auth.env_file.is_some() {
- manual_steps.push(
- "auth.env_file keeps working during the window; prefer `omnigraph login ` per server going forward".into(),
- );
- }
-
- // Legacy aliases split: content -> catalog stored query, binding ->
- // operator alias referencing the name.
- for (name, alias) in &config.aliases {
- let query_name = alias.name.clone().unwrap_or_else(|| name.clone());
- operator_merge.insert(
- format!("aliases.{name}"),
- format!(
- "{{ server: TODO-server-name, graph: {}, query: {query_name}, args: [{}] }}",
- alias.graph.as_deref().unwrap_or("TODO-graph-id"),
- alias.args.join(", ")
- ),
- );
- manual_steps.push(format!(
- "alias '{name}': move its query content ('{}') into the cluster checkout's queries/ so '{query_name}' becomes a catalog stored query",
- alias.query
- ));
- }
-
- // ---- team half ----
- let has_team_content = !config.graphs.is_empty()
- || !config.queries.is_empty()
- || config.policy.file.is_some()
- || config.server.policy.file.is_some();
- let cluster_yaml = has_team_content.then(|| {
- let mut out = String::from("version: 1\n");
- if let Some(name) = &config.project.name {
- out.push_str(&format!("metadata:\n name: {name}\n"));
- }
- out.push_str("# storage: s3://bucket/prefix # or omit: this folder is the root\n");
- if !config.graphs.is_empty() || !config.queries.is_empty() {
- out.push_str("graphs:\n");
- }
- // Single-graph top-level queries belong to a graph the legacy file
- // never named; propose one.
- if !config.queries.is_empty() && config.graphs.is_empty() {
- out.push_str(" default: # TODO: pick the graph id\n schema: # TODO: path to this graph's .pg schema\n queries: queries/\n");
- }
- for (name, target) in &config.graphs {
- out.push_str(&format!(" {name}:\n"));
- out.push_str(" schema: # TODO: path to this graph's .pg schema\n");
- if !target.queries.is_empty() {
- out.push_str(" queries: queries/ # move the .gq files here\n");
- }
- out.push_str(&format!(
- " # legacy root: {} — the cluster manages graph roots under its storage; run `omnigraph cluster import` after reviewing\n",
- target.uri
- ));
- }
- let mut policies: Vec<(String, String, String)> = Vec::new();
- if let Some(file) = &config.policy.file {
- policies.push(("default".into(), file.clone(), "graph. # TODO: bind".into()));
- }
- if let Some(file) = &config.server.policy.file {
- policies.push(("server".into(), file.clone(), "cluster".into()));
- }
- for (name, target) in &config.graphs {
- if let Some(file) = &target.policy.file {
- policies.push((name.clone(), file.clone(), format!("graph.{name}")));
- }
- }
- if !policies.is_empty() {
- out.push_str("policies:\n");
- for (name, file, binding) in policies {
- out.push_str(&format!(
- " {name}:\n file: {file}\n applies_to: [{binding}]\n"
- ));
- }
- }
- out
- });
-
- if !config.query.roots.is_empty() {
- dropped.push(DroppedKey {
- key: "query.roots".into(),
- reason: "obsolete — cluster query discovery (queries: ) replaced it".into(),
- });
- }
- if config.server.bind.is_some() || config.server.graph.is_some() {
- dropped.push(DroppedKey {
- key: "server.bind / server.graph".into(),
- reason: "deployment runtime — pass --bind / target flags or env".into(),
- });
- }
- if config.project.name.is_some() && cluster_yaml.is_none() {
- dropped.push(DroppedKey {
- key: "project.name".into(),
- reason: "the cluster's metadata.name is the deployment label".into(),
- });
- }
-
- MigrateReport {
- source: source.display().to_string(),
- cluster_yaml,
- operator_merge,
- dropped,
- manual_steps,
- }
-}
-
-pub(crate) fn render_report(report: &MigrateReport) -> String {
- let mut out = format!("migration plan for {}\n", report.source);
- if let Some(cluster) = &report.cluster_yaml {
- out.push_str("\n== team half -> cluster.yaml (ready to review) ==\n");
- out.push_str(cluster);
- }
- if !report.operator_merge.is_empty() {
- out.push_str("\n== personal half -> ~/.omnigraph/config.yaml ==\n");
- for (key, value) in &report.operator_merge {
- out.push_str(&format!(" {key}: {value}\n"));
- }
- }
- if !report.dropped.is_empty() {
- out.push_str("\n== no destination ==\n");
- for dropped in &report.dropped {
- out.push_str(&format!(" {} — {}\n", dropped.key, dropped.reason));
- }
- }
- if !report.manual_steps.is_empty() {
- out.push_str("\n== manual steps ==\n");
- for step in &report.manual_steps {
- out.push_str(&format!(" - {step}\n"));
- }
- }
- out.push_str("\n(nothing written; pass --write to apply the operator merge and emit cluster.yaml)\n");
- out
-}
-
-/// `--write`: merge the personal half into the operator config (key-level,
-/// existing entries always win; the prior file is backed up) and write the
-/// team half to cluster.yaml in the legacy config's directory (or
-/// cluster.yaml.proposed when one already exists).
-pub(crate) fn apply_report(report: &MigrateReport, legacy_dir: &Path) -> Result> {
- let mut written = Vec::new();
-
- if !report.operator_merge.is_empty() {
- let dir = operator::operator_dir()
- .ok_or_else(|| eyre!("no home directory resolvable for the operator config"))?;
- std::fs::create_dir_all(&dir)?;
- let path = dir.join(operator::OPERATOR_CONFIG_FILE);
- let existing_text = std::fs::read_to_string(&path).unwrap_or_default();
- let mut mapping: serde_yaml::Mapping = if existing_text.trim().is_empty() {
- serde_yaml::Mapping::new()
- } else {
- serde_yaml::from_str(&existing_text)
- .map_err(|err| eyre!("operator config '{}' does not parse: {err}", path.display()))?
- };
- let mut merged_any = false;
- for (dotted, value_text) in &report.operator_merge {
- if merge_dotted_if_absent(&mut mapping, dotted, value_text)? {
- merged_any = true;
- }
- }
- if merged_any {
- if !existing_text.is_empty() {
- let backup = path.with_extension("yaml.bak");
- std::fs::write(&backup, &existing_text)?;
- written.push(format!("backed up prior operator config to {}", backup.display()));
- }
- let rendered = serde_yaml::to_string(&mapping)?;
- let tmp = path.with_extension(format!("yaml.tmp.{}", std::process::id()));
- std::fs::write(&tmp, &rendered)?;
- std::fs::rename(&tmp, &path)?;
- written.push(format!("merged personal keys into {}", path.display()));
- } else {
- written.push("operator config already carries every personal key (nothing merged)".into());
- }
- }
-
- if let Some(cluster) = &report.cluster_yaml {
- let target = legacy_dir.join("cluster.yaml");
- let target = if target.exists() {
- legacy_dir.join("cluster.yaml.proposed")
- } else {
- target
- };
- std::fs::write(&target, cluster)?;
- written.push(format!("wrote team-half proposal to {}", target.display()));
- }
-
- Ok(written)
-}
-
-/// Set `a.b.c` in the mapping only when absent; returns whether it wrote.
-fn merge_dotted_if_absent(
- mapping: &mut serde_yaml::Mapping,
- dotted: &str,
- value_text: &str,
-) -> Result {
- let value: serde_yaml::Value =
- serde_yaml::from_str(value_text).unwrap_or(serde_yaml::Value::String(value_text.into()));
- let parts: Vec<&str> = dotted.split('.').collect();
- let mut current = mapping;
- for part in &parts[..parts.len() - 1] {
- let key = serde_yaml::Value::String((*part).into());
- let entry = current
- .entry(key)
- .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
- current = entry
- .as_mapping_mut()
- .ok_or_else(|| eyre!("operator config key '{dotted}' collides with a non-mapping"))?;
- }
- let leaf = serde_yaml::Value::String(parts[parts.len() - 1].into());
- if current.contains_key(&leaf) {
- return Ok(false);
- }
- current.insert(leaf, value);
- Ok(true)
-}
-
-pub(crate) fn legacy_config_path(explicit: Option<&PathBuf>) -> PathBuf {
- explicit.cloned().unwrap_or_else(|| PathBuf::from("omnigraph.yaml"))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use omnigraph_server::config::load_config;
-
- fn full_legacy_fixture(dir: &Path) -> PathBuf {
- let path = dir.join("omnigraph.yaml");
- std::fs::write(
- &path,
- r#"
-project: { name: brain }
-graphs:
- prod:
- uri: https://graph.example.com
- bearer_token_env: PROD_TOKEN
- policy: { file: ./prod.policy.yaml }
- queries:
- find: { file: ./find.gq }
- local:
- uri: /tmp/local.omni
-server: { bind: "0.0.0.0:9999", policy: { file: ./server.policy.yaml } }
-auth: { env_file: .env.omni }
-cli:
- graph: prod
- branch: main
- actor: act-me
- output_format: json
- table_max_column_width: 40
-query: { roots: ["."] }
-aliases:
- triage: { command: query, query: ./triage.gq, name: weekly_triage, args: [since], graph: prod }
-policy: { file: ./top.policy.yaml }
-queries:
- top_q: { file: ./top.gq }
-"#,
- )
- .unwrap();
- path
- }
-
- /// The RFC-008 completeness contract: every top-level key of the
- /// legacy schema must appear in the report somewhere (team half,
- /// operator merge, dropped, or manual steps).
- #[test]
- fn every_legacy_key_is_classified() {
- let dir = tempfile::tempdir().unwrap();
- let path = full_legacy_fixture(dir.path());
- let config = load_config(Some(&path)).unwrap();
- let report = build_report(&config, &path);
- let rendered = render_report(&report);
-
- let serialized =
- serde_yaml::to_value(OmnigraphConfig::default()).expect("default serializes");
- for key in serialized.as_mapping().unwrap().keys() {
- let key = key.as_str().unwrap();
- assert!(
- rendered.contains(key)
- || report.operator_merge.keys().any(|k| k.contains(key))
- || matches!(key, "graphs" | "queries" | "policy" | "project")
- && report.cluster_yaml.is_some(),
- "legacy key '{key}' is unclassified — fix the RFC-008 map: {rendered}"
- );
- }
-
- // spot checks on each section
- assert_eq!(report.operator_merge["operator.actor"], "act-me");
- assert_eq!(report.operator_merge["defaults.output"], "json");
- assert_eq!(
- report.operator_merge["servers.prod.url"],
- "https://graph.example.com"
- );
- assert!(report.operator_merge["aliases.triage"].contains("query: weekly_triage"));
- let cluster = report.cluster_yaml.as_deref().unwrap();
- assert!(cluster.contains("version: 1"));
- assert!(cluster.contains("name: brain"));
- assert!(cluster.contains(" prod:"));
- assert!(cluster.contains("applies_to: [cluster]"));
- assert!(cluster.contains("applies_to: [graph.prod]"));
- assert!(report.dropped.iter().any(|d| d.key == "query.roots"));
- assert!(report.dropped.iter().any(|d| d.key.contains("server.bind")));
- assert!(
- report
- .manual_steps
- .iter()
- .any(|s| s.contains("omnigraph login prod"))
- );
- }
-
- #[test]
- fn merge_dotted_never_clobbers_existing() {
- let mut mapping: serde_yaml::Mapping =
- serde_yaml::from_str("operator:\n actor: keep-me\n").unwrap();
- assert!(!merge_dotted_if_absent(&mut mapping, "operator.actor", "new").unwrap());
- assert!(merge_dotted_if_absent(&mut mapping, "defaults.output", "json").unwrap());
- let text = serde_yaml::to_string(&mapping).unwrap();
- assert!(text.contains("keep-me") && !text.contains("new"));
- assert!(text.contains("output: json"));
- }
-}
diff --git a/crates/omnigraph-cli/src/operator.rs b/crates/omnigraph-cli/src/operator.rs
index 929779e..96b4dc1 100644
--- a/crates/omnigraph-cli/src/operator.rs
+++ b/crates/omnigraph-cli/src/operator.rs
@@ -21,7 +21,7 @@ use color_eyre::Result;
use color_eyre::eyre::{bail, eyre};
use serde::Deserialize;
-use omnigraph_server::config::ReadOutputFormat;
+use crate::read_format::{ReadOutputFormat, TableCellLayout};
pub(crate) const OPERATOR_HOME_ENV: &str = "OMNIGRAPH_HOME";
pub(crate) const OPERATOR_DIR: &str = ".omnigraph";
@@ -102,10 +102,9 @@ pub(crate) struct OperatorIdentity {
pub(crate) struct OperatorDefaults {
/// Default read output format, below every more-specific source.
pub(crate) output: Option,
- /// Table rendering preferences (below the legacy cli.table_* keys
- /// during the RFC-008 window).
+ /// Table rendering preferences for `--format table`.
pub(crate) table_max_column_width: Option,
- pub(crate) table_cell_layout: Option,
+ pub(crate) table_cell_layout: Option,
/// Default server scope (RFC-011): the everyday addressing when no
/// `--profile` / primitive / legacy address is given. Names an entry
/// under `servers:`. Mutually exclusive with `store` — a scope binds one
diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs
index 45f96e2..70b8dc5 100644
--- a/crates/omnigraph-cli/src/planes.rs
+++ b/crates/omnigraph-cli/src/planes.rs
@@ -132,7 +132,6 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane {
Command::Embed(_)
| Command::Login { .. }
| Command::Logout { .. }
- | Command::Config { .. }
| Command::Version => Plane::Session,
}
}
@@ -144,7 +143,6 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
Command::Version => "version",
Command::Login { .. } => "login",
Command::Logout { .. } => "logout",
- Command::Config { .. } => "config",
Command::Embed(_) => "embed",
Command::Init { .. } => "init",
Command::Load { .. } => "load",
diff --git a/crates/omnigraph-cli/src/read_format.rs b/crates/omnigraph-cli/src/read_format.rs
index b205b19..3ffa9e6 100644
--- a/crates/omnigraph-cli/src/read_format.rs
+++ b/crates/omnigraph-cli/src/read_format.rs
@@ -1,9 +1,31 @@
+use clap::ValueEnum;
use color_eyre::eyre::Result;
-use omnigraph_server::ReadOutputFormat;
use omnigraph_server::api::ReadOutput;
-use omnigraph_server::config::TableCellLayout;
+use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
+/// Output rendering format for read-shaped commands (`read`/`query`/`alias`).
+/// A CLI presentation concern — lives here, not in the server.
+#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
+#[serde(rename_all = "snake_case")]
+pub enum ReadOutputFormat {
+ #[default]
+ Table,
+ Kv,
+ Csv,
+ Jsonl,
+ Json,
+}
+
+/// How an over-wide table cell is laid out when rendering `--format table`.
+#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
+#[serde(rename_all = "snake_case")]
+pub enum TableCellLayout {
+ #[default]
+ Truncate,
+ Wrap,
+}
+
pub struct ReadRenderOptions {
pub max_column_width: usize,
pub cell_layout: TableCellLayout,
diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs
index 8a9ee47..15c6c46 100644
--- a/crates/omnigraph-cli/tests/cli_schema_config.rs
+++ b/crates/omnigraph-cli/tests/cli_schema_config.rs
@@ -562,74 +562,3 @@ fn graphs_list_against_local_uri_errors_with_remote_only_message() {
);
}
-/// RFC-008 stage 2: `config migrate` proposes the split read-only, applies
-/// it with --write (operator merge never clobbers; cluster.yaml emitted),
-/// and a second --write is idempotent.
-#[test]
-fn config_migrate_splits_legacy_config() {
- let temp = tempdir().unwrap();
- fs::write(
- temp.path().join("omnigraph.yaml"),
- "graphs:\n prod:\n uri: https://graph.example.com\n bearer_token_env: PROD_TOKEN\ncli:\n actor: act-me\n output_format: json\npolicy:\n file: ./top.policy.yaml\n",
- )
- .unwrap();
- let operator_home = tempfile::tempdir().unwrap();
- fs::write(
- operator_home.path().join("config.yaml"),
- "operator:\n actor: act-existing\n",
- )
- .unwrap();
-
- // Read-only proposal: names both halves, writes nothing.
- let output = cli()
- .current_dir(temp.path())
- .env("OMNIGRAPH_HOME", operator_home.path())
- .env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
- .arg("config")
- .arg("migrate")
- .output()
- .unwrap();
- assert!(output.status.success(), "{output:?}");
- let stdout = String::from_utf8_lossy(&output.stdout);
- assert!(stdout.contains("team half -> cluster.yaml"), "{stdout}");
- assert!(stdout.contains("operator.actor: act-me"), "{stdout}");
- assert!(stdout.contains("omnigraph login prod"), "{stdout}");
- assert!(!temp.path().join("cluster.yaml").exists());
-
- // --write: cluster.yaml lands; the existing operator actor is KEPT.
- let output = cli()
- .current_dir(temp.path())
- .env("OMNIGRAPH_HOME", operator_home.path())
- .env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
- .arg("config")
- .arg("migrate")
- .arg("--write")
- .output()
- .unwrap();
- assert!(output.status.success(), "{output:?}");
- let cluster = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap();
- assert!(cluster.contains("version: 1") && cluster.contains(" prod:"), "{cluster}");
- let operator_text =
- fs::read_to_string(operator_home.path().join("config.yaml")).unwrap();
- assert!(operator_text.contains("act-existing"), "{operator_text}");
- assert!(!operator_text.contains("act-me"), "existing keys win: {operator_text}");
- assert!(operator_text.contains("output: json"), "{operator_text}");
- assert!(
- operator_text.contains("url: https://graph.example.com"),
- "{operator_text}"
- );
-
- // Second --write: cluster.yaml exists -> proposal file, no clobber.
- let output = cli()
- .current_dir(temp.path())
- .env("OMNIGRAPH_HOME", operator_home.path())
- .env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
- .arg("config")
- .arg("migrate")
- .arg("--write")
- .output()
- .unwrap();
- assert!(output.status.success(), "{output:?}");
- assert!(temp.path().join("cluster.yaml.proposed").exists());
-}
-
diff --git a/crates/omnigraph-server/src/config.rs b/crates/omnigraph-server/src/config.rs
deleted file mode 100644
index 15b957d..0000000
--- a/crates/omnigraph-server/src/config.rs
+++ /dev/null
@@ -1,1103 +0,0 @@
-use std::collections::BTreeMap;
-use std::env;
-use std::fs;
-use std::path::{Path, PathBuf};
-
-use clap::ValueEnum;
-use color_eyre::eyre::{Result, bail};
-use serde::{Deserialize, Serialize};
-
-pub const DEFAULT_CONFIG_FILE: &str = "omnigraph.yaml";
-
-pub fn graph_resource_id_for_selection(
- selected_graph: Option<&str>,
- normalized_uri: &str,
-) -> String {
- selected_graph.unwrap_or(normalized_uri).to_string()
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct ProjectConfig {
- pub name: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct TargetConfig {
- pub uri: String,
- pub bearer_token_env: Option,
- /// Per-graph Cedar policy file (MR-668). In single-graph mode this
- /// field is unused — the top-level `policy.file` applies. In
- /// multi-graph mode, each `graphs..policy.file` governs that
- /// graph's HTTP-layer Cedar enforcement.
- #[serde(default)]
- pub policy: PolicySettings,
- /// Per-graph stored-query registry: an inline `name -> entry`
- /// map. Mirrors the per-graph `policy` shape — each
- /// `graphs..queries` declares that graph's stored queries. Absent
- /// (or empty) = no stored queries for the graph. v1 is inline-only;
- /// an external `queries.yaml` manifest indirection is a deferred
- /// convenience.
- #[serde(default)]
- pub queries: BTreeMap,
-}
-
-#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
-#[serde(rename_all = "snake_case")]
-pub enum ReadOutputFormat {
- #[default]
- Table,
- Kv,
- Csv,
- Jsonl,
- Json,
-}
-
-#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
-#[serde(rename_all = "snake_case")]
-pub enum TableCellLayout {
- #[default]
- Truncate,
- Wrap,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct CliDefaults {
- #[serde(rename = "graph")]
- pub graph: Option,
- pub branch: Option,
- pub output_format: Option,
- pub table_max_column_width: Option,
- pub table_cell_layout: Option,
- /// Default actor identity for CLI direct-engine writes (MR-722).
- /// Used when `policy.file` is configured and the operator hasn't
- /// passed `--as ` on the command line. With policy configured
- /// and neither this nor `--as` set, the engine-layer footgun guard
- /// fires (no silent bypass).
- pub actor: Option,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct ServerDefaults {
- #[serde(rename = "graph")]
- pub graph: Option,
- pub bind: Option,
- /// Server-level Cedar policy (MR-668). Governs management endpoints
- /// — currently `GET /graphs`; future runtime add/remove endpoints
- /// will plug in here too. In single-graph mode this is unused — the
- /// top-level `policy.file` covers the single graph.
- #[serde(default)]
- pub policy: PolicySettings,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct AuthDefaults {
- pub env_file: Option,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct QueryDefaults {
- #[serde(default)]
- pub roots: Vec,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct PolicySettings {
- pub file: Option,
-}
-
-/// One stored-query registry entry. The map **key** is the query's
-/// identity — it must equal the `query ` symbol declared inside
-/// the referenced `.gq` file (asserted when the registry loads).
-/// Renaming the key (or the symbol) is a breaking change to callers, by
-/// design.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct QueryEntry {
- /// Path to the `.gq` file (relative to the config's `base_dir`). The
- /// file may declare several queries; the registry selects the one
- /// whose symbol matches the map key.
- pub file: String,
- #[serde(default)]
- pub mcp: McpSettings,
-}
-
-/// MCP exposure for a stored query. A *deployment* concern (the same
-/// `.gq` may be exposed in one graph and hidden in another), so it lives
-/// in YAML rather than in the `.gq` source. **Default `expose: true`** —
-/// declaring a query in the manifest *is* the opt-in, so it appears in the
-/// MCP tool catalog (`GET /queries`) by default; set `expose: false` to
-/// keep a query HTTP/service-callable but hidden from the agent tool list.
-/// `expose` governs catalog membership only — it is **not** an
-/// authorization gate (invocation is gated by `invoke_query`), so a hidden
-/// query is still invocable by name with the right permission.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct McpSettings {
- #[serde(default = "mcp_expose_default")]
- pub expose: bool,
- pub tool_name: Option,
-}
-
-fn mcp_expose_default() -> bool {
- true
-}
-
-impl Default for McpSettings {
- fn default() -> Self {
- Self {
- expose: mcp_expose_default(),
- tool_name: None,
- }
- }
-}
-
-#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum AliasCommand {
- /// Read alias (canonical: `query`). The legacy spelling `read` is
- /// kept as the variant name for back-compat with serialized configs
- /// and external SDK callers; `query` is accepted on the wire via the
- /// serde alias.
- #[serde(alias = "query")]
- Read,
- /// Mutation alias (canonical: `mutate`). The legacy spelling `change`
- /// is kept as the variant name for back-compat; `mutate` is accepted
- /// on the wire via the serde alias.
- #[serde(alias = "mutate")]
- Change,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct AliasConfig {
- pub command: AliasCommand,
- pub query: String,
- pub name: Option,
- #[serde(default)]
- pub args: Vec,
- #[serde(rename = "graph")]
- pub graph: Option,
- pub branch: Option,
- pub format: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct OmnigraphConfig {
- #[serde(default)]
- pub project: ProjectConfig,
- #[serde(default, rename = "graphs")]
- pub graphs: BTreeMap,
- #[serde(default)]
- pub server: ServerDefaults,
- #[serde(default)]
- pub auth: AuthDefaults,
- #[serde(default)]
- pub cli: CliDefaults,
- #[serde(default)]
- pub query: QueryDefaults,
- #[serde(default)]
- pub aliases: BTreeMap,
- #[serde(default)]
- pub policy: PolicySettings,
- /// Top-level stored-query registry, used in single-graph
- /// mode — mirrors how the top-level `policy` applies to the single
- /// graph. In multi-graph mode this is unused; each graph's
- /// `graphs..queries` applies instead.
- #[serde(default)]
- pub queries: BTreeMap,
- #[serde(skip)]
- base_dir: PathBuf,
-}
-
-impl Default for OmnigraphConfig {
- fn default() -> Self {
- Self {
- project: ProjectConfig::default(),
- graphs: BTreeMap::new(),
- server: ServerDefaults::default(),
- auth: AuthDefaults::default(),
- cli: CliDefaults::default(),
- query: QueryDefaults::default(),
- aliases: BTreeMap::new(),
- policy: PolicySettings::default(),
- queries: BTreeMap::new(),
- base_dir: PathBuf::new(),
- }
- }
-}
-
-impl OmnigraphConfig {
- pub fn base_dir(&self) -> &Path {
- &self.base_dir
- }
-
- pub fn cli_branch(&self) -> &str {
- self.cli.branch.as_deref().unwrap_or("main")
- }
-
- pub fn cli_output_format(&self) -> ReadOutputFormat {
- self.cli.output_format.unwrap_or_default()
- }
-
- pub fn table_max_column_width(&self) -> usize {
- self.cli.table_max_column_width.unwrap_or(80)
- }
-
- pub fn table_cell_layout(&self) -> TableCellLayout {
- self.cli.table_cell_layout.unwrap_or_default()
- }
-
- pub fn cli_graph_name(&self) -> Option<&str> {
- self.cli.graph.as_deref()
- }
-
- pub fn server_graph_name(&self) -> Option<&str> {
- self.server.graph.as_deref()
- }
-
- pub fn server_bind(&self) -> &str {
- self.server.bind.as_deref().unwrap_or("127.0.0.1:8080")
- }
-
- pub fn resolve_target_name<'a>(
- &self,
- explicit_uri: Option<&str>,
- explicit_target: Option<&'a str>,
- default_target: Option<&'a str>,
- ) -> Option<&'a str> {
- explicit_target.or_else(|| {
- if explicit_uri.is_some() {
- None
- } else {
- default_target
- }
- })
- }
-
- pub fn graph_bearer_token_env(
- &self,
- explicit_uri: Option<&str>,
- explicit_target: Option<&str>,
- default_target: Option<&str>,
- ) -> Option<&str> {
- let target_name =
- self.resolve_target_name(explicit_uri, explicit_target, default_target)?;
- self.graphs
- .get(target_name)
- .and_then(|target| target.bearer_token_env.as_deref())
- }
-
- pub fn resolve_auth_env_file(&self) -> Option {
- self.auth
- .env_file
- .as_deref()
- .map(|path| self.resolve_config_path(path))
- }
-
- pub fn resolve_policy_file(&self) -> Option {
- self.policy
- .file
- .as_deref()
- .map(|path| self.resolve_config_path(path))
- }
-
- /// Resolve the per-graph policy file path for the named target,
- /// relative to the config file's `base_dir`. Returns `None` if the
- /// target is unknown or no per-graph `policy.file` is set.
- pub fn resolve_target_policy_file(&self, target_name: &str) -> Option {
- let target = self.graphs.get(target_name)?;
- target
- .policy
- .file
- .as_deref()
- .map(|path| self.resolve_config_path(path))
- }
-
- /// The top-level stored-query registry entries (single-graph mode).
- pub fn query_entries(&self) -> &BTreeMap {
- &self.queries
- }
-
- /// The per-graph stored-query registry entries for a named target
- /// (multi-graph mode). Returns `None` if the target is unknown.
- pub fn target_query_entries(
- &self,
- target_name: &str,
- ) -> Option<&BTreeMap> {
- self.graphs.get(target_name).map(|target| &target.queries)
- }
-
- /// The stored-query registry entries that apply for a graph
- /// selection — the single definition of "which `queries:` block
- /// governs graph X", shared by server boot and the CLI so the two
- /// can't drift. A named graph present in `graphs:` uses its
- /// per-graph block; everything else (no selection, or a name that is
- /// not a known graph, e.g. a bare URI) falls back to the top-level
- /// block (single-graph mode).
- pub fn query_entries_for(&self, graph: Option<&str>) -> &BTreeMap {
- match graph {
- Some(name) if self.graphs.contains_key(name) => &self.graphs[name].queries,
- _ => &self.queries,
- }
- }
-
- /// The single CLI gate that turns a raw graph selection into a *validated*
- /// one — the fallible counterpart to the infallible
- /// [`OmnigraphConfig::query_entries_for`]. Both `queries` subcommands route
- /// their selection through here so neither can skip a check the other (or
- /// server boot) applies:
- /// * a known name passes through, but only after the same coherence check
- /// server boot enforces
- /// ([`OmnigraphConfig::ensure_top_level_blocks_honored`]) — a named graph
- /// with a populated top-level block is rejected;
- /// * an unknown name errors with the **same** message
- /// [`OmnigraphConfig::resolve_target_uri`] produces, so a command that
- /// opens no URI rejects an unknown `--target` exactly like the
- /// URI-resolving commands do;
- /// * an anonymous selection (`None`, e.g. a bare URI) stays anonymous,
- /// resolving to the top-level registry downstream (top-level honored).
- pub fn resolve_graph_selection<'a>(&self, graph: Option<&'a str>) -> Result