Merge pull request #183 from ModernRelay/feat/cluster-query-discovery

feat(cluster): Terraform-shaped query declaration; drop ./ path noise
This commit is contained in:
Andrew Altshuler 2026-06-11 01:44:52 +03:00 committed by GitHub
commit 4bd763f4b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 370 additions and 104 deletions

View file

@ -415,7 +415,141 @@ struct StateConfig {
struct GraphConfig {
schema: PathBuf,
#[serde(default)]
queries: BTreeMap<String, QueryConfig>,
queries: QueriesDecl,
}
/// How a graph declares its stored queries. Terraform-style: the `.gq`
/// files ARE the declaration — point at them (or a directory) and every
/// `query <name>` they contain is discovered. The explicit name->file map
/// remains for fine-grained control.
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum QueriesDecl {
/// `queries: ./queries/` — a directory (top-level `*.gq`, sorted) or a
/// single `.gq` file; every declaration inside is registered.
Discover(PathBuf),
/// `queries: [./queries/, ./extra.gq]` — several directories/files.
DiscoverMany(Vec<PathBuf>),
/// `queries: { name: { file: ... } }` — explicit registry.
Explicit(BTreeMap<String, QueryConfig>),
}
impl Default for QueriesDecl {
fn default() -> Self {
QueriesDecl::Explicit(BTreeMap::new())
}
}
/// Expand a graph's query declaration into the canonical name->file map.
/// Discovery reads and parses each `.gq`; unreadable or unparseable files
/// and duplicate query names are loud validation errors — a declaration the
/// tool cannot enumerate is broken, not partially usable.
fn resolve_query_decls(
config_dir: &Path,
graph_id: &str,
decl: &QueriesDecl,
diagnostics: &mut Vec<Diagnostic>,
) -> (BTreeMap<String, QueryConfig>, BTreeMap<PathBuf, String>) {
let paths: Vec<PathBuf> = match decl {
QueriesDecl::Explicit(map) => {
return (
map.iter()
.map(|(name, config)| {
(name.clone(), QueryConfig { file: config.file.clone() })
})
.collect(),
BTreeMap::new(),
);
}
QueriesDecl::Discover(path) => vec![path.clone()],
QueriesDecl::DiscoverMany(paths) => paths.clone(),
};
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new(); // (declared-relative, resolved)
for declared in &paths {
let resolved = resolve_config_path(config_dir, declared);
if resolved.is_dir() {
let mut entries: Vec<PathBuf> = match fs::read_dir(&resolved) {
Ok(read) => read
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "gq"))
.collect(),
Err(err) => {
diagnostics.push(Diagnostic::error(
"query_dir_unreadable",
format!("graphs.{graph_id}.queries"),
format!("could not list query directory '{}': {err}", resolved.display()),
));
continue;
}
};
entries.sort();
if entries.is_empty() {
diagnostics.push(Diagnostic::warning(
"query_dir_empty",
format!("graphs.{graph_id}.queries"),
format!("query directory '{}' contains no .gq files", resolved.display()),
));
}
for path in entries {
let relative = declared.join(path.file_name().expect("dir entries have names"));
files.push((relative, path));
}
} else {
files.push((declared.clone(), resolved));
}
}
let mut registry: BTreeMap<String, QueryConfig> = BTreeMap::new();
let mut origin: BTreeMap<String, PathBuf> = BTreeMap::new();
// Content read once at discovery and handed to the caller — the per-query
// digest/typecheck pass reuses it instead of re-reading (no N+1 reads, no
// window for the file to change between enumeration and validation).
let mut contents: BTreeMap<PathBuf, String> = BTreeMap::new();
for (declared, resolved) in files {
let source = match fs::read_to_string(&resolved) {
Ok(source) => source,
Err(err) => {
diagnostics.push(Diagnostic::error(
"query_file_missing",
format!("graphs.{graph_id}.queries"),
format!("could not read query file '{}': {err}", resolved.display()),
));
continue;
}
};
let parsed = match parse_query(&source) {
Ok(parsed) => parsed,
Err(err) => {
diagnostics.push(Diagnostic::error(
"query_parse_error",
format!("graphs.{graph_id}.queries"),
format!("'{}' does not parse: {err}", resolved.display()),
));
continue;
}
};
for query_decl in &parsed.queries {
let name = query_decl.name.clone();
if let Some(previous) = origin.get(&name) {
diagnostics.push(Diagnostic::error(
"duplicate_query_name",
format!("graphs.{graph_id}.queries.{name}"),
format!(
"query '{name}' is declared in both '{}' and '{}'",
previous.display(),
declared.display()
),
));
continue;
}
origin.insert(name.clone(), declared.clone());
registry.insert(name, QueryConfig { file: declared.clone() });
}
contents.insert(declared, source);
}
(registry, contents)
}
#[derive(Debug, Serialize, Deserialize)]
@ -3600,7 +3734,9 @@ fn load_desired(config_dir: &Path) -> LoadOutcome {
}
});
for (query_name, query) in &graph.queries {
let (graph_queries, query_contents) =
resolve_query_decls(&config_dir, graph_id, &graph.queries, &mut diagnostics);
for (query_name, query) in &graph_queries {
validate_id(
"query name",
&format!("graphs.{graph_id}.queries.{query_name}"),
@ -3618,7 +3754,11 @@ fn load_desired(config_dir: &Path) -> LoadOutcome {
});
let query_path = resolve_config_path(&config_dir, &query.file);
match fs::read_to_string(&query_path) {
let source = match query_contents.get(&query.file) {
Some(cached) => Ok(cached.clone()),
None => fs::read_to_string(&query_path),
};
match source {
Ok(source) => {
let digest = sha256_hex(source.as_bytes());
graph_query_digests
@ -7560,6 +7700,115 @@ policies:
);
}
// ---- query discovery (Terraform-style declaration) ----
#[test]
fn queries_directory_discovers_every_declaration() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("people.pg"), "\nnode Person {\n name: String @key\n}\n").unwrap();
fs::create_dir(dir.path().join("queries")).unwrap();
fs::write(
dir.path().join("queries/people.gq"),
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n\nquery all_people() {\n match { $p: Person }\n return { $p.name }\n}\n",
)
.unwrap();
fs::write(
dir.path().join("queries/extra.gq"),
"\nquery count_people() {\n match { $p: Person }\n return { count($p) }\n}\n",
)
.unwrap();
fs::write(dir.path().join("queries/notes.txt"), "ignored").unwrap();
fs::write(
dir.path().join("cluster.yaml"),
"version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: ./queries/\n",
)
.unwrap();
let out = validate_config_dir(dir.path());
assert!(out.ok, "{:?}", out.diagnostics);
let names: Vec<&str> = out
.resource_digests
.keys()
.filter_map(|address| address.strip_prefix("query.knowledge."))
.collect();
assert_eq!(names, vec!["all_people", "count_people", "find_person"]);
}
#[test]
fn queries_list_and_single_file_forms_discover() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("people.pg"), "\nnode Person {\n name: String @key\n}\n").unwrap();
fs::write(
dir.path().join("a.gq"),
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
)
.unwrap();
fs::write(
dir.path().join("b.gq"),
"\nquery all_people() {\n match { $p: Person }\n return { $p.name }\n}\n",
)
.unwrap();
fs::write(
dir.path().join("cluster.yaml"),
"version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: [./a.gq, ./b.gq]\n",
)
.unwrap();
let out = validate_config_dir(dir.path());
assert!(out.ok, "{:?}", out.diagnostics);
assert!(out.resource_digests.contains_key("query.knowledge.find_person"));
assert!(out.resource_digests.contains_key("query.knowledge.all_people"));
// Single-file string form
fs::write(
dir.path().join("cluster.yaml"),
"version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: ./a.gq\n",
)
.unwrap();
let out = validate_config_dir(dir.path());
assert!(out.ok, "{:?}", out.diagnostics);
assert!(out.resource_digests.contains_key("query.knowledge.find_person"));
assert!(!out.resource_digests.contains_key("query.knowledge.all_people"));
}
#[test]
fn query_discovery_rejects_duplicates_and_parse_errors() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("people.pg"), "\nnode Person {\n name: String @key\n}\n").unwrap();
let decl = "\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n";
fs::write(dir.path().join("a.gq"), decl).unwrap();
fs::write(dir.path().join("b.gq"), decl).unwrap();
fs::write(
dir.path().join("cluster.yaml"),
"version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: [./a.gq, ./b.gq]\n",
)
.unwrap();
let out = validate_config_dir(dir.path());
assert!(!out.ok);
assert!(
out.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "duplicate_query_name"),
"{:?}",
out.diagnostics
);
fs::write(dir.path().join("broken.gq"), "query {{{ nope").unwrap();
fs::write(
dir.path().join("cluster.yaml"),
"version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: ./broken.gq\n",
)
.unwrap();
let out = validate_config_dir(dir.path());
assert!(!out.ok);
assert!(
out.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "query_parse_error"),
"{:?}",
out.diagnostics
);
}
#[test]
fn status_warns_on_pending_recovery_sidecar() {
let dir = fixture();

View file

@ -54,7 +54,7 @@ cli:
query:
roots: [<dir>, …] # search path for .gq files
auth:
env_file: ./.env.omni
env_file: .env.omni
aliases:
<alias>:
# accepted values: `read` / `query` (read alias), `change` / `mutate`
@ -70,20 +70,20 @@ aliases:
queries: # top-level registry — applies only to a bare-URI (anonymous) graph; a graph served by name uses its `graphs.<id>.queries`. Mirrors top-level `policy`.
<query-name>: { file: <path-to-.gq> } # mcp.expose defaults to true
policy:
file: ./policy.yaml
file: policy.yaml
```
## Cluster config preview
```bash
omnigraph cluster validate --config ./company-brain
omnigraph cluster plan --config ./company-brain --json
omnigraph cluster apply --config ./company-brain --json
omnigraph cluster approve graph.<id> --config ./company-brain --as <actor>
omnigraph cluster status --config ./company-brain --json
omnigraph cluster refresh --config ./company-brain --json
omnigraph cluster import --config ./company-brain --json
omnigraph cluster force-unlock <LOCK_ID> --config ./company-brain --json
omnigraph cluster validate --config company-brain
omnigraph cluster plan --config company-brain --json
omnigraph cluster apply --config company-brain --json
omnigraph cluster approve graph.<id> --config company-brain --as <actor>
omnigraph cluster status --config company-brain --json
omnigraph cluster refresh --config company-brain --json
omnigraph cluster import --config company-brain --json
omnigraph cluster force-unlock <LOCK_ID> --config company-brain --json
```
`--config` is a directory containing `cluster.yaml`; it defaults to `.`.

View file

@ -3,11 +3,11 @@
## Core Graph Flow
```bash
omnigraph init --schema ./schema.pg ./graph.omni
omnigraph load --data ./data.jsonl --mode overwrite ./graph.omni
omnigraph snapshot ./graph.omni --branch main --json
omnigraph query --uri ./graph.omni --query ./queries.gq --name get_person --params '{"name":"Alice"}'
omnigraph mutate --uri ./graph.omni --query ./queries.gq --name insert_person --params '{"name":"Mina","age":28}'
omnigraph init --schema schema.pg graph.omni
omnigraph load --data data.jsonl --mode overwrite graph.omni
omnigraph snapshot graph.omni --branch main --json
omnigraph query --uri graph.omni --query queries.gq --name get_person --params '{"name":"Alice"}'
omnigraph mutate --uri graph.omni --query queries.gq --name insert_person --params '{"name":"Mina","age":28}'
```
`omnigraph query` is the canonical read command (pairs with `POST /query`);
@ -21,11 +21,11 @@ For ad-hoc reads and mutations (REPLs, AI agents, one-off scripts), pass the
GQ source inline with `-e` / `--query-string` instead of a file path:
```bash
omnigraph query --uri ./graph.omni \
omnigraph query --uri graph.omni \
-e 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }' \
--params '{"name":"Alice"}'
omnigraph mutate --uri ./graph.omni \
omnigraph mutate --uri graph.omni \
-e 'query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }' \
--params '{"name":"Inline","age":42}'
```
@ -38,14 +38,14 @@ only the source loader changes.
## Branching And Reviewable Data Flows
```bash
omnigraph branch create --uri ./graph.omni --from main feature-x
omnigraph branch list --uri ./graph.omni
omnigraph branch merge --uri ./graph.omni feature-x --into main
omnigraph branch create --uri graph.omni --from main feature-x
omnigraph branch list --uri graph.omni
omnigraph branch merge --uri graph.omni feature-x --into main
omnigraph ingest --data ./batch.jsonl --branch review/import-2026-04-09 ./graph.omni
omnigraph export ./graph.omni --branch main --type Person > people.jsonl
omnigraph commit list ./graph.omni --branch main --json
omnigraph commit show --uri ./graph.omni <commit-id> --json
omnigraph ingest --data batch.jsonl --branch review/import-2026-04-09 graph.omni
omnigraph export graph.omni --branch main --type Person > people.jsonl
omnigraph commit list graph.omni --branch main --json
omnigraph commit show --uri graph.omni <commit-id> --json
```
## Remote Server Mode
@ -53,7 +53,7 @@ omnigraph commit show --uri ./graph.omni <commit-id> --json
Serve a graph:
```bash
omnigraph-server ./graph.omni --bind 127.0.0.1:8080
omnigraph-server graph.omni --bind 127.0.0.1:8080
```
Read through the HTTP API:
@ -61,7 +61,7 @@ Read through the HTTP API:
```bash
omnigraph query \
--target http://127.0.0.1:8080 \
--query ./queries.gq \
--query queries.gq \
--name get_person \
--params '{"name":"Alice"}'
```
@ -87,23 +87,23 @@ Runtime add/remove is **not** in v0.6.0. To add a graph, stop the server, add a
Per-graph URLs: hit a graph's cluster route from any subcommand by pointing `--uri` at it:
```bash
omnigraph read --uri http://server.example.com/graphs/beta --query ./q.gq ...
omnigraph read --uri http://server.example.com/graphs/beta --query q.gq ...
```
## Runs, Policy, And Diagnostics
```bash
omnigraph lint --query ./queries.gq --schema ./schema.pg --json
omnigraph check --query ./queries.gq ./graph.omni --json
omnigraph lint --query queries.gq --schema schema.pg --json
omnigraph check --query queries.gq graph.omni --json
omnigraph schema plan --schema ./next.pg ./graph.omni --json
omnigraph schema apply --schema ./next.pg ./graph.omni --json
omnigraph policy validate --config ./omnigraph.yaml
omnigraph policy test --config ./omnigraph.yaml
omnigraph policy explain --config ./omnigraph.yaml --actor act-alice --action read --branch main
omnigraph schema plan --schema next.pg graph.omni --json
omnigraph schema apply --schema next.pg graph.omni --json
omnigraph policy validate --config omnigraph.yaml
omnigraph policy test --config omnigraph.yaml
omnigraph policy explain --config omnigraph.yaml --actor act-alice --action read --branch main
omnigraph commit list ./graph.omni --json
omnigraph commit show --uri ./graph.omni <commit-id> --json
omnigraph commit list graph.omni --json
omnigraph commit show --uri graph.omni <commit-id> --json
```
(The legacy `omnigraph run list/show/publish/abort` subcommands were removed in MR-771; mutations and loads publish atomically and the commit graph (`omnigraph commit list`) is the audit surface.)
@ -120,7 +120,7 @@ query roots:
```yaml
graphs:
local:
uri: ./demo.omni
uri: demo.omni
dev:
uri: http://127.0.0.1:8080
bearer_token_env: OMNIGRAPH_BEARER_TOKEN

View file

@ -20,14 +20,14 @@ or serve anything it applies: the server still boots from `omnigraph.yaml`.
## Commands
```bash
omnigraph cluster validate --config ./company-brain
omnigraph cluster plan --config ./company-brain --json
omnigraph cluster apply --config ./company-brain --json
omnigraph cluster approve graph.<id> --config ./company-brain --as <actor>
omnigraph cluster status --config ./company-brain --json
omnigraph cluster refresh --config ./company-brain --json
omnigraph cluster import --config ./company-brain --json
omnigraph cluster force-unlock <LOCK_ID> --config ./company-brain --json
omnigraph cluster validate --config company-brain
omnigraph cluster plan --config company-brain --json
omnigraph cluster apply --config company-brain --json
omnigraph cluster approve graph.<id> --config company-brain --as <actor>
omnigraph cluster status --config company-brain --json
omnigraph cluster refresh --config company-brain --json
omnigraph cluster import --config company-brain --json
omnigraph cluster force-unlock <LOCK_ID> --config company-brain --json
```
`--config` points at a directory, not a file. The directory must contain
@ -54,7 +54,7 @@ The exact contract:
cluster state XOR `omnigraph.yaml`, never a merge.
- **The other direction is ergonomics, not coupling**: a per-operator
`omnigraph.yaml` may point `graphs.<name>.uri` at a cluster's derived root
(`./company-brain/graphs/knowledge.omni`) so data-plane commands can use
(`company-brain/graphs/knowledge.omni`) so data-plane commands can use
`--target <name>` — an ordinary local path, no special handling.
## Supported `cluster.yaml`
@ -72,17 +72,35 @@ state:
graphs:
knowledge:
schema: ./knowledge.pg
queries:
find_experts:
file: ./knowledge.gq
schema: knowledge.pg
queries: queries/ # discover every `query <name>` in queries/*.gq
policies:
base:
file: ./base.policy.yaml
file: base.policy.yaml
applies_to: [knowledge]
```
`queries` is Terraform-shaped — the `.gq` files are the declaration. Three
forms:
```yaml
queries: queries/ # directory: top-level *.gq, sorted; every declaration registers
queries: [people.gq, extra/a.gq] # explicit files; every declaration in each
queries: # fine-grained name -> file map
find_experts:
file: knowledge.gq
```
Discovery is loud: an unreadable or unparseable `.gq`, or the same query name
declared in two files, fails validation (`query_parse_error`,
`duplicate_query_name`). Each discovered query is still an individually
addressed resource (`query.<graph>.<name>`) with its own plan/apply lifecycle;
the digest is the containing file's hash, so editing a multi-query file
updates all of its queries together. Paths are relative to the config
directory — the cluster is one explicit folder, so no `./` prefixes are
needed.
`metadata.name` is a display label. `state.backend` may be omitted or set to
`cluster`; external state backends are reserved for a later stage. `state.lock`
defaults to `true`. When enabled, `cluster plan`, `cluster apply`,
@ -324,7 +342,7 @@ without graph movement.
## Serving from the cluster (the mode switch)
```bash
omnigraph-server --cluster ./company-brain --bind 0.0.0.0:8080
omnigraph-server --cluster company-brain --bind 0.0.0.0:8080
```
`--cluster <dir>` is an **exclusive boot source** (axiom 15): it cannot

View file

@ -32,7 +32,8 @@ Lay out a config directory:
company-brain/
├── cluster.yaml
├── people.pg # schema for the "knowledge" graph
├── people.gq # a stored query
├── queries/ # stored queries — the .gq files ARE the declaration
│ └── people.gq
└── base.policy.yaml # a Cedar policy bundle
```
@ -43,27 +44,25 @@ metadata:
name: company-brain
graphs:
knowledge:
schema: ./people.pg
queries:
find_person:
file: ./people.gq
schema: people.pg
queries: queries/ # every `query <name>` in queries/*.gq registers
policies:
base:
file: ./base.policy.yaml
file: base.policy.yaml
applies_to: [knowledge] # graph-bound; use [cluster] for server-level
```
Bring it to life:
```bash
omnigraph cluster validate --config ./company-brain # parse + typecheck everything
omnigraph cluster import --config ./company-brain # create the state ledger
omnigraph cluster plan --config ./company-brain # preview: what would apply do?
omnigraph cluster apply --config ./company-brain # converge
omnigraph cluster validate --config company-brain # parse + typecheck everything
omnigraph cluster import --config company-brain # create the state ledger
omnigraph cluster plan --config company-brain # preview: what would apply do?
omnigraph cluster apply --config company-brain # converge
```
That single `apply` **creates the graph** (at the derived root
`./company-brain/graphs/knowledge.omni`), applies its schema, and publishes
`company-brain/graphs/knowledge.omni`), applies its schema, and publishes
the query and policy into the content-addressed catalog
(`__cluster/resources/…`). The output lists every change with its
disposition; `converged: true` means there is nothing left to do — re-running
@ -73,14 +72,14 @@ Load data through the normal graph plane (the control plane manages
*definitions*, not rows):
```bash
omnigraph load --data ./seed.jsonl ./company-brain/graphs/knowledge.omni
omnigraph load --data seed.jsonl company-brain/graphs/knowledge.omni
```
Serve it:
```bash
OMNIGRAPH_SERVER_BEARER_TOKENS_JSON='{"act-reader":"s3cret"}' \
omnigraph-server --cluster ./company-brain --bind 0.0.0.0:8080
omnigraph-server --cluster company-brain --bind 0.0.0.0:8080
```
`--cluster` is an **exclusive boot source**: it cannot be combined with a
@ -103,8 +102,8 @@ Every change follows the same loop, whatever its kind:
```bash
$EDITOR company-brain/people.pg # or any .gq / policy / cluster.yaml edit
omnigraph cluster plan --config ./company-brain
omnigraph cluster apply --config ./company-brain --as andrew
omnigraph cluster plan --config company-brain
omnigraph cluster apply --config company-brain --as andrew
# restart cluster-booted servers to pick it up
```
@ -142,8 +141,8 @@ anything runs.
## 3. Inspect: status, refresh, drift
```bash
omnigraph cluster status --config ./company-brain --json # ledger only, read-only
omnigraph cluster refresh --config ./company-brain # re-observe live graphs
omnigraph cluster status --config company-brain --json # ledger only, read-only
omnigraph cluster refresh --config company-brain # re-observe live graphs
```
`status` never touches the graphs; `refresh` opens them read-only and
@ -164,13 +163,13 @@ converges the ledger.
Removing a graph from `cluster.yaml` never executes silently:
```bash
omnigraph cluster apply --config ./company-brain
omnigraph cluster apply --config company-brain
# Delete graph.scratch [Blocked: approval_required]
omnigraph cluster approve graph.scratch --config ./company-brain --as andrew
omnigraph cluster approve graph.scratch --config company-brain --as andrew
# cluster approve: delete graph.scratch approved by andrew (approval 01KT…)
omnigraph cluster apply --config ./company-brain --as andrew
omnigraph cluster apply --config company-brain --as andrew
# Delete graph.scratch [Applied] ← root removed, subtree tombstoned
```
@ -196,8 +195,8 @@ again**.
**A held lock** (a crashed process left `__cluster/lock.json`):
```bash
omnigraph cluster status --config ./company-brain # shows the lock holder + id
omnigraph cluster force-unlock <LOCK_ID> --config ./company-brain
omnigraph cluster status --config company-brain # shows the lock holder + id
omnigraph cluster force-unlock <LOCK_ID> --config company-brain
```
Force-unlock requires the exact lock id (from status) — there is no blind
@ -240,7 +239,7 @@ with an in-flight apply.
- **`omnigraph.yaml` still has a job**: per-operator settings — your
`cli.actor` default for `--as`, CLI defaults, credentials, and data-plane
ergonomics (point `graphs.<name>.uri` at a derived root like
`./company-brain/graphs/knowledge.omni` to use `--target <name>` for
`company-brain/graphs/knowledge.omni` to use `--target <name>` for
loads). It just no longer describes the deployment — a server boots from
one source or the other, never a merge of both.

View file

@ -33,7 +33,7 @@ On Windows, the binaries are `omnigraph.exe` and `omnigraph-server.exe`.
Run against a local graph:
```bash
omnigraph-server ./graph.omni --bind 0.0.0.0:8080
omnigraph-server graph.omni --bind 0.0.0.0:8080
```
Run against an object-store-backed graph:
@ -208,7 +208,7 @@ docker run --rm -p 8080:8080 \
-e OMNIGRAPH_CONFIG="/etc/omnigraph/omnigraph.yaml" \
-v "$PWD/config:/etc/omnigraph:ro" \
omnigraph-server:local
# /etc/omnigraph/omnigraph.yaml contains `policy: { file: ./policy.yaml }`;
# /etc/omnigraph/omnigraph.yaml contains `policy: { file: policy.yaml }`;
# policy.yaml (+ optional policy.tests.yaml) sit beside it in the mount.
```

View file

@ -35,13 +35,13 @@ In multi mode (`omnigraph.yaml` with a non-empty `graphs:` map), policy files at
```yaml
server:
policy:
file: ./server-policy.yaml # server-level: graph_list
file: server-policy.yaml # server-level: graph_list
graphs:
alpha:
uri: s3://tenant-bucket/alpha
policy:
file: ./policies/alpha.yaml # per-graph: read, change, branch_*, schema_apply
file: policies/alpha.yaml # per-graph: read, change, branch_*, schema_apply
beta:
uri: s3://tenant-bucket/beta
# no per-graph policy → no engine-layer Cedar enforcement on beta
@ -78,8 +78,8 @@ rules:
```yaml
policy:
file: ./policy.yaml # Cedar rules + groups
tests: ./policy.tests.yaml # declarative test cases
file: policy.yaml # Cedar rules + groups
tests: policy.tests.yaml # declarative test cases
cli:
actor: act-andrew # default actor for CLI direct-engine writes

View file

@ -47,8 +47,8 @@ query register_employee_with_team($name: String, $age: I32, $team: String) {
```
```bash
omnigraph change --query ./mutations.gq --name register_employee_with_team \
--params '{"name":"Alice","age":30,"team":"Acme"}' ./graph.omni
omnigraph change --query mutations.gq --name register_employee_with_team \
--params '{"name":"Alice","age":30,"team":"Acme"}' graph.omni
```
If the second statement fails (e.g. `Acme` doesn't exist), the publisher never publishes; `Alice` is not in the database. Atomic.
@ -57,10 +57,10 @@ If the second statement fails (e.g. `Acme` doesn't exist), the publisher never p
```bash
# Query 1
omnigraph change --query ./mutations.gq --name register_employee --params '{"name":"Alice","age":30}' ./graph.omni
omnigraph change --query mutations.gq --name register_employee --params '{"name":"Alice","age":30}' graph.omni
# Query 2 — runs after Query 1 has already published
omnigraph change --query ./mutations.gq --name link_to_team --params '{"name":"Alice","team":"Acme"}' ./graph.omni
omnigraph change --query mutations.gq --name link_to_team --params '{"name":"Alice","team":"Acme"}' graph.omni
```
These are **two publishes** on `main`. If Query 2 fails, Query 1's effects are already visible. There is no `ROLLBACK` for Query 1.
@ -75,32 +75,32 @@ The pattern when you need to run multiple queries — possibly across multiple c
```bash
# Fork a working branch from main.
omnigraph branch create --from main onboarding/2026-04-25 ./graph.omni
omnigraph branch create --from main onboarding/2026-04-25 graph.omni
# Run any number of mutations on the branch — each one is its own publish on the branch.
# Concurrent reads of `main` are unaffected.
omnigraph change --branch onboarding/2026-04-25 \
--query ./mutations.gq --name register_employee \
--params '{"name":"Alice","age":30}' ./graph.omni
--query mutations.gq --name register_employee \
--params '{"name":"Alice","age":30}' graph.omni
omnigraph change --branch onboarding/2026-04-25 \
--query ./mutations.gq --name register_employee \
--params '{"name":"Bob","age":25}' ./graph.omni
--query mutations.gq --name register_employee \
--params '{"name":"Bob","age":25}' graph.omni
omnigraph change --branch onboarding/2026-04-25 \
--query ./mutations.gq --name link_to_team \
--params '{"name":"Alice","team":"Acme"}' ./graph.omni
--query mutations.gq --name link_to_team \
--params '{"name":"Alice","team":"Acme"}' graph.omni
# Inspect the branch — read queries work just like on main.
omnigraph read --branch onboarding/2026-04-25 \
--query ./queries.gq --name list_employees ./graph.omni
--query queries.gq --name list_employees graph.omni
# Happy with what's on the branch? Merge it. This is one atomic publish:
# `main` flips to include every commit on the branch.
omnigraph branch merge onboarding/2026-04-25 --into main ./graph.omni
omnigraph branch merge onboarding/2026-04-25 --into main graph.omni
# OR: not happy? Throw it away. `main` is untouched.
# omnigraph branch delete onboarding/2026-04-25 ./graph.omni
# omnigraph branch delete onboarding/2026-04-25 graph.omni
```
Properties:
@ -115,16 +115,16 @@ Two agents writing to the same graph independently:
```bash
# Agent A
omnigraph branch create --from main agent-a/work ./graph.omni
omnigraph change --branch agent-a/work … ./graph.omni
omnigraph branch create --from main agent-a/work graph.omni
omnigraph change --branch agent-a/work … graph.omni
# … many mutations …
omnigraph branch merge agent-a/work --into main ./graph.omni
omnigraph branch merge agent-a/work --into main graph.omni
# Agent B (running concurrently)
omnigraph branch create --from main agent-b/work ./graph.omni
omnigraph change --branch agent-b/work … ./graph.omni
omnigraph branch create --from main agent-b/work graph.omni
omnigraph change --branch agent-b/work … graph.omni
# … many mutations …
omnigraph branch merge agent-b/work --into main ./graph.omni
omnigraph branch merge agent-b/work --into main graph.omni
```
Each agent sees a consistent snapshot of `main` at the time it forked. The first merge to `main` lands as a fast-forward (or a no-op if no concurrent change). The second merge runs three-way: rows touched by both branches surface as `MergeConflict`s for the caller to resolve.