2026-04-10 20:49:41 +03:00
|
|
|
use std::fs;
|
2026-04-11 19:01:48 +03:00
|
|
|
use std::io::{self, Write};
|
2026-04-10 20:49:41 +03:00
|
|
|
use std::path::Path;
|
|
|
|
|
use std::path::PathBuf;
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
use std::sync::Arc;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
use clap::{Arg, ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
|
|
|
|
use color_eyre::eyre::{Result, bail};
|
2026-04-30 08:52:50 +02:00
|
|
|
use omnigraph::db::{Omnigraph, ReadTarget, SnapshotId};
|
2026-04-10 20:49:41 +03:00
|
|
|
use omnigraph::loader::LoadMode;
|
|
|
|
|
use omnigraph_compiler::query::parser::parse_query;
|
2026-04-13 00:37:44 +03:00
|
|
|
use omnigraph_compiler::schema::parser::parse_schema;
|
|
|
|
|
use omnigraph_compiler::{
|
|
|
|
|
JsonParamMode, ParamMap, QueryLintOutput, QueryLintQueryKind, QueryLintSchemaSource,
|
|
|
|
|
QueryLintSeverity, QueryLintStatus, SchemaMigrationPlan, SchemaMigrationStep, build_catalog,
|
|
|
|
|
json_params_to_param_map, lint_query_file,
|
|
|
|
|
};
|
2026-04-10 20:49:41 +03:00
|
|
|
use omnigraph_server::api::{
|
|
|
|
|
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
|
|
|
|
|
BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput,
|
mr-668: remove POST /graphs and CLI graphs create (defer runtime graph mgmt)
The POST /graphs runtime-create endpoint shipped in PR 7/10 has three
unresolved high-severity bugs:
- flock-on-renamed-inode race: the YAML flock is taken on
omnigraph.yaml itself, then a temp file is renamed over it.
Cross-process writers end up locking different inodes — both
believing they hold exclusive access.
- duplicate-check outside the file lock: precheck runs against
the in-memory registry only; the locked closure does
config.graphs.insert(...) unconditionally. Concurrent same-id
POSTs can persist the loser in YAML while the in-memory registry
keeps the winner — they disagree after restart.
- best_effort_cleanup_init_artifacts deletes _schema.pg /
_schema.ir.json / __schema_state.json on any init failure. An
accidental re-init against an existing graph's URI destroys its
schema; subsequent open() fails at read_text(_schema.pg).
The correct fix is a Lance-style cluster catalog (reserve → init →
publish with recovery sidecars), parallel to the engine's existing
__manifest discipline. That work is out of scope for v0.7.0.
For now, disable runtime add/remove from the network and CLI surface.
Operators add graphs by editing omnigraph.yaml and restarting. The
GET /graphs read-only enumeration stays.
Removed:
- POST /graphs handler + router fragment + utoipa registration
- 13 post_graphs_* server tests + 3 composite POST tests +
multi_mode_app_with_real_config / post_graph helpers
- CLI omnigraph graphs create subcommand + its handler + cli.rs tests
- system_remote.rs combined list+create test trimmed to list-only
- YAML rewrite infra: rewrite_atomic[_with_modify], RewriteAtomicError,
staging_path, hash_config_file, AppState::config_hash field +
threading through new_multi and open_multi_graph_state
- fs2 dependency (verified absent from cargo tree)
- sha2/fs2 imports in config.rs (only the rewrite path used them)
- Cedar PolicyAction::GraphCreate variant + "graph_create" match arms
+ action def in Cedar schema + graph_create_action_authorizes_against_server_resource test
- GraphCreateRequest / GraphCreateResponse / GraphSchemaSpec /
GraphPolicySpec API types (only the POST handler / CLI imported them)
Kept:
- GET /graphs (read-only enumeration) and graph_list Cedar action
- omnigraph graphs list CLI subcommand
- All multi-graph startup, mode inference, cluster routes,
per-graph + server-level Cedar policies
- server_settings_drive_multi_graph_startup_end_to_end (the test
that covers operator-authored YAML + restart — the path that
survives)
- best_effort_cleanup_init_artifacts and the three init failpoints
(still reachable from CLI `omnigraph init`; preflight fix deferred
as a follow-up)
- GraphRegistry::insert and its concurrency tests — production
callers gone, but the method is the natural seam for the future
cluster-catalog work
Also fixed (transcript issue 4):
- ALWAYS_FLAT_PATHS now includes /graphs so multi-mode OpenAPI
advertises the management route correctly (was previously rewritten
to /graphs/{graph_id}/graphs)
- multi_mode_openapi_keeps_healthz_flat → renamed to
multi_mode_openapi_keeps_management_paths_flat, asserts both
/healthz and /graphs stay flat
- multi_mode_openapi_prefixes_operation_ids_with_cluster skips
/graphs in addition to /healthz
Doc fixes:
- docs/user/cli.md: graphs list example was --target http://...,
but --target is a config-graph-name lookup; corrected to --uri.
Removed the graphs create example.
- docs/user/server.md: dropped POST /graphs row, "omnigraph.yaml
ownership", and "POST /graphs body shape" sections. Added a
paragraph stating runtime add/remove is not exposed in v0.7.0.
- docs/user/policy.md: dropped graph_create action; reworded the
"Configuration" line to clarify that server-scoped rules (graph_list)
take neither branch_scope nor target_branch_scope.
- docs/releases/v0.7.0.md: rewrote release narrative — multi-graph
mode ships; runtime add/remove deferred.
- AGENTS.md: HTTP server bullet and capability matrix row updated to
reflect read-only GET /graphs and the operator-edit workflow.
- openapi.json regenerated; /graphs has only .get, no .post.
Diff: 17 files, +123 −1525 LOC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:49:38 +02:00
|
|
|
CommitOutput, ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest,
|
|
|
|
|
ReadOutput, ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput,
|
mr-668: CLI omnigraph graphs list/create (PR 8/10)
PR 8 of the MR-668 multi-graph server work. CLI parity for the
v0.7.0 management surface: operators can now manage graphs from
the command line against a running multi-graph server.
omnigraph graphs list --target dev --json
omnigraph graphs create \
--target dev \
--graph-id beta \
--graph-uri /data/beta.omni \
--schema schema.pg
DELETE is intentionally absent — server-side DELETE was deferred from
v0.7.0 scope, and shipping a client subcommand for a server endpoint
that doesn't exist would be dead vocabulary. The help output, the
subcommand enum, and the test that pins it (`graphs_subcommand_help_
lists_list_and_create`) all agree.
CLI architecture (modeled on `BranchCommand`):
- New `Command::Graphs { command: GraphsCommand }` top-level variant.
- `GraphsCommand { List, Create }` enum.
- List: GET `<base>/graphs`. Stdout is `<graph_id>\t<uri>` per line,
or JSON via `--json`.
- Create: reads `--schema <path>` from local disk, inlines as
`schema: { source: <file> }` in the POST body (nested per
MR-668 decision 7). Optional `--policy-file <path>` becomes
`policy: { file: <path> }`. Returns 201 → "created graph X at Y"
or JSON via `--json`.
- Both subcommands reject local URI targets with a clear
"remote multi-graph server URL" error.
New API type imports in the CLI: `GraphCreateRequest`,
`GraphCreateResponse`, `GraphListResponse`, `GraphSchemaSpec`,
`GraphPolicySpec` — all from `omnigraph-server::api`.
Tests:
- cli.rs (4 new, non-network):
* `graphs_subcommand_help_lists_list_and_create` — pins the
deferral of `delete` (catches scope creep).
* `graphs_list_against_local_uri_errors_with_remote_only_message`
* `graphs_create_against_local_uri_errors_with_remote_only_message`
* `graphs_create_with_missing_schema_file_errors` — pins the
IO context in the schema-read error path.
- system_remote.rs (1 new, `#[ignore]` like its peers):
* `graphs_list_and_create_against_multi_graph_server` — spawns a
multi-mode server, calls `graphs list` (sees `alpha`),
`graphs create` (adds `beta`), `graphs list` again (sees both),
and confirms the new graph is reachable via its cluster route.
CLI suite: 62 tests green (58 existing + 4 new). The new ignored
end-to-end test runs locally with `cargo test --ignored`.
LOC: +159 main.rs (enum + handlers), +88 cli.rs (unit tests),
+131 system_remote.rs (integration test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:54:21 +02:00
|
|
|
SnapshotTableOutput, commit_output, ingest_output, read_output, schema_apply_output,
|
|
|
|
|
snapshot_payload,
|
2026-04-10 20:49:41 +03:00
|
|
|
};
|
|
|
|
|
use omnigraph_server::{
|
|
|
|
|
AliasCommand, OmnigraphConfig, PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest,
|
|
|
|
|
PolicyTestConfig, ReadOutputFormat, load_config,
|
|
|
|
|
};
|
|
|
|
|
use reqwest::Method;
|
|
|
|
|
use reqwest::header::AUTHORIZATION;
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use serde::de::DeserializeOwned;
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
|
|
|
|
mod embed;
|
|
|
|
|
mod read_format;
|
|
|
|
|
|
|
|
|
|
use embed::{EmbedArgs, EmbedOutput, execute_embed};
|
|
|
|
|
use read_format::{ReadRenderOptions, render_read};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN";
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Parser)]
|
|
|
|
|
#[command(name = "omnigraph")]
|
|
|
|
|
#[command(about = "Omnigraph graph database CLI")]
|
|
|
|
|
#[command(version = env!("CARGO_PKG_VERSION"), disable_version_flag = true)]
|
|
|
|
|
struct Cli {
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
/// Actor identity for direct-engine writes (MR-722). Overrides
|
|
|
|
|
/// `cli.actor` from `omnigraph.yaml`. When the configured policy
|
|
|
|
|
/// is in effect, Cedar evaluates this actor against the requested
|
|
|
|
|
/// action and scope; with policy configured but neither this flag
|
|
|
|
|
/// nor `cli.actor` set, the engine-layer footgun guard fires and
|
|
|
|
|
/// the write is denied (no silent bypass). Has no effect on remote
|
|
|
|
|
/// HTTP writes — those resolve their actor server-side from the
|
|
|
|
|
/// bearer token.
|
|
|
|
|
#[arg(long = "as", global = true, value_name = "ACTOR")]
|
|
|
|
|
as_actor: Option<String>,
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: Command,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum Command {
|
|
|
|
|
/// Print the CLI version
|
|
|
|
|
Version,
|
|
|
|
|
/// Generate, clean, or refresh explicit seed embeddings
|
|
|
|
|
Embed(EmbedArgs),
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Initialize a new graph from a schema
|
2026-04-10 20:49:41 +03:00
|
|
|
Init {
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
schema: PathBuf,
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI (local path or s3://)
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: String,
|
2026-05-27 13:24:49 +02:00
|
|
|
/// Overwrite existing schema artifacts at the URI. Without
|
|
|
|
|
/// this flag, init refuses to touch a URI that already holds
|
|
|
|
|
/// `_schema.pg`, `_schema.ir.json`, or `__schema_state.json`
|
|
|
|
|
/// — closes the re-init footgun (MR-668 follow-up). With the
|
|
|
|
|
/// flag, the operator opts in to destructive semantics.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
force: bool,
|
2026-04-10 20:49:41 +03:00
|
|
|
},
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Load data into a graph
|
2026-04-10 20:49:41 +03:00
|
|
|
Load {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
data: PathBuf,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
branch: Option<String>,
|
|
|
|
|
#[arg(long, default_value = "overwrite")]
|
|
|
|
|
mode: CliLoadMode,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
/// Ingest data into a reviewable named branch
|
|
|
|
|
Ingest {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
data: PathBuf,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
branch: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
from: Option<String>,
|
|
|
|
|
#[arg(long, default_value = "merge")]
|
|
|
|
|
mode: CliLoadMode,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
/// Branch operations
|
|
|
|
|
Branch {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: BranchCommand,
|
|
|
|
|
},
|
|
|
|
|
/// Schema planning operations
|
|
|
|
|
Schema {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: SchemaCommand,
|
|
|
|
|
},
|
2026-04-13 00:37:44 +03:00
|
|
|
/// Query validation and linting
|
|
|
|
|
Query {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: QueryCommand,
|
|
|
|
|
},
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Show graph snapshot
|
2026-04-10 20:49:41 +03:00
|
|
|
Snapshot {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
branch: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
/// Export a full graph snapshot as JSONL
|
|
|
|
|
Export {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
branch: Option<String>,
|
2026-04-11 19:01:48 +03:00
|
|
|
#[arg(long, hide = true)]
|
2026-04-10 20:49:41 +03:00
|
|
|
jsonl: bool,
|
|
|
|
|
#[arg(long = "type")]
|
|
|
|
|
type_names: Vec<String>,
|
|
|
|
|
#[arg(long = "table")]
|
|
|
|
|
table_keys: Vec<String>,
|
|
|
|
|
},
|
|
|
|
|
/// Commit history operations
|
|
|
|
|
Commit {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: CommitCommand,
|
|
|
|
|
},
|
|
|
|
|
/// Execute a read query against a branch or snapshot
|
|
|
|
|
Read {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-11 19:01:48 +03:00
|
|
|
#[arg(long)]
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
2026-04-11 19:01:48 +03:00
|
|
|
#[arg(hide = true)]
|
|
|
|
|
legacy_uri: Option<String>,
|
2026-04-10 20:49:41 +03:00
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
alias: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
query: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
name: Option<String>,
|
|
|
|
|
#[command(flatten)]
|
|
|
|
|
params: ParamsArgs,
|
|
|
|
|
#[arg(long, conflicts_with = "snapshot")]
|
|
|
|
|
branch: Option<String>,
|
|
|
|
|
#[arg(long, conflicts_with = "branch")]
|
|
|
|
|
snapshot: Option<String>,
|
|
|
|
|
#[arg(long, conflicts_with = "json")]
|
|
|
|
|
format: Option<ReadOutputFormat>,
|
|
|
|
|
#[arg(long, conflicts_with = "format")]
|
|
|
|
|
json: bool,
|
|
|
|
|
#[arg()]
|
|
|
|
|
alias_args: Vec<String>,
|
|
|
|
|
},
|
|
|
|
|
/// Execute a graph change query against a branch
|
|
|
|
|
Change {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-11 19:01:48 +03:00
|
|
|
#[arg(long)]
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
2026-04-11 19:01:48 +03:00
|
|
|
#[arg(hide = true)]
|
|
|
|
|
legacy_uri: Option<String>,
|
2026-04-10 20:49:41 +03:00
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
alias: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
query: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
name: Option<String>,
|
|
|
|
|
#[command(flatten)]
|
|
|
|
|
params: ParamsArgs,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
branch: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
#[arg()]
|
|
|
|
|
alias_args: Vec<String>,
|
|
|
|
|
},
|
|
|
|
|
/// Policy administration and diagnostics
|
|
|
|
|
Policy {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: PolicyCommand,
|
|
|
|
|
},
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Compact small Lance fragments in every table of the graph
|
2026-04-25 14:22:14 +03:00
|
|
|
Optimize {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-25 14:22:14 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Remove old Lance versions from every table of the graph (destructive)
|
2026-04-25 14:22:14 +03:00
|
|
|
Cleanup {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-25 14:22:14 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
/// Number of recent versions to keep per table. Either `--keep` or
|
|
|
|
|
/// `--older-than` (or both) must be set.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
keep: Option<u32>,
|
|
|
|
|
/// Only remove versions older than this duration. Accepts Go-style
|
|
|
|
|
/// durations: `7d`, `24h`, `90m`. At least one of --keep / --older-than.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
older_than: Option<String>,
|
|
|
|
|
/// Required to actually run; without it, prints what would be removed
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
confirm: bool,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
mr-668: CLI omnigraph graphs list/create (PR 8/10)
PR 8 of the MR-668 multi-graph server work. CLI parity for the
v0.7.0 management surface: operators can now manage graphs from
the command line against a running multi-graph server.
omnigraph graphs list --target dev --json
omnigraph graphs create \
--target dev \
--graph-id beta \
--graph-uri /data/beta.omni \
--schema schema.pg
DELETE is intentionally absent — server-side DELETE was deferred from
v0.7.0 scope, and shipping a client subcommand for a server endpoint
that doesn't exist would be dead vocabulary. The help output, the
subcommand enum, and the test that pins it (`graphs_subcommand_help_
lists_list_and_create`) all agree.
CLI architecture (modeled on `BranchCommand`):
- New `Command::Graphs { command: GraphsCommand }` top-level variant.
- `GraphsCommand { List, Create }` enum.
- List: GET `<base>/graphs`. Stdout is `<graph_id>\t<uri>` per line,
or JSON via `--json`.
- Create: reads `--schema <path>` from local disk, inlines as
`schema: { source: <file> }` in the POST body (nested per
MR-668 decision 7). Optional `--policy-file <path>` becomes
`policy: { file: <path> }`. Returns 201 → "created graph X at Y"
or JSON via `--json`.
- Both subcommands reject local URI targets with a clear
"remote multi-graph server URL" error.
New API type imports in the CLI: `GraphCreateRequest`,
`GraphCreateResponse`, `GraphListResponse`, `GraphSchemaSpec`,
`GraphPolicySpec` — all from `omnigraph-server::api`.
Tests:
- cli.rs (4 new, non-network):
* `graphs_subcommand_help_lists_list_and_create` — pins the
deferral of `delete` (catches scope creep).
* `graphs_list_against_local_uri_errors_with_remote_only_message`
* `graphs_create_against_local_uri_errors_with_remote_only_message`
* `graphs_create_with_missing_schema_file_errors` — pins the
IO context in the schema-read error path.
- system_remote.rs (1 new, `#[ignore]` like its peers):
* `graphs_list_and_create_against_multi_graph_server` — spawns a
multi-mode server, calls `graphs list` (sees `alpha`),
`graphs create` (adds `beta`), `graphs list` again (sees both),
and confirms the new graph is reachable via its cluster route.
CLI suite: 62 tests green (58 existing + 4 new). The new ignored
end-to-end test runs locally with `cargo test --ignored`.
LOC: +159 main.rs (enum + handlers), +88 cli.rs (unit tests),
+131 system_remote.rs (integration test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:54:21 +02:00
|
|
|
/// Manage graphs on a multi-graph server (MR-668)
|
|
|
|
|
Graphs {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: GraphsCommand,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Operations on the graph registry of a multi-graph server (MR-668).
|
|
|
|
|
///
|
|
|
|
|
/// All operations target a remote multi-graph server URL (http:// or
|
mr-668: remove POST /graphs and CLI graphs create (defer runtime graph mgmt)
The POST /graphs runtime-create endpoint shipped in PR 7/10 has three
unresolved high-severity bugs:
- flock-on-renamed-inode race: the YAML flock is taken on
omnigraph.yaml itself, then a temp file is renamed over it.
Cross-process writers end up locking different inodes — both
believing they hold exclusive access.
- duplicate-check outside the file lock: precheck runs against
the in-memory registry only; the locked closure does
config.graphs.insert(...) unconditionally. Concurrent same-id
POSTs can persist the loser in YAML while the in-memory registry
keeps the winner — they disagree after restart.
- best_effort_cleanup_init_artifacts deletes _schema.pg /
_schema.ir.json / __schema_state.json on any init failure. An
accidental re-init against an existing graph's URI destroys its
schema; subsequent open() fails at read_text(_schema.pg).
The correct fix is a Lance-style cluster catalog (reserve → init →
publish with recovery sidecars), parallel to the engine's existing
__manifest discipline. That work is out of scope for v0.7.0.
For now, disable runtime add/remove from the network and CLI surface.
Operators add graphs by editing omnigraph.yaml and restarting. The
GET /graphs read-only enumeration stays.
Removed:
- POST /graphs handler + router fragment + utoipa registration
- 13 post_graphs_* server tests + 3 composite POST tests +
multi_mode_app_with_real_config / post_graph helpers
- CLI omnigraph graphs create subcommand + its handler + cli.rs tests
- system_remote.rs combined list+create test trimmed to list-only
- YAML rewrite infra: rewrite_atomic[_with_modify], RewriteAtomicError,
staging_path, hash_config_file, AppState::config_hash field +
threading through new_multi and open_multi_graph_state
- fs2 dependency (verified absent from cargo tree)
- sha2/fs2 imports in config.rs (only the rewrite path used them)
- Cedar PolicyAction::GraphCreate variant + "graph_create" match arms
+ action def in Cedar schema + graph_create_action_authorizes_against_server_resource test
- GraphCreateRequest / GraphCreateResponse / GraphSchemaSpec /
GraphPolicySpec API types (only the POST handler / CLI imported them)
Kept:
- GET /graphs (read-only enumeration) and graph_list Cedar action
- omnigraph graphs list CLI subcommand
- All multi-graph startup, mode inference, cluster routes,
per-graph + server-level Cedar policies
- server_settings_drive_multi_graph_startup_end_to_end (the test
that covers operator-authored YAML + restart — the path that
survives)
- best_effort_cleanup_init_artifacts and the three init failpoints
(still reachable from CLI `omnigraph init`; preflight fix deferred
as a follow-up)
- GraphRegistry::insert and its concurrency tests — production
callers gone, but the method is the natural seam for the future
cluster-catalog work
Also fixed (transcript issue 4):
- ALWAYS_FLAT_PATHS now includes /graphs so multi-mode OpenAPI
advertises the management route correctly (was previously rewritten
to /graphs/{graph_id}/graphs)
- multi_mode_openapi_keeps_healthz_flat → renamed to
multi_mode_openapi_keeps_management_paths_flat, asserts both
/healthz and /graphs stay flat
- multi_mode_openapi_prefixes_operation_ids_with_cluster skips
/graphs in addition to /healthz
Doc fixes:
- docs/user/cli.md: graphs list example was --target http://...,
but --target is a config-graph-name lookup; corrected to --uri.
Removed the graphs create example.
- docs/user/server.md: dropped POST /graphs row, "omnigraph.yaml
ownership", and "POST /graphs body shape" sections. Added a
paragraph stating runtime add/remove is not exposed in v0.7.0.
- docs/user/policy.md: dropped graph_create action; reworded the
"Configuration" line to clarify that server-scoped rules (graph_list)
take neither branch_scope nor target_branch_scope.
- docs/releases/v0.7.0.md: rewrote release narrative — multi-graph
mode ships; runtime add/remove deferred.
- AGENTS.md: HTTP server bullet and capability matrix row updated to
reflect read-only GET /graphs and the operator-edit workflow.
- openapi.json regenerated; /graphs has only .get, no .post.
Diff: 17 files, +123 −1525 LOC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:49:38 +02:00
|
|
|
/// https://). Local-URI invocations return a clear error. To add or
|
|
|
|
|
/// remove graphs, operators edit `omnigraph.yaml` directly and restart
|
|
|
|
|
/// the server — runtime mutation is not exposed in v0.7.0.
|
mr-668: CLI omnigraph graphs list/create (PR 8/10)
PR 8 of the MR-668 multi-graph server work. CLI parity for the
v0.7.0 management surface: operators can now manage graphs from
the command line against a running multi-graph server.
omnigraph graphs list --target dev --json
omnigraph graphs create \
--target dev \
--graph-id beta \
--graph-uri /data/beta.omni \
--schema schema.pg
DELETE is intentionally absent — server-side DELETE was deferred from
v0.7.0 scope, and shipping a client subcommand for a server endpoint
that doesn't exist would be dead vocabulary. The help output, the
subcommand enum, and the test that pins it (`graphs_subcommand_help_
lists_list_and_create`) all agree.
CLI architecture (modeled on `BranchCommand`):
- New `Command::Graphs { command: GraphsCommand }` top-level variant.
- `GraphsCommand { List, Create }` enum.
- List: GET `<base>/graphs`. Stdout is `<graph_id>\t<uri>` per line,
or JSON via `--json`.
- Create: reads `--schema <path>` from local disk, inlines as
`schema: { source: <file> }` in the POST body (nested per
MR-668 decision 7). Optional `--policy-file <path>` becomes
`policy: { file: <path> }`. Returns 201 → "created graph X at Y"
or JSON via `--json`.
- Both subcommands reject local URI targets with a clear
"remote multi-graph server URL" error.
New API type imports in the CLI: `GraphCreateRequest`,
`GraphCreateResponse`, `GraphListResponse`, `GraphSchemaSpec`,
`GraphPolicySpec` — all from `omnigraph-server::api`.
Tests:
- cli.rs (4 new, non-network):
* `graphs_subcommand_help_lists_list_and_create` — pins the
deferral of `delete` (catches scope creep).
* `graphs_list_against_local_uri_errors_with_remote_only_message`
* `graphs_create_against_local_uri_errors_with_remote_only_message`
* `graphs_create_with_missing_schema_file_errors` — pins the
IO context in the schema-read error path.
- system_remote.rs (1 new, `#[ignore]` like its peers):
* `graphs_list_and_create_against_multi_graph_server` — spawns a
multi-mode server, calls `graphs list` (sees `alpha`),
`graphs create` (adds `beta`), `graphs list` again (sees both),
and confirms the new graph is reachable via its cluster route.
CLI suite: 62 tests green (58 existing + 4 new). The new ignored
end-to-end test runs locally with `cargo test --ignored`.
LOC: +159 main.rs (enum + handlers), +88 cli.rs (unit tests),
+131 system_remote.rs (integration test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:54:21 +02:00
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum GraphsCommand {
|
|
|
|
|
/// List every graph registered with the multi-graph server.
|
|
|
|
|
List {
|
|
|
|
|
/// Remote server URL (e.g. `https://server.example.com`).
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum BranchCommand {
|
|
|
|
|
/// Create a new branch
|
|
|
|
|
Create {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
#[arg(long)]
|
|
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
from: Option<String>,
|
|
|
|
|
name: String,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
/// List branches
|
|
|
|
|
List {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
#[arg(long)]
|
|
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
/// Delete a branch
|
|
|
|
|
Delete {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
#[arg(long)]
|
|
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
name: String,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
/// Merge a source branch into a target branch
|
|
|
|
|
Merge {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
#[arg(long)]
|
|
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
source: String,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
into: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum SchemaCommand {
|
|
|
|
|
/// Plan a schema migration against the accepted persisted schema
|
|
|
|
|
Plan {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
schema: PathBuf,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
/// Show the plan as it would execute with `--allow-data-loss`.
|
|
|
|
|
/// Promotes every `DropMode::Soft` step to `DropMode::Hard`
|
|
|
|
|
/// so the plan output reflects the destructive intent.
|
|
|
|
|
#[arg(long, default_value_t = false)]
|
|
|
|
|
allow_data_loss: bool,
|
2026-04-10 20:49:41 +03:00
|
|
|
},
|
2026-04-12 04:01:14 +03:00
|
|
|
/// Apply a supported schema migration
|
|
|
|
|
Apply {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-12 04:01:14 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
schema: PathBuf,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
/// Allow destructive (data-loss) schema changes.
|
|
|
|
|
///
|
|
|
|
|
/// Without this flag, drops are "soft": the column or table
|
|
|
|
|
/// is removed from the current manifest version but prior
|
|
|
|
|
/// versions are retained, so `snapshot_at_version(pre_drop)`
|
|
|
|
|
/// can still read the dropped data until `omnigraph cleanup`
|
|
|
|
|
/// runs. With this flag, drops are "hard": `cleanup_old_versions`
|
|
|
|
|
/// runs on the affected datasets immediately after the apply,
|
|
|
|
|
/// making the prior data unreachable.
|
|
|
|
|
#[arg(long, default_value_t = false)]
|
|
|
|
|
allow_data_loss: bool,
|
2026-04-12 04:01:14 +03:00
|
|
|
},
|
2026-04-18 00:30:46 +03:00
|
|
|
/// Show the current accepted schema source
|
|
|
|
|
#[command(alias = "get")]
|
|
|
|
|
Show {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-16 21:15:17 +00:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 00:37:44 +03:00
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum QueryCommand {
|
|
|
|
|
/// Validate queries and report higher-level drift warnings
|
|
|
|
|
#[command(visible_alias = "check")]
|
|
|
|
|
Lint {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-13 00:37:44 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
query: PathBuf,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
schema: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum CommitCommand {
|
|
|
|
|
/// List graph commits
|
|
|
|
|
List {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
branch: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
/// Show a graph commit
|
|
|
|
|
Show {
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Graph URI
|
2026-04-11 19:01:48 +03:00
|
|
|
#[arg(long)]
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
commit_id: String,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
json: bool,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum PolicyCommand {
|
|
|
|
|
/// Validate policy YAML and compiled Cedar policy state
|
|
|
|
|
Validate {
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
},
|
|
|
|
|
/// Run declarative policy tests from policy.tests.yaml
|
|
|
|
|
Test {
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
},
|
|
|
|
|
/// Explain one policy decision locally
|
|
|
|
|
Explain {
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
config: Option<PathBuf>,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
actor: String,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
action: PolicyAction,
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
branch: Option<String>,
|
|
|
|
|
#[arg(long = "target-branch")]
|
|
|
|
|
target_branch: Option<String>,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
|
|
|
struct ParamsArgs {
|
|
|
|
|
#[arg(long, conflicts_with = "params_file")]
|
|
|
|
|
params: Option<String>,
|
|
|
|
|
#[arg(long, conflicts_with = "params")]
|
|
|
|
|
params_file: Option<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)]
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
enum CliLoadMode {
|
|
|
|
|
Overwrite,
|
|
|
|
|
Append,
|
|
|
|
|
Merge,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<CliLoadMode> for LoadMode {
|
|
|
|
|
fn from(value: CliLoadMode) -> Self {
|
|
|
|
|
match value {
|
|
|
|
|
CliLoadMode::Overwrite => LoadMode::Overwrite,
|
|
|
|
|
CliLoadMode::Append => LoadMode::Append,
|
|
|
|
|
CliLoadMode::Merge => LoadMode::Merge,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CliLoadMode {
|
|
|
|
|
fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
CliLoadMode::Overwrite => "overwrite",
|
|
|
|
|
CliLoadMode::Append => "append",
|
|
|
|
|
CliLoadMode::Merge => "merge",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
struct LoadOutput<'a> {
|
|
|
|
|
uri: &'a str,
|
|
|
|
|
branch: &'a str,
|
|
|
|
|
mode: &'a str,
|
|
|
|
|
nodes_loaded: usize,
|
|
|
|
|
edges_loaded: usize,
|
2026-04-11 19:01:48 +03:00
|
|
|
node_types_loaded: usize,
|
|
|
|
|
edge_types_loaded: usize,
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
struct SchemaPlanOutput<'a> {
|
|
|
|
|
uri: &'a str,
|
|
|
|
|
supported: bool,
|
|
|
|
|
step_count: usize,
|
|
|
|
|
steps: &'a [SchemaMigrationStep],
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:01:14 +03:00
|
|
|
fn print_schema_apply_human(output: &SchemaApplyOutput) {
|
|
|
|
|
println!("schema apply for {}", output.uri);
|
|
|
|
|
println!("supported: {}", if output.supported { "yes" } else { "no" });
|
|
|
|
|
println!("applied: {}", if output.applied { "yes" } else { "no" });
|
|
|
|
|
println!("manifest_version: {}", output.manifest_version);
|
|
|
|
|
if output.steps.is_empty() {
|
|
|
|
|
println!("no schema changes");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for step in &output.steps {
|
|
|
|
|
println!("- {}", render_schema_plan_step(step));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 00:37:44 +03:00
|
|
|
fn query_kind_label(kind: QueryLintQueryKind) -> &'static str {
|
|
|
|
|
match kind {
|
|
|
|
|
QueryLintQueryKind::Read => "read",
|
|
|
|
|
QueryLintQueryKind::Mutation => "mutation",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn severity_label(severity: QueryLintSeverity) -> &'static str {
|
|
|
|
|
match severity {
|
|
|
|
|
QueryLintSeverity::Error => "ERROR",
|
|
|
|
|
QueryLintSeverity::Warning => "WARN ",
|
|
|
|
|
QueryLintSeverity::Info => "INFO ",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_query_lint_human(output: &QueryLintOutput) {
|
|
|
|
|
for result in &output.results {
|
|
|
|
|
match result.status {
|
|
|
|
|
QueryLintStatus::Ok => {
|
|
|
|
|
println!(
|
|
|
|
|
"OK query `{}` ({})",
|
|
|
|
|
result.name,
|
|
|
|
|
query_kind_label(result.kind)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
QueryLintStatus::Error => {
|
|
|
|
|
println!(
|
|
|
|
|
"ERROR query `{}`: {}",
|
|
|
|
|
result.name,
|
|
|
|
|
result.error.as_deref().unwrap_or("unknown error")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for warning in &result.warnings {
|
|
|
|
|
println!("WARN query `{}`: {}", result.name, warning);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for finding in &output.findings {
|
|
|
|
|
println!("{} {}", severity_label(finding.severity), finding.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println!(
|
|
|
|
|
"INFO Lint complete: {} queries processed ({} error(s), {} warning(s), {} info item(s))",
|
|
|
|
|
output.queries_processed, output.errors, output.warnings, output.infos
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn finish_query_lint(output: &QueryLintOutput, json: bool) -> Result<()> {
|
|
|
|
|
if json {
|
|
|
|
|
print_json(output)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_query_lint_human(output);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if output.status == QueryLintStatus::Error {
|
|
|
|
|
io::stdout().flush()?;
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
fn ensure_local_graph_parent(uri: &str) -> Result<()> {
|
2026-04-10 20:49:41 +03:00
|
|
|
if !uri.contains("://") {
|
|
|
|
|
fs::create_dir_all(uri)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_json<T: Serialize>(value: &T) -> Result<()> {
|
|
|
|
|
println!("{}", serde_json::to_string_pretty(value)?);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_remote_uri(uri: &str) -> bool {
|
|
|
|
|
uri.starts_with("http://") || uri.starts_with("https://")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remote_url(base: &str, path: &str) -> String {
|
|
|
|
|
format!("{}{}", base.trim_end_matches('/'), path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remote_branch_url(base: &str, branch: &str) -> Result<String> {
|
|
|
|
|
let mut url = reqwest::Url::parse(&format!("{}/", base.trim_end_matches('/')))?;
|
|
|
|
|
url.path_segments_mut()
|
|
|
|
|
.map_err(|_| color_eyre::eyre::eyre!("invalid remote base url"))?
|
|
|
|
|
.extend(["branches", branch]);
|
|
|
|
|
Ok(url.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_bearer_token(value: Option<String>) -> Option<String> {
|
|
|
|
|
value
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn bearer_token_from_env(var_name: &str) -> Option<String> {
|
|
|
|
|
normalize_bearer_token(std::env::var(var_name).ok())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_env_assignment(line: &str) -> Option<(String, String)> {
|
|
|
|
|
let line = line.trim();
|
|
|
|
|
if line.is_empty() || line.starts_with('#') {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let line = line.strip_prefix("export ").unwrap_or(line).trim();
|
|
|
|
|
let (name, value) = line.split_once('=')?;
|
|
|
|
|
let name = name.trim();
|
|
|
|
|
if name.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let value = value.trim();
|
|
|
|
|
let value = if value.len() >= 2
|
|
|
|
|
&& ((value.starts_with('"') && value.ends_with('"'))
|
|
|
|
|
|| (value.starts_with('\'') && value.ends_with('\'')))
|
|
|
|
|
{
|
|
|
|
|
&value[1..value.len() - 1]
|
|
|
|
|
} else {
|
|
|
|
|
value
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Some((name.to_string(), value.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn bearer_token_from_env_file(path: &Path, var_name: &str) -> Result<Option<String>> {
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for line in fs::read_to_string(path)?.lines() {
|
|
|
|
|
let Some((name, value)) = parse_env_assignment(line) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if name == var_name {
|
|
|
|
|
return Ok(normalize_bearer_token(Some(value)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_env_file_into_process(path: &Path) -> Result<()> {
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for line in fs::read_to_string(path)?.lines() {
|
|
|
|
|
let Some((name, value)) = parse_env_assignment(line) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if std::env::var_os(&name).is_none() {
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::set_var(name, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_cli_config(config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
|
|
|
|
|
let config = load_config(config_path)?;
|
|
|
|
|
if let Some(path) = config.resolve_auth_env_file() {
|
|
|
|
|
load_env_file_into_process(&path)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(config)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_policy_engine(config: &OmnigraphConfig) -> Result<PolicyEngine> {
|
|
|
|
|
let policy_file = config
|
|
|
|
|
.resolve_policy_file()
|
|
|
|
|
.ok_or_else(|| color_eyre::eyre::eyre!("policy.file must be set in omnigraph.yaml"))?;
|
2026-05-27 13:35:22 +02:00
|
|
|
PolicyEngine::load_graph(&policy_file, &policy_graph_id(config))
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
/// Open a local-URI graph and, when `policy.file` is configured in
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
/// `omnigraph.yaml`, install the resolved `PolicyEngine` on the engine
|
|
|
|
|
/// handle so every direct-engine write goes through
|
|
|
|
|
/// `Omnigraph::enforce(...)` (MR-722). Without a configured policy this
|
|
|
|
|
/// is identical to a bare `Omnigraph::open`.
|
|
|
|
|
///
|
|
|
|
|
/// Returns owned `Omnigraph`; chained on top of `Omnigraph::open(...)`'s
|
|
|
|
|
/// existing future to keep call sites narrow.
|
|
|
|
|
async fn open_local_db_with_policy(uri: &str, config: &OmnigraphConfig) -> Result<Omnigraph> {
|
|
|
|
|
let db = Omnigraph::open(uri).await?;
|
|
|
|
|
if config.resolve_policy_file().is_some() {
|
|
|
|
|
let engine = Arc::new(resolve_policy_engine(config)?);
|
|
|
|
|
Ok(db.with_policy(engine as Arc<dyn omnigraph_policy::PolicyChecker>))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(db)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve the CLI's effective actor identity for engine-layer policy
|
|
|
|
|
/// (MR-722). Precedence: `--as <ACTOR>` (top-level flag) overrides
|
|
|
|
|
/// `cli.actor` from `omnigraph.yaml`; both unset returns `None`. When
|
|
|
|
|
/// policy is configured and this returns `None`, the engine-layer
|
|
|
|
|
/// footgun guard intentionally denies — silent bypass via "I forgot the
|
|
|
|
|
/// actor" is what the guard prevents.
|
2026-05-24 16:46:00 +01:00
|
|
|
fn resolve_cli_actor<'a>(cli_as: Option<&'a str>, config: &'a OmnigraphConfig) -> Option<&'a str> {
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
cli_as.or(config.cli.actor.as_deref())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
fn resolve_policy_tests_path(config: &OmnigraphConfig) -> Result<PathBuf> {
|
|
|
|
|
config.resolve_policy_tests_file().ok_or_else(|| {
|
|
|
|
|
color_eyre::eyre::eyre!(
|
|
|
|
|
"policy.tests.yaml requires policy.file to be set in omnigraph.yaml"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
fn policy_graph_id(config: &OmnigraphConfig) -> String {
|
2026-04-10 20:49:41 +03:00
|
|
|
if let Some(name) = &config.project.name {
|
|
|
|
|
return name.clone();
|
|
|
|
|
}
|
|
|
|
|
config
|
2026-04-14 04:12:14 +03:00
|
|
|
.resolve_target_uri(None, None, config.server_graph_name())
|
|
|
|
|
.or_else(|_| config.resolve_target_uri(None, None, config.cli_graph_name()))
|
2026-04-10 20:49:41 +03:00
|
|
|
.unwrap_or_else(|_| "default".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_remote_bearer_token(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
explicit_uri: Option<&str>,
|
|
|
|
|
explicit_target: Option<&str>,
|
|
|
|
|
) -> Result<Option<String>> {
|
|
|
|
|
let scoped_env =
|
2026-04-14 04:12:14 +03:00
|
|
|
config.graph_bearer_token_env(explicit_uri, explicit_target, config.cli_graph_name());
|
2026-04-10 20:49:41 +03:00
|
|
|
let mut env_names = Vec::new();
|
|
|
|
|
if let Some(name) = scoped_env {
|
|
|
|
|
env_names.push(name.to_string());
|
|
|
|
|
}
|
|
|
|
|
if env_names
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|name| name != DEFAULT_BEARER_TOKEN_ENV)
|
|
|
|
|
{
|
|
|
|
|
env_names.push(DEFAULT_BEARER_TOKEN_ENV.to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let env_file = config.resolve_auth_env_file();
|
|
|
|
|
for env_name in env_names {
|
|
|
|
|
if let Some(token) = bearer_token_from_env(&env_name) {
|
|
|
|
|
return Ok(Some(token));
|
|
|
|
|
}
|
|
|
|
|
if let Some(path) = env_file.as_ref() {
|
|
|
|
|
if let Some(token) = bearer_token_from_env_file(path, &env_name)? {
|
|
|
|
|
return Ok(Some(token));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_http_client() -> Result<reqwest::Client> {
|
|
|
|
|
Ok(reqwest::Client::new())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_bearer_token(
|
|
|
|
|
request: reqwest::RequestBuilder,
|
|
|
|
|
token: Option<&str>,
|
|
|
|
|
) -> reqwest::RequestBuilder {
|
|
|
|
|
if let Some(token) = token {
|
|
|
|
|
request.header(AUTHORIZATION, format!("Bearer {}", token))
|
|
|
|
|
} else {
|
|
|
|
|
request
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn remote_json<T: DeserializeOwned>(
|
|
|
|
|
client: &reqwest::Client,
|
|
|
|
|
method: Method,
|
|
|
|
|
url: String,
|
|
|
|
|
body: Option<Value>,
|
|
|
|
|
bearer_token: Option<&str>,
|
|
|
|
|
) -> Result<T> {
|
|
|
|
|
let request = apply_bearer_token(client.request(method, url), bearer_token);
|
|
|
|
|
let request = if let Some(body) = body {
|
|
|
|
|
request.json(&body)
|
|
|
|
|
} else {
|
|
|
|
|
request
|
|
|
|
|
};
|
|
|
|
|
let response = request.send().await?;
|
|
|
|
|
let status = response.status();
|
|
|
|
|
let text = response.text().await?;
|
|
|
|
|
if !status.is_success() {
|
|
|
|
|
if let Ok(error) = serde_json::from_str::<ErrorOutput>(&text) {
|
|
|
|
|
bail!(error.error);
|
|
|
|
|
}
|
|
|
|
|
bail!("server returned {}: {}", status, text);
|
|
|
|
|
}
|
|
|
|
|
Ok(serde_json::from_str(&text)?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_uri(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
cli_uri: Option<String>,
|
|
|
|
|
cli_target: Option<&str>,
|
|
|
|
|
) -> Result<String> {
|
2026-04-14 04:12:14 +03:00
|
|
|
config.resolve_target_uri(cli_uri, cli_target, config.cli_graph_name())
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 14:22:14 +03:00
|
|
|
/// Parse a Go-style compact duration: `7d`, `24h`, `30m`, `90s`, or a plain
|
|
|
|
|
/// integer as seconds. Used by the `cleanup --older-than` flag.
|
|
|
|
|
fn parse_duration_arg(s: &str) -> Result<std::time::Duration> {
|
|
|
|
|
let s = s.trim();
|
|
|
|
|
if s.is_empty() {
|
|
|
|
|
bail!("duration is empty");
|
|
|
|
|
}
|
2026-05-24 16:46:00 +01:00
|
|
|
let (num_part, unit) = match s
|
|
|
|
|
.char_indices()
|
|
|
|
|
.rev()
|
|
|
|
|
.find(|(_, c)| c.is_ascii_alphabetic())
|
|
|
|
|
{
|
|
|
|
|
Some((i, _)) => (
|
|
|
|
|
&s[..i + 1 - s[i..].chars().next().unwrap().len_utf8()],
|
|
|
|
|
&s[i..],
|
|
|
|
|
),
|
2026-04-25 14:22:14 +03:00
|
|
|
None => (s, ""),
|
|
|
|
|
};
|
|
|
|
|
let n: u64 = num_part
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|e| color_eyre::eyre::eyre!("invalid duration '{}': {}", s, e))?;
|
|
|
|
|
let secs = match unit {
|
|
|
|
|
"" | "s" => n,
|
|
|
|
|
"m" => n * 60,
|
|
|
|
|
"h" => n * 60 * 60,
|
|
|
|
|
"d" => n * 60 * 60 * 24,
|
|
|
|
|
"w" => n * 60 * 60 * 24 * 7,
|
|
|
|
|
_ => bail!("unknown duration unit '{}'. Supported: s, m, h, d, w", unit),
|
|
|
|
|
};
|
|
|
|
|
Ok(std::time::Duration::from_secs(secs))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
fn resolve_local_uri(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
cli_uri: Option<String>,
|
|
|
|
|
cli_target: Option<&str>,
|
|
|
|
|
operation: &str,
|
|
|
|
|
) -> Result<String> {
|
|
|
|
|
let uri = resolve_uri(config, cli_uri, cli_target)?;
|
|
|
|
|
if is_remote_uri(&uri) {
|
|
|
|
|
bail!(
|
2026-05-24 16:46:00 +01:00
|
|
|
"{} is only supported against local graph URIs in this milestone",
|
2026-04-10 20:49:41 +03:00
|
|
|
operation
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(uri)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_branch(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
cli_branch: Option<String>,
|
|
|
|
|
alias_branch: Option<String>,
|
|
|
|
|
default_branch: &str,
|
|
|
|
|
) -> String {
|
|
|
|
|
cli_branch
|
|
|
|
|
.or(alias_branch)
|
|
|
|
|
.or_else(|| config.cli.branch.clone())
|
|
|
|
|
.unwrap_or_else(|| default_branch.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_read_target(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
cli_branch: Option<String>,
|
|
|
|
|
cli_snapshot: Option<String>,
|
|
|
|
|
alias_branch: Option<String>,
|
|
|
|
|
) -> Result<ReadTarget> {
|
|
|
|
|
if cli_branch.is_some() && cli_snapshot.is_some() {
|
|
|
|
|
bail!("read target may specify branch or snapshot, not both");
|
|
|
|
|
}
|
|
|
|
|
Ok(read_target_from_cli(
|
|
|
|
|
cli_branch
|
|
|
|
|
.or(alias_branch)
|
|
|
|
|
.or_else(|| config.cli.branch.clone()),
|
|
|
|
|
cli_snapshot,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 00:37:44 +03:00
|
|
|
fn resolve_query_path(
|
2026-04-10 20:49:41 +03:00
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
explicit_query: Option<&PathBuf>,
|
|
|
|
|
alias_query: Option<&str>,
|
2026-04-13 00:37:44 +03:00
|
|
|
) -> Result<PathBuf> {
|
|
|
|
|
explicit_query
|
2026-04-10 20:49:41 +03:00
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.or_else(|| alias_query.map(PathBuf::from))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
color_eyre::eyre::eyre!("exactly one of --query or --alias must be provided")
|
2026-04-13 00:37:44 +03:00
|
|
|
})
|
|
|
|
|
.and_then(|query_path| config.resolve_query_path(&query_path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_query_source(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
explicit_query: Option<&PathBuf>,
|
|
|
|
|
alias_query: Option<&str>,
|
|
|
|
|
) -> Result<String> {
|
|
|
|
|
Ok(fs::read_to_string(resolve_query_path(
|
|
|
|
|
config,
|
|
|
|
|
explicit_query,
|
|
|
|
|
alias_query,
|
|
|
|
|
)?)?)
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_alias_value(value: &str) -> Value {
|
|
|
|
|
serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn merged_params_json(
|
|
|
|
|
alias_name: Option<&str>,
|
|
|
|
|
alias_arg_names: &[String],
|
|
|
|
|
alias_arg_values: &[String],
|
|
|
|
|
explicit: Option<Value>,
|
|
|
|
|
) -> Result<Option<Value>> {
|
|
|
|
|
if alias_arg_values.len() > alias_arg_names.len() {
|
|
|
|
|
let alias = alias_name.unwrap_or("<alias>");
|
|
|
|
|
bail!(
|
|
|
|
|
"alias '{}' expects at most {} args but got {}",
|
|
|
|
|
alias,
|
|
|
|
|
alias_arg_names.len(),
|
|
|
|
|
alias_arg_values.len()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut merged = serde_json::Map::new();
|
|
|
|
|
for (arg_name, arg_value) in alias_arg_names.iter().zip(alias_arg_values.iter()) {
|
|
|
|
|
merged.insert(arg_name.clone(), parse_alias_value(arg_value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match explicit {
|
|
|
|
|
Some(Value::Object(object)) => {
|
|
|
|
|
for (key, value) in object {
|
|
|
|
|
merged.insert(key, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some(_) => bail!("params JSON must be an object"),
|
|
|
|
|
None => {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if merged.is_empty() {
|
|
|
|
|
Ok(None)
|
|
|
|
|
} else {
|
|
|
|
|
Ok(Some(Value::Object(merged)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_load_human(
|
|
|
|
|
uri: &str,
|
|
|
|
|
branch: &str,
|
|
|
|
|
mode: CliLoadMode,
|
|
|
|
|
nodes_loaded: usize,
|
|
|
|
|
edges_loaded: usize,
|
2026-04-11 19:01:48 +03:00
|
|
|
node_types_loaded: usize,
|
|
|
|
|
edge_types_loaded: usize,
|
2026-04-10 20:49:41 +03:00
|
|
|
) {
|
|
|
|
|
println!(
|
2026-04-11 19:01:48 +03:00
|
|
|
"loaded {} on branch {} with {}: {} nodes across {} node types, {} edges across {} edge types",
|
2026-04-10 20:49:41 +03:00
|
|
|
uri,
|
|
|
|
|
branch,
|
|
|
|
|
mode.as_str(),
|
|
|
|
|
nodes_loaded,
|
2026-04-11 19:01:48 +03:00
|
|
|
node_types_loaded,
|
|
|
|
|
edges_loaded,
|
|
|
|
|
edge_types_loaded
|
2026-04-10 20:49:41 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_ingest_human(output: &IngestOutput) {
|
|
|
|
|
println!(
|
|
|
|
|
"ingested {} into branch {} from {} with {} ({})",
|
|
|
|
|
output.uri,
|
|
|
|
|
output.branch,
|
|
|
|
|
output.base_branch,
|
|
|
|
|
output.mode.as_str(),
|
|
|
|
|
if output.branch_created {
|
|
|
|
|
"branch created"
|
|
|
|
|
} else {
|
|
|
|
|
"branch exists"
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
for table in &output.tables {
|
|
|
|
|
println!("{} rows_loaded={}", table.table_key, table.rows_loaded);
|
|
|
|
|
}
|
|
|
|
|
if let Some(actor_id) = &output.actor_id {
|
|
|
|
|
println!("actor_id: {}", actor_id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_schema_plan_human(uri: &str, plan: &SchemaMigrationPlan) {
|
|
|
|
|
println!("schema plan for {}", uri);
|
|
|
|
|
println!("supported: {}", if plan.supported { "yes" } else { "no" });
|
|
|
|
|
if plan.steps.is_empty() {
|
|
|
|
|
println!("no schema changes");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for step in &plan.steps {
|
|
|
|
|
println!("- {}", render_schema_plan_step(step));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_schema_plan_step(step: &SchemaMigrationStep) -> String {
|
|
|
|
|
match step {
|
|
|
|
|
SchemaMigrationStep::AddType { type_kind, name } => {
|
|
|
|
|
format!("add {} type '{}'", schema_type_kind_label(*type_kind), name)
|
|
|
|
|
}
|
|
|
|
|
SchemaMigrationStep::RenameType {
|
|
|
|
|
type_kind,
|
|
|
|
|
from,
|
|
|
|
|
to,
|
|
|
|
|
} => format!(
|
|
|
|
|
"rename {} type '{}' -> '{}'",
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
from,
|
|
|
|
|
to
|
|
|
|
|
),
|
|
|
|
|
SchemaMigrationStep::AddProperty {
|
|
|
|
|
type_kind,
|
|
|
|
|
type_name,
|
|
|
|
|
property_name,
|
|
|
|
|
property_type,
|
|
|
|
|
} => format!(
|
|
|
|
|
"add property '{}.{}' ({}) on {} '{}'",
|
|
|
|
|
type_name,
|
|
|
|
|
property_name,
|
|
|
|
|
render_prop_type(property_type),
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
type_name
|
|
|
|
|
),
|
|
|
|
|
SchemaMigrationStep::RenameProperty {
|
|
|
|
|
type_kind,
|
|
|
|
|
type_name,
|
|
|
|
|
from,
|
|
|
|
|
to,
|
|
|
|
|
} => format!(
|
|
|
|
|
"rename property '{}.{}' -> '{}.{}' on {} '{}'",
|
|
|
|
|
type_name,
|
|
|
|
|
from,
|
|
|
|
|
type_name,
|
|
|
|
|
to,
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
type_name
|
|
|
|
|
),
|
|
|
|
|
SchemaMigrationStep::AddConstraint {
|
|
|
|
|
type_kind,
|
|
|
|
|
type_name,
|
|
|
|
|
constraint,
|
|
|
|
|
} => format!(
|
|
|
|
|
"add constraint {} on {} '{}'",
|
|
|
|
|
render_constraint(constraint),
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
type_name
|
|
|
|
|
),
|
|
|
|
|
SchemaMigrationStep::UpdateTypeMetadata {
|
|
|
|
|
type_kind,
|
|
|
|
|
name,
|
|
|
|
|
annotations,
|
|
|
|
|
} => format!(
|
|
|
|
|
"update metadata on {} '{}' ({})",
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
name,
|
|
|
|
|
render_annotations(annotations)
|
|
|
|
|
),
|
|
|
|
|
SchemaMigrationStep::UpdatePropertyMetadata {
|
|
|
|
|
type_kind,
|
|
|
|
|
type_name,
|
|
|
|
|
property_name,
|
|
|
|
|
annotations,
|
|
|
|
|
} => format!(
|
|
|
|
|
"update metadata on property '{}.{}' of {} '{}' ({})",
|
|
|
|
|
type_name,
|
|
|
|
|
property_name,
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
type_name,
|
|
|
|
|
render_annotations(annotations)
|
|
|
|
|
),
|
schema-lint chassis v1.0: DropProperty Soft + code-tagged diagnostics (MR-694) (#90)
* schema-lint chassis v1 (WIP): tier surfacing + plan doc
First commit of the chassis v1 branch. Lands a small, foundational
slice without behavior change, plus a planning doc that lays out the
remaining 7 commits in sequence so the PR can be reviewed
incrementally.
This commit:
- Adds SchemaMigrationStep::diagnostic() returning the full
&'static DiagnosticCode (family + tier + severity) for
UnsupportedChange steps with codes. Renderers can now reach the
tier without re-implementing the code → tier lookup.
- CLI `omnigraph schema plan` output now displays tier alongside
code:
unsupported change on node:Person.age [OG-DS-104, destructive]:
removing property 'Person.age' is not supported in schema
migration v1
Operators see at-a-glance the kind of risk each rejection
represents — not just the rule identifier.
- No behavior change. All 11 existing schema_apply tests still pass.
Planning doc at docs/schema-lint-v1-plan.md tracks the 7 remaining
commits to bring v1 to feature-complete:
1. (this commit) Tier surfacing in plan output.
2. Soft / Hard mode enum on drop steps.
3. Tombstone fields on catalog IR.
4. Planner emits DropProperty { Soft } by default.
5. Apply path implements Soft mode.
6. Convert PR #62 destructive-rejection tests.
7. --allow-data-loss flag + Hard mode.
8. (optional) Tombstone unhide / restore command.
Delete the planning doc when v1 lands. Intentionally checked in to
the WIP branch so the scope is reviewable; not intended as a
permanent doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* schema-lint v1 commit 2: DropMode + dormant Drop* variants
Second commit of the chassis v1 branch. Lands the type-level shape
of soft/hard drops without wiring them up. Variants are reachable
from emitters but the planner doesn't produce them yet; the apply
path returns an explicit not-yet-implemented error if one shows up
via deserialization.
Added:
- `DropMode { Soft, Hard }` — orthogonal to `SafetyTier`. Tier
classifies the rule's risk class; mode is the operator's intent
for data treatment.
- `Soft` → catalog tombstone, data retained. Tier: safe.
- `Hard` → Lance-level removal. Tier: destructive; will require
--allow-data-loss to apply (commit 7).
- `SchemaMigrationStep::DropType { type_kind, name, mode }` and
`SchemaMigrationStep::DropProperty { type_kind, type_name,
property_name, mode }` variants.
- Re-export `DropMode` from `omnigraph_compiler::DropMode` so
downstream crates don't reach into the catalog submodule.
- CLI `render_schema_plan_step` arms for both variants, surfacing
the mode in plan output: `drop property 'Person.age' of node
'Person' (soft mode)`.
- `apply_schema_with_lock` exhaustive match arm for the two new
variants that returns `manifest_internal` with a clear
not-yet-implemented message. If a SchemaIR JSON containing
Drop{Type,Property} arrives (e.g. from a future tool or hand-
written), the apply path fails explicitly rather than silently
misclassifying.
- Two new in-source tests:
- `drop_steps_round_trip_through_serde` — pins the wire shape
for all four (variant × mode) combinations.
- `drop_mode_serde_uses_snake_case` — pins external-tool-
friendly serialization (`"soft"` / `"hard"`).
Build: clean, only pre-existing warnings.
Tests:
- omnigraph-compiler schema_plan: 6/6 (4 existing + 2 new).
- omnigraph-engine schema_apply: 11/11 (unchanged — planner still
emits UnsupportedChange for removal paths).
Next commit (commit 3 per docs/schema-lint-v1-plan.md): add the
`tombstoned: bool` fields to NodeIR / EdgeIR / PropertyIR for the
catalog representation of soft-mode tombstones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* plan doc: reframe v1 around Lance native drop_columns
After a substrate audit of the Lance data-evolution guide on
2026-05-13, the v1 plan was simplified. Two key findings:
1. Lance's `drop_columns()` is already metadata-only and reversible
via time travel until cleanup. No need for a parallel
`tombstoned: bool` field in our catalog IR — Lance's version
graph IS the tombstone.
2. The full schema_apply substrate migration (add_columns,
drop_columns, alter_columns vs. stage_overwrite across all step
types) is consolidated in MR-948 as a sibling issue. v1 only
uses the relevant slice (drop_columns for OG-DS-1XX).
Net plan changes:
- Commit 3 (original): tombstone fields on catalog IR → dropped.
No catalog IR change needed. The Lance drop_columns commit IS the
tombstone.
- Commit 5 (original): apply path writes tombstoned: true → replaced
with: apply path calls Dataset::drop_columns([name]).
- Commit 7 Hard mode: stage_overwrite removing the column → replaced
with: drop_columns + compact_files + cleanup_old_versions. Same
APIs omnigraph cleanup already uses.
- Commit 8 (original): omnigraph schema unhide → dropped. Time
travel is the undo (omnigraph snapshot --at <commit>).
Net result: 8 commits → 5 commits. ~250 LoC less surface. More
substrate-aligned.
The chassis types from commit 2 (DropMode enum, DropType /
DropProperty variants) are kept exactly as designed; only the
implementation strategy changed.
The Lance docs quote is included in the doc so future readers see
the substrate behavior cited verbatim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* schema-lint v1 commit 3: emit + apply DropProperty { Soft }
Wire the dormant DropProperty variant end-to-end for the Soft case.
Per docs/schema-lint-v1-plan.md, commit #3 of the schema-lint chassis
v1 series (MR-694).
Planner (schema_plan.rs):
- plan_properties: emit DropProperty { type_kind, type_name,
property_name, mode: Soft } instead of UnsupportedChange when a
property exists in accepted but not in desired. Plan is now
supported = true for drop-only changes.
Apply (schema_apply.rs):
- Route DropProperty { Soft } through rewritten_tables. The existing
batch_for_schema_apply_rewrite path already iterates the *target*
schema fields, so a property absent from desired_catalog is
naturally projected away. The prior Lance version retains the
dropped column for time-travel reversibility (until cleanup runs).
- DropType still errors (lands in commit #4 with different mechanics:
__manifest entry removal instead of column projection).
- DropProperty { Hard } still errors (lands in commit #5 with
--allow-data-loss CLI flag + immediate compact_files +
cleanup_old_versions).
Tests:
- Planner unit test plan_emits_soft_drop_for_removed_nullable_property
asserts the variant emission + supported = true + no UnsupportedChange.
- Integration test apply_schema_drops_a_nullable_property_softly_
preserves_prior_version (replaces the former
apply_schema_rejects_dropping_a_property_with_data) asserts:
(a) plan contains DropProperty { Soft }
(b) apply succeeds + manifest advances + row count unchanged
(c) current dataset schema lacks the dropped column
(d) snapshot_at_version(pre_drop) still has the dropped column
(e) reopen consistency — drop preserved across engine restart
Recovery: rides on SidecarKind::SchemaApply per MR-847. No new
sidecar kind needed; the entire apply path is already sidecar-wrapped.
Substrate alignment: this commit uses the stage_overwrite full-rewrite
path (full_rewrite cost class) rather than Lance native drop_columns
(catalog_only cost class). MR-948 is the follow-up substrate-alignment
refactor that introduces a LanceColumnOp surface and switches the
metadata-only case onto drop_columns. Functional outcome is identical;
cost-class improvement deferred.
Test results:
- cargo test -p omnigraph-compiler --lib: 238 passed
- cargo test -p omnigraph-engine --test schema_apply: 11 passed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: move schema-lint-v1-plan into docs/dev/ + add to index
Post-rebase fixup for the docs split (#93). The plan doc was added
to docs/ at the top level before main reorganized to docs/{user,dev}/.
This moves it into docs/dev/ and adds an entry to docs/dev/index.md
under a new "Active Implementation Plans" section so the
check-agents-md.sh link check passes.
Per the original commit message (617a77d), the plan doc is intentionally
temporary — it will be deleted when v1 lands.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:30:03 +03:00
|
|
|
SchemaMigrationStep::DropType {
|
|
|
|
|
type_kind,
|
|
|
|
|
name,
|
|
|
|
|
mode,
|
|
|
|
|
} => format!(
|
|
|
|
|
"drop {} type '{}' ({} mode)",
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
name,
|
|
|
|
|
drop_mode_label(*mode),
|
|
|
|
|
),
|
|
|
|
|
SchemaMigrationStep::DropProperty {
|
|
|
|
|
type_kind,
|
|
|
|
|
type_name,
|
|
|
|
|
property_name,
|
|
|
|
|
mode,
|
|
|
|
|
} => format!(
|
|
|
|
|
"drop property '{}.{}' of {} '{}' ({} mode)",
|
|
|
|
|
type_name,
|
|
|
|
|
property_name,
|
|
|
|
|
schema_type_kind_label(*type_kind),
|
|
|
|
|
type_name,
|
|
|
|
|
drop_mode_label(*mode),
|
|
|
|
|
),
|
2026-05-24 16:46:00 +01:00
|
|
|
SchemaMigrationStep::UnsupportedChange { entity, reason, .. } => {
|
schema-lint chassis v1.0: DropProperty Soft + code-tagged diagnostics (MR-694) (#90)
* schema-lint chassis v1 (WIP): tier surfacing + plan doc
First commit of the chassis v1 branch. Lands a small, foundational
slice without behavior change, plus a planning doc that lays out the
remaining 7 commits in sequence so the PR can be reviewed
incrementally.
This commit:
- Adds SchemaMigrationStep::diagnostic() returning the full
&'static DiagnosticCode (family + tier + severity) for
UnsupportedChange steps with codes. Renderers can now reach the
tier without re-implementing the code → tier lookup.
- CLI `omnigraph schema plan` output now displays tier alongside
code:
unsupported change on node:Person.age [OG-DS-104, destructive]:
removing property 'Person.age' is not supported in schema
migration v1
Operators see at-a-glance the kind of risk each rejection
represents — not just the rule identifier.
- No behavior change. All 11 existing schema_apply tests still pass.
Planning doc at docs/schema-lint-v1-plan.md tracks the 7 remaining
commits to bring v1 to feature-complete:
1. (this commit) Tier surfacing in plan output.
2. Soft / Hard mode enum on drop steps.
3. Tombstone fields on catalog IR.
4. Planner emits DropProperty { Soft } by default.
5. Apply path implements Soft mode.
6. Convert PR #62 destructive-rejection tests.
7. --allow-data-loss flag + Hard mode.
8. (optional) Tombstone unhide / restore command.
Delete the planning doc when v1 lands. Intentionally checked in to
the WIP branch so the scope is reviewable; not intended as a
permanent doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* schema-lint v1 commit 2: DropMode + dormant Drop* variants
Second commit of the chassis v1 branch. Lands the type-level shape
of soft/hard drops without wiring them up. Variants are reachable
from emitters but the planner doesn't produce them yet; the apply
path returns an explicit not-yet-implemented error if one shows up
via deserialization.
Added:
- `DropMode { Soft, Hard }` — orthogonal to `SafetyTier`. Tier
classifies the rule's risk class; mode is the operator's intent
for data treatment.
- `Soft` → catalog tombstone, data retained. Tier: safe.
- `Hard` → Lance-level removal. Tier: destructive; will require
--allow-data-loss to apply (commit 7).
- `SchemaMigrationStep::DropType { type_kind, name, mode }` and
`SchemaMigrationStep::DropProperty { type_kind, type_name,
property_name, mode }` variants.
- Re-export `DropMode` from `omnigraph_compiler::DropMode` so
downstream crates don't reach into the catalog submodule.
- CLI `render_schema_plan_step` arms for both variants, surfacing
the mode in plan output: `drop property 'Person.age' of node
'Person' (soft mode)`.
- `apply_schema_with_lock` exhaustive match arm for the two new
variants that returns `manifest_internal` with a clear
not-yet-implemented message. If a SchemaIR JSON containing
Drop{Type,Property} arrives (e.g. from a future tool or hand-
written), the apply path fails explicitly rather than silently
misclassifying.
- Two new in-source tests:
- `drop_steps_round_trip_through_serde` — pins the wire shape
for all four (variant × mode) combinations.
- `drop_mode_serde_uses_snake_case` — pins external-tool-
friendly serialization (`"soft"` / `"hard"`).
Build: clean, only pre-existing warnings.
Tests:
- omnigraph-compiler schema_plan: 6/6 (4 existing + 2 new).
- omnigraph-engine schema_apply: 11/11 (unchanged — planner still
emits UnsupportedChange for removal paths).
Next commit (commit 3 per docs/schema-lint-v1-plan.md): add the
`tombstoned: bool` fields to NodeIR / EdgeIR / PropertyIR for the
catalog representation of soft-mode tombstones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* plan doc: reframe v1 around Lance native drop_columns
After a substrate audit of the Lance data-evolution guide on
2026-05-13, the v1 plan was simplified. Two key findings:
1. Lance's `drop_columns()` is already metadata-only and reversible
via time travel until cleanup. No need for a parallel
`tombstoned: bool` field in our catalog IR — Lance's version
graph IS the tombstone.
2. The full schema_apply substrate migration (add_columns,
drop_columns, alter_columns vs. stage_overwrite across all step
types) is consolidated in MR-948 as a sibling issue. v1 only
uses the relevant slice (drop_columns for OG-DS-1XX).
Net plan changes:
- Commit 3 (original): tombstone fields on catalog IR → dropped.
No catalog IR change needed. The Lance drop_columns commit IS the
tombstone.
- Commit 5 (original): apply path writes tombstoned: true → replaced
with: apply path calls Dataset::drop_columns([name]).
- Commit 7 Hard mode: stage_overwrite removing the column → replaced
with: drop_columns + compact_files + cleanup_old_versions. Same
APIs omnigraph cleanup already uses.
- Commit 8 (original): omnigraph schema unhide → dropped. Time
travel is the undo (omnigraph snapshot --at <commit>).
Net result: 8 commits → 5 commits. ~250 LoC less surface. More
substrate-aligned.
The chassis types from commit 2 (DropMode enum, DropType /
DropProperty variants) are kept exactly as designed; only the
implementation strategy changed.
The Lance docs quote is included in the doc so future readers see
the substrate behavior cited verbatim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* schema-lint v1 commit 3: emit + apply DropProperty { Soft }
Wire the dormant DropProperty variant end-to-end for the Soft case.
Per docs/schema-lint-v1-plan.md, commit #3 of the schema-lint chassis
v1 series (MR-694).
Planner (schema_plan.rs):
- plan_properties: emit DropProperty { type_kind, type_name,
property_name, mode: Soft } instead of UnsupportedChange when a
property exists in accepted but not in desired. Plan is now
supported = true for drop-only changes.
Apply (schema_apply.rs):
- Route DropProperty { Soft } through rewritten_tables. The existing
batch_for_schema_apply_rewrite path already iterates the *target*
schema fields, so a property absent from desired_catalog is
naturally projected away. The prior Lance version retains the
dropped column for time-travel reversibility (until cleanup runs).
- DropType still errors (lands in commit #4 with different mechanics:
__manifest entry removal instead of column projection).
- DropProperty { Hard } still errors (lands in commit #5 with
--allow-data-loss CLI flag + immediate compact_files +
cleanup_old_versions).
Tests:
- Planner unit test plan_emits_soft_drop_for_removed_nullable_property
asserts the variant emission + supported = true + no UnsupportedChange.
- Integration test apply_schema_drops_a_nullable_property_softly_
preserves_prior_version (replaces the former
apply_schema_rejects_dropping_a_property_with_data) asserts:
(a) plan contains DropProperty { Soft }
(b) apply succeeds + manifest advances + row count unchanged
(c) current dataset schema lacks the dropped column
(d) snapshot_at_version(pre_drop) still has the dropped column
(e) reopen consistency — drop preserved across engine restart
Recovery: rides on SidecarKind::SchemaApply per MR-847. No new
sidecar kind needed; the entire apply path is already sidecar-wrapped.
Substrate alignment: this commit uses the stage_overwrite full-rewrite
path (full_rewrite cost class) rather than Lance native drop_columns
(catalog_only cost class). MR-948 is the follow-up substrate-alignment
refactor that introduces a LanceColumnOp surface and switches the
metadata-only case onto drop_columns. Functional outcome is identical;
cost-class improvement deferred.
Test results:
- cargo test -p omnigraph-compiler --lib: 238 passed
- cargo test -p omnigraph-engine --test schema_apply: 11 passed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: move schema-lint-v1-plan into docs/dev/ + add to index
Post-rebase fixup for the docs split (#93). The plan doc was added
to docs/ at the top level before main reorganized to docs/{user,dev}/.
This moves it into docs/dev/ and adds an entry to docs/dev/index.md
under a new "Active Implementation Plans" section so the
check-agents-md.sh link check passes.
Per the original commit message (617a77d), the plan doc is intentionally
temporary — it will be deleted when v1 lands.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:30:03 +03:00
|
|
|
// When a schema-lint code is attached, render code + tier
|
|
|
|
|
// so operators see at-a-glance the kind of risk (destructive
|
|
|
|
|
// / validated / safe) — not just the rule identifier.
|
|
|
|
|
// Reach the diagnostic via the `diagnostic()` helper so the
|
|
|
|
|
// CLI doesn't need to know how the lookup works.
|
|
|
|
|
match step.diagnostic() {
|
|
|
|
|
Some(diag) => format!(
|
|
|
|
|
"unsupported change on {} [{}, {}]: {}",
|
|
|
|
|
entity,
|
|
|
|
|
diag.code,
|
|
|
|
|
schema_lint_tier_label(diag.tier),
|
|
|
|
|
reason,
|
|
|
|
|
),
|
|
|
|
|
None => format!("unsupported change on {}: {}", entity, reason),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn schema_type_kind_label(kind: omnigraph_compiler::SchemaTypeKind) -> &'static str {
|
|
|
|
|
match kind {
|
|
|
|
|
omnigraph_compiler::SchemaTypeKind::Interface => "interface",
|
|
|
|
|
omnigraph_compiler::SchemaTypeKind::Node => "node",
|
|
|
|
|
omnigraph_compiler::SchemaTypeKind::Edge => "edge",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
schema-lint chassis v1.0: DropProperty Soft + code-tagged diagnostics (MR-694) (#90)
* schema-lint chassis v1 (WIP): tier surfacing + plan doc
First commit of the chassis v1 branch. Lands a small, foundational
slice without behavior change, plus a planning doc that lays out the
remaining 7 commits in sequence so the PR can be reviewed
incrementally.
This commit:
- Adds SchemaMigrationStep::diagnostic() returning the full
&'static DiagnosticCode (family + tier + severity) for
UnsupportedChange steps with codes. Renderers can now reach the
tier without re-implementing the code → tier lookup.
- CLI `omnigraph schema plan` output now displays tier alongside
code:
unsupported change on node:Person.age [OG-DS-104, destructive]:
removing property 'Person.age' is not supported in schema
migration v1
Operators see at-a-glance the kind of risk each rejection
represents — not just the rule identifier.
- No behavior change. All 11 existing schema_apply tests still pass.
Planning doc at docs/schema-lint-v1-plan.md tracks the 7 remaining
commits to bring v1 to feature-complete:
1. (this commit) Tier surfacing in plan output.
2. Soft / Hard mode enum on drop steps.
3. Tombstone fields on catalog IR.
4. Planner emits DropProperty { Soft } by default.
5. Apply path implements Soft mode.
6. Convert PR #62 destructive-rejection tests.
7. --allow-data-loss flag + Hard mode.
8. (optional) Tombstone unhide / restore command.
Delete the planning doc when v1 lands. Intentionally checked in to
the WIP branch so the scope is reviewable; not intended as a
permanent doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* schema-lint v1 commit 2: DropMode + dormant Drop* variants
Second commit of the chassis v1 branch. Lands the type-level shape
of soft/hard drops without wiring them up. Variants are reachable
from emitters but the planner doesn't produce them yet; the apply
path returns an explicit not-yet-implemented error if one shows up
via deserialization.
Added:
- `DropMode { Soft, Hard }` — orthogonal to `SafetyTier`. Tier
classifies the rule's risk class; mode is the operator's intent
for data treatment.
- `Soft` → catalog tombstone, data retained. Tier: safe.
- `Hard` → Lance-level removal. Tier: destructive; will require
--allow-data-loss to apply (commit 7).
- `SchemaMigrationStep::DropType { type_kind, name, mode }` and
`SchemaMigrationStep::DropProperty { type_kind, type_name,
property_name, mode }` variants.
- Re-export `DropMode` from `omnigraph_compiler::DropMode` so
downstream crates don't reach into the catalog submodule.
- CLI `render_schema_plan_step` arms for both variants, surfacing
the mode in plan output: `drop property 'Person.age' of node
'Person' (soft mode)`.
- `apply_schema_with_lock` exhaustive match arm for the two new
variants that returns `manifest_internal` with a clear
not-yet-implemented message. If a SchemaIR JSON containing
Drop{Type,Property} arrives (e.g. from a future tool or hand-
written), the apply path fails explicitly rather than silently
misclassifying.
- Two new in-source tests:
- `drop_steps_round_trip_through_serde` — pins the wire shape
for all four (variant × mode) combinations.
- `drop_mode_serde_uses_snake_case` — pins external-tool-
friendly serialization (`"soft"` / `"hard"`).
Build: clean, only pre-existing warnings.
Tests:
- omnigraph-compiler schema_plan: 6/6 (4 existing + 2 new).
- omnigraph-engine schema_apply: 11/11 (unchanged — planner still
emits UnsupportedChange for removal paths).
Next commit (commit 3 per docs/schema-lint-v1-plan.md): add the
`tombstoned: bool` fields to NodeIR / EdgeIR / PropertyIR for the
catalog representation of soft-mode tombstones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* plan doc: reframe v1 around Lance native drop_columns
After a substrate audit of the Lance data-evolution guide on
2026-05-13, the v1 plan was simplified. Two key findings:
1. Lance's `drop_columns()` is already metadata-only and reversible
via time travel until cleanup. No need for a parallel
`tombstoned: bool` field in our catalog IR — Lance's version
graph IS the tombstone.
2. The full schema_apply substrate migration (add_columns,
drop_columns, alter_columns vs. stage_overwrite across all step
types) is consolidated in MR-948 as a sibling issue. v1 only
uses the relevant slice (drop_columns for OG-DS-1XX).
Net plan changes:
- Commit 3 (original): tombstone fields on catalog IR → dropped.
No catalog IR change needed. The Lance drop_columns commit IS the
tombstone.
- Commit 5 (original): apply path writes tombstoned: true → replaced
with: apply path calls Dataset::drop_columns([name]).
- Commit 7 Hard mode: stage_overwrite removing the column → replaced
with: drop_columns + compact_files + cleanup_old_versions. Same
APIs omnigraph cleanup already uses.
- Commit 8 (original): omnigraph schema unhide → dropped. Time
travel is the undo (omnigraph snapshot --at <commit>).
Net result: 8 commits → 5 commits. ~250 LoC less surface. More
substrate-aligned.
The chassis types from commit 2 (DropMode enum, DropType /
DropProperty variants) are kept exactly as designed; only the
implementation strategy changed.
The Lance docs quote is included in the doc so future readers see
the substrate behavior cited verbatim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* schema-lint v1 commit 3: emit + apply DropProperty { Soft }
Wire the dormant DropProperty variant end-to-end for the Soft case.
Per docs/schema-lint-v1-plan.md, commit #3 of the schema-lint chassis
v1 series (MR-694).
Planner (schema_plan.rs):
- plan_properties: emit DropProperty { type_kind, type_name,
property_name, mode: Soft } instead of UnsupportedChange when a
property exists in accepted but not in desired. Plan is now
supported = true for drop-only changes.
Apply (schema_apply.rs):
- Route DropProperty { Soft } through rewritten_tables. The existing
batch_for_schema_apply_rewrite path already iterates the *target*
schema fields, so a property absent from desired_catalog is
naturally projected away. The prior Lance version retains the
dropped column for time-travel reversibility (until cleanup runs).
- DropType still errors (lands in commit #4 with different mechanics:
__manifest entry removal instead of column projection).
- DropProperty { Hard } still errors (lands in commit #5 with
--allow-data-loss CLI flag + immediate compact_files +
cleanup_old_versions).
Tests:
- Planner unit test plan_emits_soft_drop_for_removed_nullable_property
asserts the variant emission + supported = true + no UnsupportedChange.
- Integration test apply_schema_drops_a_nullable_property_softly_
preserves_prior_version (replaces the former
apply_schema_rejects_dropping_a_property_with_data) asserts:
(a) plan contains DropProperty { Soft }
(b) apply succeeds + manifest advances + row count unchanged
(c) current dataset schema lacks the dropped column
(d) snapshot_at_version(pre_drop) still has the dropped column
(e) reopen consistency — drop preserved across engine restart
Recovery: rides on SidecarKind::SchemaApply per MR-847. No new
sidecar kind needed; the entire apply path is already sidecar-wrapped.
Substrate alignment: this commit uses the stage_overwrite full-rewrite
path (full_rewrite cost class) rather than Lance native drop_columns
(catalog_only cost class). MR-948 is the follow-up substrate-alignment
refactor that introduces a LanceColumnOp surface and switches the
metadata-only case onto drop_columns. Functional outcome is identical;
cost-class improvement deferred.
Test results:
- cargo test -p omnigraph-compiler --lib: 238 passed
- cargo test -p omnigraph-engine --test schema_apply: 11 passed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: move schema-lint-v1-plan into docs/dev/ + add to index
Post-rebase fixup for the docs split (#93). The plan doc was added
to docs/ at the top level before main reorganized to docs/{user,dev}/.
This moves it into docs/dev/ and adds an entry to docs/dev/index.md
under a new "Active Implementation Plans" section so the
check-agents-md.sh link check passes.
Per the original commit message (617a77d), the plan doc is intentionally
temporary — it will be deleted when v1 lands.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:30:03 +03:00
|
|
|
fn schema_lint_tier_label(tier: omnigraph_compiler::SafetyTier) -> &'static str {
|
|
|
|
|
match tier {
|
|
|
|
|
omnigraph_compiler::SafetyTier::Safe => "safe",
|
|
|
|
|
omnigraph_compiler::SafetyTier::Validated => "validated",
|
|
|
|
|
omnigraph_compiler::SafetyTier::Destructive => "destructive",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn drop_mode_label(mode: omnigraph_compiler::DropMode) -> &'static str {
|
|
|
|
|
match mode {
|
|
|
|
|
omnigraph_compiler::DropMode::Soft => "soft",
|
|
|
|
|
omnigraph_compiler::DropMode::Hard => "hard",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
fn render_prop_type(prop_type: &omnigraph_compiler::PropType) -> String {
|
|
|
|
|
let base = if let Some(values) = &prop_type.enum_values {
|
|
|
|
|
format!("Enum({})", values.join("|"))
|
|
|
|
|
} else {
|
|
|
|
|
prop_type.scalar.to_string()
|
|
|
|
|
};
|
|
|
|
|
let base = if prop_type.list {
|
|
|
|
|
format!("[{}]", base)
|
|
|
|
|
} else {
|
|
|
|
|
base
|
|
|
|
|
};
|
|
|
|
|
if prop_type.nullable {
|
|
|
|
|
format!("{}?", base)
|
|
|
|
|
} else {
|
|
|
|
|
base
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_constraint(constraint: &omnigraph_compiler::schema::ast::Constraint) -> String {
|
|
|
|
|
match constraint {
|
|
|
|
|
omnigraph_compiler::schema::ast::Constraint::Key(columns) => {
|
|
|
|
|
format!("@key({})", columns.join(", "))
|
|
|
|
|
}
|
|
|
|
|
omnigraph_compiler::schema::ast::Constraint::Unique(columns) => {
|
|
|
|
|
format!("@unique({})", columns.join(", "))
|
|
|
|
|
}
|
|
|
|
|
omnigraph_compiler::schema::ast::Constraint::Index(columns) => {
|
|
|
|
|
format!("@index({})", columns.join(", "))
|
|
|
|
|
}
|
|
|
|
|
omnigraph_compiler::schema::ast::Constraint::Range { property, min, max } => {
|
|
|
|
|
format!("@range({}, {:?}, {:?})", property, min, max)
|
|
|
|
|
}
|
|
|
|
|
omnigraph_compiler::schema::ast::Constraint::Check { property, pattern } => {
|
|
|
|
|
format!("@check({}, {:?})", property, pattern)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_annotations(annotations: &[omnigraph_compiler::schema::ast::Annotation]) -> String {
|
|
|
|
|
annotations
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|annotation| match &annotation.value {
|
|
|
|
|
Some(value) => format!("@{}({})", annotation.name, value),
|
|
|
|
|
None => format!("@{}", annotation.name),
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_embed_human(output: &EmbedOutput) {
|
|
|
|
|
println!(
|
|
|
|
|
"embedded {} rows (selected {}, cleaned {}) from {} -> {} [{} {}d]",
|
|
|
|
|
output.embedded_rows,
|
|
|
|
|
output.selected_rows,
|
|
|
|
|
output.cleaned_rows,
|
|
|
|
|
output.input,
|
|
|
|
|
output.output,
|
|
|
|
|
output.mode,
|
|
|
|
|
output.dimension
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_snapshot_human(branch: &str, manifest_version: u64, entries: &[SnapshotTableOutput]) {
|
|
|
|
|
println!("branch: {}", branch);
|
|
|
|
|
println!("manifest_version: {}", manifest_version);
|
|
|
|
|
for entry in entries {
|
|
|
|
|
println!(
|
|
|
|
|
"{} v{} branch={} rows={}",
|
|
|
|
|
entry.table_key,
|
|
|
|
|
entry.table_version,
|
|
|
|
|
entry.table_branch.as_deref().unwrap_or("main"),
|
|
|
|
|
entry.row_count
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_read_output(
|
|
|
|
|
output: &ReadOutput,
|
|
|
|
|
format: ReadOutputFormat,
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
println!(
|
|
|
|
|
"{}",
|
|
|
|
|
render_read(
|
|
|
|
|
output,
|
|
|
|
|
format,
|
|
|
|
|
&ReadRenderOptions {
|
|
|
|
|
max_column_width: config.table_max_column_width(),
|
|
|
|
|
cell_layout: config.table_cell_layout(),
|
|
|
|
|
},
|
|
|
|
|
)?
|
|
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_change_human(output: &ChangeOutput) {
|
|
|
|
|
println!(
|
|
|
|
|
"changed {} via {}: {} nodes, {} edges",
|
|
|
|
|
output.branch, output.query_name, output.affected_nodes, output.affected_edges
|
|
|
|
|
);
|
|
|
|
|
if let Some(actor_id) = &output.actor_id {
|
|
|
|
|
println!("actor_id: {}", actor_id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_commit_list_human(commits: &[CommitOutput]) {
|
|
|
|
|
for commit in commits {
|
|
|
|
|
let branch = commit.manifest_branch.as_deref().unwrap_or("main");
|
|
|
|
|
println!(
|
|
|
|
|
"{} branch={} version={}{}",
|
|
|
|
|
commit.graph_commit_id,
|
|
|
|
|
branch,
|
|
|
|
|
commit.manifest_version,
|
|
|
|
|
commit
|
|
|
|
|
.actor_id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|actor| format!(" actor={}", actor))
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_commit_human(commit: &CommitOutput) {
|
|
|
|
|
println!("graph_commit_id: {}", commit.graph_commit_id);
|
|
|
|
|
println!(
|
|
|
|
|
"manifest_branch: {}",
|
|
|
|
|
commit.manifest_branch.as_deref().unwrap_or("main")
|
|
|
|
|
);
|
|
|
|
|
println!("manifest_version: {}", commit.manifest_version);
|
|
|
|
|
if let Some(parent_commit_id) = &commit.parent_commit_id {
|
|
|
|
|
println!("parent_commit_id: {}", parent_commit_id);
|
|
|
|
|
}
|
|
|
|
|
if let Some(merged_parent_commit_id) = &commit.merged_parent_commit_id {
|
|
|
|
|
println!("merged_parent_commit_id: {}", merged_parent_commit_id);
|
|
|
|
|
}
|
|
|
|
|
if let Some(actor_id) = &commit.actor_id {
|
|
|
|
|
println!("actor_id: {}", actor_id);
|
|
|
|
|
}
|
|
|
|
|
println!("created_at: {}", commit.created_at);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 12:00:52 +02:00
|
|
|
fn print_policy_explain(decision: &PolicyDecision, actor_id: &str, request: &PolicyRequest) {
|
2026-04-10 20:49:41 +03:00
|
|
|
println!(
|
|
|
|
|
"decision: {}",
|
|
|
|
|
if decision.allowed { "allow" } else { "deny" }
|
|
|
|
|
);
|
2026-05-27 12:00:52 +02:00
|
|
|
println!("actor: {}", actor_id);
|
2026-04-10 20:49:41 +03:00
|
|
|
println!("action: {}", request.action);
|
|
|
|
|
if let Some(branch) = &request.branch {
|
|
|
|
|
println!("branch: {}", branch);
|
|
|
|
|
}
|
|
|
|
|
if let Some(target_branch) = &request.target_branch {
|
|
|
|
|
println!("target_branch: {}", target_branch);
|
|
|
|
|
}
|
|
|
|
|
if let Some(rule_id) = &decision.matched_rule_id {
|
|
|
|
|
println!("matched_rule: {}", rule_id);
|
|
|
|
|
}
|
|
|
|
|
println!("message: {}", decision.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_read_format(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
cli_format: Option<ReadOutputFormat>,
|
|
|
|
|
json: bool,
|
|
|
|
|
alias_format: Option<ReadOutputFormat>,
|
|
|
|
|
) -> ReadOutputFormat {
|
|
|
|
|
if json {
|
|
|
|
|
ReadOutputFormat::Json
|
|
|
|
|
} else {
|
|
|
|
|
cli_format
|
|
|
|
|
.or(alias_format)
|
|
|
|
|
.unwrap_or_else(|| config.cli_output_format())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_alias<'a>(
|
|
|
|
|
config: &'a OmnigraphConfig,
|
|
|
|
|
alias_name: Option<&'a str>,
|
|
|
|
|
expected: AliasCommand,
|
|
|
|
|
) -> Result<Option<(&'a str, &'a omnigraph_server::AliasConfig)>> {
|
|
|
|
|
let Some(alias_name) = alias_name else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let alias = config.alias(alias_name)?;
|
|
|
|
|
if alias.command != expected {
|
|
|
|
|
bail!(
|
|
|
|
|
"alias '{}' is a {:?} alias, not a {:?} alias",
|
|
|
|
|
alias_name,
|
|
|
|
|
alias.command,
|
|
|
|
|
expected
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(Some((alias_name, alias)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 19:01:48 +03:00
|
|
|
fn normalize_legacy_alias_uri(
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: Option<String>,
|
2026-04-11 19:01:48 +03:00
|
|
|
target_available: bool,
|
2026-04-10 20:49:41 +03:00
|
|
|
alias_name: Option<&str>,
|
|
|
|
|
mut alias_args: Vec<String>,
|
|
|
|
|
) -> (Option<String>, Vec<String>) {
|
|
|
|
|
let Some(candidate) = uri else {
|
|
|
|
|
return (None, alias_args);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-11 19:01:48 +03:00
|
|
|
if alias_name.is_some() && target_available {
|
2026-04-10 20:49:41 +03:00
|
|
|
alias_args.insert(0, candidate);
|
|
|
|
|
return (None, alias_args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(Some(candidate), alias_args)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn scaffold_config_if_missing(uri: &str) -> Result<()> {
|
|
|
|
|
let path = inferred_config_path(uri)?;
|
|
|
|
|
if path.exists() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs::write(
|
|
|
|
|
path,
|
|
|
|
|
format!(
|
|
|
|
|
"\
|
|
|
|
|
project:
|
|
|
|
|
name: Omnigraph Project
|
|
|
|
|
|
2026-04-14 04:12:14 +03:00
|
|
|
graphs:
|
2026-04-10 20:49:41 +03:00
|
|
|
local:
|
|
|
|
|
uri: {}
|
|
|
|
|
# bearer_token_env: OMNIGRAPH_BEARER_TOKEN
|
|
|
|
|
|
|
|
|
|
server:
|
2026-04-14 04:12:14 +03:00
|
|
|
graph: local
|
2026-04-10 20:49:41 +03:00
|
|
|
bind: 127.0.0.1:8080
|
|
|
|
|
|
|
|
|
|
cli:
|
2026-04-14 04:12:14 +03:00
|
|
|
graph: local
|
2026-04-10 20:49:41 +03:00
|
|
|
branch: main
|
|
|
|
|
output_format: table
|
|
|
|
|
table_max_column_width: 80
|
|
|
|
|
table_cell_layout: truncate
|
|
|
|
|
|
|
|
|
|
query:
|
|
|
|
|
roots:
|
|
|
|
|
- queries
|
|
|
|
|
- .
|
|
|
|
|
|
|
|
|
|
aliases:
|
|
|
|
|
# owner:
|
|
|
|
|
# command: read
|
|
|
|
|
# query: context.gq
|
|
|
|
|
# name: decision_owner
|
|
|
|
|
# args: [slug]
|
2026-04-14 04:12:14 +03:00
|
|
|
# graph: local
|
2026-04-10 20:49:41 +03:00
|
|
|
# branch: main
|
|
|
|
|
# format: kv
|
|
|
|
|
#
|
|
|
|
|
# attach_trace:
|
|
|
|
|
# command: change
|
|
|
|
|
# query: mutations.gq
|
|
|
|
|
# name: attach_trace
|
|
|
|
|
# args: [decision_slug, trace_slug]
|
2026-04-14 04:12:14 +03:00
|
|
|
# graph: local
|
2026-04-10 20:49:41 +03:00
|
|
|
# branch: main
|
|
|
|
|
|
|
|
|
|
# auth:
|
|
|
|
|
# env_file: ./.env.omni
|
|
|
|
|
#
|
|
|
|
|
# policy:
|
|
|
|
|
# file: ./policy.yaml
|
|
|
|
|
",
|
|
|
|
|
yaml_string(uri),
|
|
|
|
|
),
|
|
|
|
|
)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn yaml_string(value: &str) -> String {
|
|
|
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inferred_config_path(uri: &str) -> Result<PathBuf> {
|
|
|
|
|
if uri.contains("://") {
|
|
|
|
|
return Ok(omnigraph_server::config::default_config_path());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path = Path::new(uri);
|
|
|
|
|
let base = if path.is_absolute() {
|
|
|
|
|
path.parent()
|
|
|
|
|
.map(Path::to_path_buf)
|
|
|
|
|
.unwrap_or(std::env::current_dir()?)
|
|
|
|
|
} else {
|
|
|
|
|
std::env::current_dir()?.join(path.parent().unwrap_or_else(|| Path::new(".")))
|
|
|
|
|
};
|
|
|
|
|
Ok(base.join(omnigraph_server::config::DEFAULT_CONFIG_FILE))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_target_from_cli(branch: Option<String>, snapshot: Option<String>) -> ReadTarget {
|
|
|
|
|
if let Some(snapshot) = snapshot {
|
|
|
|
|
ReadTarget::snapshot(SnapshotId::new(snapshot))
|
|
|
|
|
} else {
|
|
|
|
|
ReadTarget::branch(branch.unwrap_or_else(|| "main".to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_params_json(params: &ParamsArgs) -> Result<Option<Value>> {
|
|
|
|
|
match (¶ms.params, ¶ms.params_file) {
|
|
|
|
|
(Some(inline), None) => Ok(Some(serde_json::from_str(inline)?)),
|
|
|
|
|
(None, Some(path)) => Ok(Some(serde_json::from_str(&fs::read_to_string(path)?)?)),
|
|
|
|
|
(None, None) => Ok(None),
|
|
|
|
|
(Some(_), Some(_)) => bail!("only one of --params or --params-file may be provided"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn select_named_query(
|
|
|
|
|
query_source: &str,
|
|
|
|
|
requested_name: Option<&str>,
|
|
|
|
|
) -> Result<(String, Vec<omnigraph_compiler::query::ast::Param>)> {
|
|
|
|
|
let parsed = parse_query(query_source)?;
|
|
|
|
|
let query = if let Some(name) = requested_name {
|
|
|
|
|
parsed
|
|
|
|
|
.queries
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|query| query.name == name)
|
|
|
|
|
.ok_or_else(|| color_eyre::eyre::eyre!("query '{}' not found", name))?
|
|
|
|
|
} else if parsed.queries.len() == 1 {
|
|
|
|
|
parsed.queries.into_iter().next().unwrap()
|
|
|
|
|
} else {
|
|
|
|
|
bail!("query file contains multiple queries; pass --name");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok((query.name, query.params))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn query_params_from_json(
|
|
|
|
|
query_params: &[omnigraph_compiler::query::ast::Param],
|
|
|
|
|
params_json: Option<&Value>,
|
|
|
|
|
) -> Result<ParamMap> {
|
|
|
|
|
json_params_to_param_map(params_json, query_params, JsonParamMode::Standard)
|
|
|
|
|
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 00:37:44 +03:00
|
|
|
async fn execute_query_lint(
|
|
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
cli_uri: Option<String>,
|
|
|
|
|
cli_target: Option<&str>,
|
|
|
|
|
schema_path: Option<&PathBuf>,
|
|
|
|
|
query_path: &PathBuf,
|
|
|
|
|
) -> Result<QueryLintOutput> {
|
|
|
|
|
let resolved_query_path = resolve_query_path(config, Some(query_path), None)?;
|
|
|
|
|
let query_source = fs::read_to_string(&resolved_query_path)?;
|
|
|
|
|
let query_path = resolved_query_path.to_string_lossy().into_owned();
|
|
|
|
|
|
|
|
|
|
if let Some(schema_path) = schema_path {
|
|
|
|
|
let schema_source = fs::read_to_string(schema_path)?;
|
|
|
|
|
let schema =
|
|
|
|
|
parse_schema(&schema_source).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
|
|
|
|
let catalog =
|
|
|
|
|
build_catalog(&schema).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
|
|
|
|
return Ok(lint_query_file(
|
|
|
|
|
&catalog,
|
|
|
|
|
&query_source,
|
|
|
|
|
query_path,
|
|
|
|
|
QueryLintSchemaSource::file(schema_path.to_string_lossy().into_owned()),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
let has_graph_target =
|
2026-04-14 04:12:14 +03:00
|
|
|
cli_uri.is_some() || cli_target.is_some() || config.cli_graph_name().is_some();
|
2026-05-24 16:46:00 +01:00
|
|
|
if !has_graph_target {
|
|
|
|
|
bail!("query lint requires --schema <schema.pg> or a resolvable graph target");
|
2026-04-13 00:37:44 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let uri = resolve_local_uri(config, cli_uri, cli_target, "query lint")?;
|
|
|
|
|
let db = Omnigraph::open(&uri).await?;
|
|
|
|
|
Ok(lint_query_file(
|
mr-686: bundle PR 0/1a/1b foundation + PR 2 catalog/schema_source ArcSwap
Bundles the working-tree state from the prior session (PR 0 bench harness,
PR 1a audit_actor_id removal, PR 1b WriteQueueManager + writer integration)
together with the first half of PR 2's interior-mutability foundation
(catalog and schema_source wrapped in Arc<ArcSwap<...>>). The two streams
intermix in 7 of the same files, so splitting via git add -p was
impractical. Subsequent PR 2 steps land as separate atomic commits.
PR 0 — server-level concurrent /change bench harness
- crates/omnigraph-server/examples/bench_concurrent_http.rs (new)
- .context/bench-results/{baseline-main,after-pr1}/ (gitignored)
PR 1a — drop the audit_actor_id field, thread per-call
- removed Omnigraph::audit_actor_id and the swap-restore patterns in
mutation.rs, merge.rs, loader/mod.rs
- actor_id: Option<&str> threaded through MutationStaging::finalize,
mutate_with_current_actor, ingest_with_current_actor,
branch_merge_impl, branch_merge_on_current_target,
commit_prepared_updates*, record_merge_commit,
commit_updates_on_branch_with_expected
- apply_schema and ensure_indices_for_branch pass None (system-attributed)
PR 1b — per-(table_key, branch) write queue + revalidation + sidecar
- new crates/omnigraph/src/db/write_queue.rs with WriteQueueManager,
acquire/acquire_many, sorted+deduped acquisition; 6 unit tests
- Arc<WriteQueueManager> field on Omnigraph + db.write_queue() accessor
- MutationStaging::finalize split into stage_all (Phase A, no queue)
and StagedMutation::commit_all (Phase B, acquire_many + revalidate
pins + sidecar + commit_staged); guards held across publisher
- delete-only mutations now emit recovery sidecars; revalidation
extended to inline_committed tables
- branch_merge_on_current_target, apply_schema_with_lock, and
ensure_indices_for_branch acquire per-table queues for their
touched tables
PR 2 Step B (partial) — catalog and schema_source via ArcSwap
- catalog: Catalog -> Arc<ArcSwap<Catalog>>
- schema_source: String -> Arc<ArcSwap<String>>
- public accessors return Arc<Catalog> / Arc<String>; readers bind
locally where the borrow has to outlive an expression
- new pub(crate) store_catalog / store_schema_source helpers replace
the field assignments in apply_schema and reload_schema_if_source_changed
- 117 tests across lifecycle/end_to_end/branching/runs pass; engine
lib + workspace compile clean
Coordinator wrap (Mutex) and the &mut self -> &self engine API
conversion follow in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:22:38 +02:00
|
|
|
&db.catalog(),
|
2026-04-13 00:37:44 +03:00
|
|
|
&query_source,
|
|
|
|
|
query_path,
|
2026-05-24 16:46:00 +01:00
|
|
|
QueryLintSchemaSource::graph(uri),
|
2026-04-13 00:37:44 +03:00
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
async fn execute_read(
|
|
|
|
|
uri: &str,
|
|
|
|
|
query_source: &str,
|
|
|
|
|
query_name: Option<&str>,
|
|
|
|
|
target: ReadTarget,
|
|
|
|
|
params_json: Option<&Value>,
|
|
|
|
|
) -> Result<ReadOutput> {
|
|
|
|
|
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
|
|
|
|
|
let params = query_params_from_json(&query_params, params_json)?;
|
|
|
|
|
let db = Omnigraph::open(uri).await?;
|
|
|
|
|
let result = db
|
|
|
|
|
.query(target.clone(), query_source, &selected_name, ¶ms)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(read_output(selected_name, &target, result))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn execute_read_remote(
|
|
|
|
|
client: &reqwest::Client,
|
|
|
|
|
uri: &str,
|
|
|
|
|
query_source: &str,
|
|
|
|
|
query_name: Option<&str>,
|
|
|
|
|
target: ReadTarget,
|
|
|
|
|
params_json: Option<&Value>,
|
|
|
|
|
bearer_token: Option<&str>,
|
|
|
|
|
) -> Result<ReadOutput> {
|
|
|
|
|
let (branch, snapshot) = match &target {
|
|
|
|
|
ReadTarget::Branch(branch) => (Some(branch.clone()), None),
|
|
|
|
|
ReadTarget::Snapshot(snapshot) => (None, Some(snapshot.as_str().to_string())),
|
|
|
|
|
};
|
|
|
|
|
remote_json(
|
|
|
|
|
client,
|
|
|
|
|
Method::POST,
|
|
|
|
|
remote_url(uri, "/read"),
|
|
|
|
|
Some(serde_json::to_value(ReadRequest {
|
|
|
|
|
query_source: query_source.to_string(),
|
|
|
|
|
query_name: query_name.map(ToOwned::to_owned),
|
|
|
|
|
params: params_json.cloned(),
|
|
|
|
|
branch,
|
|
|
|
|
snapshot,
|
|
|
|
|
})?),
|
|
|
|
|
bearer_token,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn execute_change(
|
|
|
|
|
uri: &str,
|
|
|
|
|
query_source: &str,
|
|
|
|
|
query_name: Option<&str>,
|
|
|
|
|
branch: &str,
|
|
|
|
|
params_json: Option<&Value>,
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
config: &OmnigraphConfig,
|
|
|
|
|
cli_as_actor: Option<&str>,
|
2026-04-10 20:49:41 +03:00
|
|
|
) -> Result<ChangeOutput> {
|
|
|
|
|
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
|
|
|
|
|
let params = query_params_from_json(&query_params, params_json)?;
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = open_local_db_with_policy(uri, config).await?;
|
|
|
|
|
let actor = resolve_cli_actor(cli_as_actor, config);
|
2026-04-10 20:49:41 +03:00
|
|
|
let result = db
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
.mutate_as(branch, query_source, &selected_name, ¶ms, actor)
|
2026-04-10 20:49:41 +03:00
|
|
|
.await?;
|
|
|
|
|
Ok(ChangeOutput {
|
|
|
|
|
branch: branch.to_string(),
|
|
|
|
|
query_name: selected_name,
|
|
|
|
|
affected_nodes: result.affected_nodes,
|
|
|
|
|
affected_edges: result.affected_edges,
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
actor_id: actor.map(String::from),
|
2026-04-10 20:49:41 +03:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn execute_change_remote(
|
|
|
|
|
client: &reqwest::Client,
|
|
|
|
|
uri: &str,
|
|
|
|
|
query_source: &str,
|
|
|
|
|
query_name: Option<&str>,
|
|
|
|
|
branch: &str,
|
|
|
|
|
params_json: Option<&Value>,
|
|
|
|
|
bearer_token: Option<&str>,
|
|
|
|
|
) -> Result<ChangeOutput> {
|
|
|
|
|
remote_json(
|
|
|
|
|
client,
|
|
|
|
|
Method::POST,
|
|
|
|
|
remote_url(uri, "/change"),
|
|
|
|
|
Some(serde_json::to_value(ChangeRequest {
|
|
|
|
|
query_source: query_source.to_string(),
|
|
|
|
|
query_name: query_name.map(ToOwned::to_owned),
|
|
|
|
|
params: params_json.cloned(),
|
|
|
|
|
branch: Some(branch.to_string()),
|
|
|
|
|
})?),
|
|
|
|
|
bearer_token,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 19:01:48 +03:00
|
|
|
async fn execute_export_to_writer<W: Write>(
|
2026-04-10 20:49:41 +03:00
|
|
|
uri: &str,
|
|
|
|
|
branch: &str,
|
|
|
|
|
type_names: &[String],
|
|
|
|
|
table_keys: &[String],
|
2026-04-11 19:01:48 +03:00
|
|
|
writer: &mut W,
|
|
|
|
|
) -> Result<()> {
|
2026-04-10 20:49:41 +03:00
|
|
|
let db = Omnigraph::open(uri).await?;
|
2026-04-11 19:01:48 +03:00
|
|
|
db.export_jsonl_to_writer(branch, type_names, table_keys, writer)
|
|
|
|
|
.await?;
|
|
|
|
|
writer.flush()?;
|
|
|
|
|
Ok(())
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 19:01:48 +03:00
|
|
|
async fn execute_export_remote_to_writer<W: Write>(
|
2026-04-10 20:49:41 +03:00
|
|
|
client: &reqwest::Client,
|
|
|
|
|
uri: &str,
|
|
|
|
|
branch: &str,
|
|
|
|
|
type_names: &[String],
|
|
|
|
|
table_keys: &[String],
|
|
|
|
|
bearer_token: Option<&str>,
|
2026-04-11 19:01:48 +03:00
|
|
|
writer: &mut W,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let request = apply_bearer_token(
|
|
|
|
|
client.request(Method::POST, remote_url(uri, "/export")),
|
2026-04-10 20:49:41 +03:00
|
|
|
bearer_token,
|
|
|
|
|
)
|
2026-04-11 19:01:48 +03:00
|
|
|
.json(&ExportRequest {
|
|
|
|
|
branch: Some(branch.to_string()),
|
|
|
|
|
type_names: type_names.to_vec(),
|
|
|
|
|
table_keys: table_keys.to_vec(),
|
|
|
|
|
});
|
|
|
|
|
let mut response = request.send().await?;
|
|
|
|
|
let status = response.status();
|
|
|
|
|
if !status.is_success() {
|
|
|
|
|
let text = response.text().await?;
|
|
|
|
|
if let Ok(error) = serde_json::from_str::<ErrorOutput>(&text) {
|
|
|
|
|
bail!(error.error);
|
|
|
|
|
}
|
|
|
|
|
bail!("server returned {}: {}", status, text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while let Some(chunk) = response.chunk().await? {
|
|
|
|
|
writer.write_all(&chunk)?;
|
|
|
|
|
}
|
|
|
|
|
writer.flush()?;
|
|
|
|
|
Ok(())
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::main]
|
|
|
|
|
async fn main() -> Result<()> {
|
|
|
|
|
color_eyre::install()?;
|
|
|
|
|
let cli = {
|
|
|
|
|
let matches = Cli::command()
|
|
|
|
|
.arg(
|
|
|
|
|
Arg::new("version")
|
|
|
|
|
.short('v')
|
|
|
|
|
.long("version")
|
|
|
|
|
.action(ArgAction::Version)
|
|
|
|
|
.help("Print version"),
|
|
|
|
|
)
|
|
|
|
|
.get_matches();
|
|
|
|
|
Cli::from_arg_matches(&matches)?
|
|
|
|
|
};
|
|
|
|
|
let http_client = build_http_client()?;
|
|
|
|
|
match cli.command {
|
|
|
|
|
Command::Version => {
|
|
|
|
|
println!("omnigraph {}", env!("CARGO_PKG_VERSION"));
|
|
|
|
|
}
|
|
|
|
|
Command::Embed(args) => {
|
|
|
|
|
let output = execute_embed(&args).await?;
|
|
|
|
|
if args.json {
|
|
|
|
|
print_json(&output)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_embed_human(&output);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-27 13:24:49 +02:00
|
|
|
Command::Init { schema, uri, force } => {
|
2026-04-10 20:49:41 +03:00
|
|
|
let schema_source = fs::read_to_string(&schema)?;
|
2026-05-24 16:46:00 +01:00
|
|
|
ensure_local_graph_parent(&uri)?;
|
2026-05-27 13:24:49 +02:00
|
|
|
Omnigraph::init_with_options(
|
|
|
|
|
&uri,
|
|
|
|
|
&schema_source,
|
|
|
|
|
omnigraph::db::InitOptions { force },
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2026-04-10 20:49:41 +03:00
|
|
|
scaffold_config_if_missing(&uri)?;
|
|
|
|
|
println!("initialized {}", uri);
|
|
|
|
|
}
|
|
|
|
|
Command::Load {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
data,
|
|
|
|
|
branch,
|
|
|
|
|
mode,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let uri = resolve_local_uri(&config, uri, target.as_deref(), "load")?;
|
|
|
|
|
let branch = resolve_branch(&config, branch, None, "main");
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = open_local_db_with_policy(&uri, &config).await?;
|
|
|
|
|
let actor = resolve_cli_actor(cli.as_actor.as_deref(), &config);
|
2026-04-10 20:49:41 +03:00
|
|
|
let result = db
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
.load_file_as(&branch, &data.to_string_lossy(), mode.into(), actor)
|
2026-04-10 20:49:41 +03:00
|
|
|
.await?;
|
|
|
|
|
let payload = LoadOutput {
|
|
|
|
|
uri: &uri,
|
|
|
|
|
branch: &branch,
|
|
|
|
|
mode: mode.as_str(),
|
2026-04-11 19:01:48 +03:00
|
|
|
nodes_loaded: result.nodes_loaded.values().sum(),
|
|
|
|
|
edges_loaded: result.edges_loaded.values().sum(),
|
|
|
|
|
node_types_loaded: result.nodes_loaded.len(),
|
|
|
|
|
edge_types_loaded: result.edges_loaded.len(),
|
2026-04-10 20:49:41 +03:00
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_load_human(
|
|
|
|
|
&uri,
|
|
|
|
|
&branch,
|
|
|
|
|
mode,
|
|
|
|
|
payload.nodes_loaded,
|
|
|
|
|
payload.edges_loaded,
|
2026-04-11 19:01:48 +03:00
|
|
|
payload.node_types_loaded,
|
|
|
|
|
payload.edge_types_loaded,
|
2026-04-10 20:49:41 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Command::Ingest {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
data,
|
|
|
|
|
branch,
|
|
|
|
|
from,
|
|
|
|
|
mode,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let branch = resolve_branch(&config, branch, None, "main");
|
|
|
|
|
let from = resolve_branch(&config, from, None, "main");
|
|
|
|
|
let payload = if is_remote_uri(&uri) {
|
|
|
|
|
let data = fs::read_to_string(&data)?;
|
|
|
|
|
remote_json::<IngestOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::POST,
|
|
|
|
|
remote_url(&uri, "/ingest"),
|
|
|
|
|
Some(serde_json::to_value(IngestRequest {
|
|
|
|
|
branch: Some(branch.clone()),
|
|
|
|
|
from: Some(from.clone()),
|
|
|
|
|
mode: Some(mode.into()),
|
|
|
|
|
data,
|
|
|
|
|
})?),
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = open_local_db_with_policy(&uri, &config).await?;
|
|
|
|
|
let actor = resolve_cli_actor(cli.as_actor.as_deref(), &config);
|
2026-04-10 20:49:41 +03:00
|
|
|
let result = db
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
.ingest_file_as(
|
|
|
|
|
&branch,
|
|
|
|
|
Some(&from),
|
|
|
|
|
&data.to_string_lossy(),
|
|
|
|
|
mode.into(),
|
|
|
|
|
actor,
|
|
|
|
|
)
|
2026-04-10 20:49:41 +03:00
|
|
|
.await?;
|
|
|
|
|
ingest_output(&uri, &result, None)
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_ingest_human(&payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Command::Branch { command } => match command {
|
|
|
|
|
BranchCommand::Create {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
from,
|
|
|
|
|
name,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let from = resolve_branch(&config, from, None, "main");
|
|
|
|
|
let payload = if is_remote_uri(&uri) {
|
|
|
|
|
remote_json::<BranchCreateOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::POST,
|
|
|
|
|
remote_url(&uri, "/branches"),
|
|
|
|
|
Some(serde_json::to_value(BranchCreateRequest {
|
|
|
|
|
from: Some(from.clone()),
|
|
|
|
|
name: name.clone(),
|
|
|
|
|
})?),
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = open_local_db_with_policy(&uri, &config).await?;
|
|
|
|
|
let actor = resolve_cli_actor(cli.as_actor.as_deref(), &config);
|
|
|
|
|
db.branch_create_from_as(ReadTarget::branch(&from), &name, actor)
|
2026-04-10 20:49:41 +03:00
|
|
|
.await?;
|
|
|
|
|
BranchCreateOutput {
|
|
|
|
|
uri: uri.clone(),
|
|
|
|
|
from: from.clone(),
|
|
|
|
|
name: name.clone(),
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
actor_id: actor.map(String::from),
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
println!("created branch {} from {}", payload.name, payload.from);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
BranchCommand::List {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let payload = if is_remote_uri(&uri) {
|
|
|
|
|
remote_json::<BranchListOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::GET,
|
|
|
|
|
remote_url(&uri, "/branches"),
|
|
|
|
|
None,
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
|
|
|
|
let db = Omnigraph::open(&uri).await?;
|
|
|
|
|
let mut branches = db.branch_list().await?;
|
|
|
|
|
branches.sort();
|
|
|
|
|
BranchListOutput { branches }
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
for branch in payload.branches {
|
|
|
|
|
println!("{}", branch);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
BranchCommand::Delete {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
name,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let payload = if is_remote_uri(&uri) {
|
|
|
|
|
remote_json::<BranchDeleteOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::DELETE,
|
|
|
|
|
remote_branch_url(&uri, &name)?,
|
|
|
|
|
None,
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = open_local_db_with_policy(&uri, &config).await?;
|
|
|
|
|
let actor = resolve_cli_actor(cli.as_actor.as_deref(), &config);
|
|
|
|
|
db.branch_delete_as(&name, actor).await?;
|
2026-04-10 20:49:41 +03:00
|
|
|
BranchDeleteOutput {
|
|
|
|
|
uri: uri.clone(),
|
|
|
|
|
name: name.clone(),
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
actor_id: actor.map(String::from),
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
println!("deleted branch {}", payload.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
BranchCommand::Merge {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
source,
|
|
|
|
|
into,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let into = resolve_branch(&config, into, None, "main");
|
|
|
|
|
let payload = if is_remote_uri(&uri) {
|
|
|
|
|
remote_json::<BranchMergeOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::POST,
|
|
|
|
|
remote_url(&uri, "/branches/merge"),
|
|
|
|
|
Some(serde_json::to_value(BranchMergeRequest {
|
|
|
|
|
source: source.clone(),
|
|
|
|
|
target: Some(into.clone()),
|
|
|
|
|
})?),
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = open_local_db_with_policy(&uri, &config).await?;
|
|
|
|
|
let actor = resolve_cli_actor(cli.as_actor.as_deref(), &config);
|
|
|
|
|
let outcome = db.branch_merge_as(&source, &into, actor).await?;
|
2026-04-10 20:49:41 +03:00
|
|
|
BranchMergeOutput {
|
|
|
|
|
source: source.clone(),
|
|
|
|
|
target: into.clone(),
|
|
|
|
|
outcome: outcome.into(),
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
actor_id: actor.map(String::from),
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
println!(
|
|
|
|
|
"merged {} into {}: {}",
|
|
|
|
|
payload.source,
|
|
|
|
|
payload.target,
|
|
|
|
|
payload.outcome.as_str()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Command::Commit { command } => match command {
|
|
|
|
|
CommitCommand::List {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
branch,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let commits = if is_remote_uri(&uri) {
|
|
|
|
|
remote_json::<CommitListOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::GET,
|
|
|
|
|
if let Some(branch) = branch.as_deref() {
|
|
|
|
|
format!("{}?branch={}", remote_url(&uri, "/commits"), branch)
|
|
|
|
|
} else {
|
|
|
|
|
remote_url(&uri, "/commits")
|
|
|
|
|
},
|
|
|
|
|
None,
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
.commits
|
|
|
|
|
} else {
|
|
|
|
|
let db = Omnigraph::open(&uri).await?;
|
|
|
|
|
db.list_commits(branch.as_deref())
|
|
|
|
|
.await?
|
|
|
|
|
.iter()
|
|
|
|
|
.map(commit_output)
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&CommitListOutput { commits })?;
|
|
|
|
|
} else {
|
|
|
|
|
print_commit_list_human(&commits);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
CommitCommand::Show {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
commit_id,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let commit = if is_remote_uri(&uri) {
|
|
|
|
|
remote_json::<CommitOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::GET,
|
|
|
|
|
remote_url(&uri, &format!("/commits/{}", commit_id)),
|
|
|
|
|
None,
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
|
|
|
|
let db = Omnigraph::open(&uri).await?;
|
|
|
|
|
commit_output(&db.get_commit(&commit_id).await?)
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&commit)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_commit_human(&commit);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Command::Schema { command } => match command {
|
|
|
|
|
SchemaCommand::Plan {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
schema,
|
|
|
|
|
json,
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
allow_data_loss,
|
2026-04-10 20:49:41 +03:00
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let uri = resolve_local_uri(&config, uri, target.as_deref(), "schema plan")?;
|
|
|
|
|
let schema_source = fs::read_to_string(&schema)?;
|
|
|
|
|
let db = Omnigraph::open(&uri).await?;
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
let plan = db
|
|
|
|
|
.plan_schema_with_options(
|
|
|
|
|
&schema_source,
|
|
|
|
|
omnigraph::db::SchemaApplyOptions { allow_data_loss },
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2026-04-10 20:49:41 +03:00
|
|
|
let output = SchemaPlanOutput {
|
|
|
|
|
uri: &uri,
|
|
|
|
|
supported: plan.supported,
|
|
|
|
|
step_count: plan.steps.len(),
|
|
|
|
|
steps: &plan.steps,
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&output)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_schema_plan_human(&uri, &plan);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-12 04:01:14 +03:00
|
|
|
SchemaCommand::Apply {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
schema,
|
|
|
|
|
json,
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
allow_data_loss,
|
2026-04-12 04:01:14 +03:00
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let schema_source = fs::read_to_string(&schema)?;
|
|
|
|
|
let output = if is_remote_uri(&uri) {
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
// MR-694 PR B: SchemaApplyRequest gained an
|
|
|
|
|
// allow_data_loss field so Hard-mode drops are no
|
|
|
|
|
// longer CLI-only. The previous bail is gone; the
|
|
|
|
|
// field is forwarded into the JSON payload, and
|
|
|
|
|
// the server's `server_schema_apply` honors it.
|
2026-04-12 04:01:14 +03:00
|
|
|
remote_json::<SchemaApplyOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::POST,
|
|
|
|
|
remote_url(&uri, "/schema/apply"),
|
|
|
|
|
Some(serde_json::to_value(SchemaApplyRequest {
|
|
|
|
|
schema_source: schema_source.clone(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
allow_data_loss,
|
2026-04-12 04:01:14 +03:00
|
|
|
})?),
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = open_local_db_with_policy(&uri, &config).await?;
|
|
|
|
|
let actor = resolve_cli_actor(cli.as_actor.as_deref(), &config);
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
let result = db
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
.apply_schema_as(
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
&schema_source,
|
|
|
|
|
omnigraph::db::SchemaApplyOptions { allow_data_loss },
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
actor,
|
schema-lint chassis v1.2: --allow-data-loss flag + Hard mode (MR-694) — completes v1 (#100)
* schema-lint v1 commit 5: --allow-data-loss flag + Hard mode
Final v1 commit. Wires up the --allow-data-loss CLI flag and Hard
mode for both DropProperty and DropType. Per
docs/dev/schema-lint-v1-plan.md, commit #5 of the schema-lint
chassis v1 series (MR-694).
CLI (omnigraph-cli/src/main.rs):
- New --allow-data-loss flag on both `omnigraph schema plan` and
`omnigraph schema apply` subcommands. Off by default (Soft).
- HTTP remote schema apply explicitly rejects the flag for now
(CLI-only; HTTP parity is a separate small follow-up that adds
the field to SchemaApplyRequest + the server handler).
Engine (omnigraph.rs + schema_apply.rs):
- New SchemaApplyOptions { allow_data_loss: bool } public struct
(Default = all false), re-exported via omnigraph::db::SchemaApplyOptions.
- New public methods: plan_schema_with_options and
apply_schema_with_options. Existing plan_schema/apply_schema are
now thin wrappers that pass Default::default().
- promote_drops_to_hard: post-plan walk that promotes every
DropMode::Soft step to DropMode::Hard when the flag is set.
Keeps the compiler's plan_schema_migration signature unchanged
(no breaking change for tests / callers).
- Apply path: both Drop arms accept Hard mode; behavior is
identical to Soft inside the apply loop. The DIFFERENCE is the
new hard_cleanup_targets: Vec<(String, String)> accumulator,
populated for every Hard variant with (table_key, full_dataset_uri).
- Post-publish cleanup: a new loop after the manifest commit
iterates hard_cleanup_targets and calls cleanup_old_versions
(before_timestamp = now) on each dataset URI. Best-effort —
the apply is already durable; cleanup failure is logged via
tracing::warn rather than failing the apply.
- New cleanup_dataset_old_versions helper inlines the Lance
cleanup_old_versions call against a dataset URI.
Behavioral details:
- DropProperty Hard: stage_overwrite produced a new dataset version
without the column. cleanup_old_versions removes the prior version
(and reclaims unique fragments). After Hard apply,
snapshot_at_version(pre_drop).open(table_key) FAILS because the
prior dataset version was reclaimed.
- DropType Hard: no per-table write happens (the change is the
manifest tombstone). cleanup_old_versions on the orphan dataset
is a no-op in the immediate term (no prior versions to clean
since the dataset wasn't modified by this apply). The dataset
directory persists. Full orphan-cleanup is a documented
follow-up — the user-facing contract is "data is unreachable
via omnigraph" (manifest entry tombstoned), which is satisfied.
Tests (tests/schema_apply.rs):
- apply_schema_with_allow_data_loss_promotes_drops_to_hard:
default plan emits Soft; with options.allow_data_loss=true,
plan emits Hard; apply succeeds.
- apply_schema_hard_drops_property_makes_prior_version_unreachable:
Hard drop succeeds, current snapshot lacks the column, and
snapshot_at_version(pre_drop).open("node:Person") FAILS (Lance
prior version reclaimed by cleanup).
- apply_schema_hard_drops_node_and_edge_with_flag_succeeds: both
Node and Edge DropType variants are promoted to Hard with the
flag; apply succeeds; current manifest entries gone. (Orphan
dataset directory cleanup deferred.)
Test results:
- cargo test -p omnigraph-compiler --lib: 239 passed
- cargo test -p omnigraph-engine --test schema_apply: 14 passed
(3 new Hard tests + 11 existing soft/regression tests)
- cargo test -p omnigraph-server --test openapi: 60 passed (no
HTTP API surface changes in this commit; OpenAPI parity follow-up
noted)
v1 status: complete for CLI/embedded use. MR-694 chassis epic +
MR-700 DropType/DropProperty ticket can close after this lands.
Known follow-ups (separate small PRs):
- HTTP parity: extend SchemaApplyRequest with allow_data_loss field,
thread through server handler, regenerate openapi.json.
- Orphan-dataset directory deletion for DropType Hard (currently
the dataset directory persists; cleanup_old_versions doesn't
remove it because the dataset wasn't modified).
- MR-948 substrate alignment: swap DropProperty Soft from
stage_overwrite to Dataset::drop_columns (catalog_only vs
full_rewrite cost class).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup: use bail! from color_eyre::eyre instead of anyhow
The remote-rejection branch in SchemaCommand::Apply used
anyhow::anyhow! which isn't in scope; the CLI's Result type is
color_eyre::eyre::Result and bail! is already imported.
Caught by CI Test Workspace job on PR #100.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:12:46 +03:00
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
schema_apply_output(&uri, result)
|
2026-04-12 04:01:14 +03:00
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&output)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_schema_apply_human(&output);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-18 00:30:46 +03:00
|
|
|
SchemaCommand::Show {
|
2026-04-16 21:15:17 +00:00
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let output = if is_remote_uri(&uri) {
|
2026-04-18 00:30:46 +03:00
|
|
|
remote_json::<SchemaOutput>(
|
2026-04-16 21:15:17 +00:00
|
|
|
&http_client,
|
|
|
|
|
Method::GET,
|
|
|
|
|
remote_url(&uri, "/schema"),
|
|
|
|
|
None,
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
|
|
|
|
let db = Omnigraph::open(&uri).await?;
|
2026-04-18 00:30:46 +03:00
|
|
|
SchemaOutput {
|
|
|
|
|
schema_source: db.schema_source().to_string(),
|
2026-04-16 21:15:17 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&output)?;
|
|
|
|
|
} else {
|
2026-04-18 00:30:46 +03:00
|
|
|
println!("{}", output.schema_source);
|
2026-04-16 21:15:17 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 20:49:41 +03:00
|
|
|
},
|
2026-04-13 00:37:44 +03:00
|
|
|
Command::Query { command } => match command {
|
|
|
|
|
QueryCommand::Lint {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
query,
|
|
|
|
|
schema,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let output =
|
|
|
|
|
execute_query_lint(&config, uri, target.as_deref(), schema.as_ref(), &query)
|
|
|
|
|
.await?;
|
|
|
|
|
finish_query_lint(&output, json)?;
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-10 20:49:41 +03:00
|
|
|
Command::Snapshot {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
branch,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let branch = resolve_branch(&config, branch, None, "main");
|
|
|
|
|
let payload = if is_remote_uri(&uri) {
|
|
|
|
|
remote_json::<SnapshotOutput>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::GET,
|
|
|
|
|
format!("{}?branch={}", remote_url(&uri, "/snapshot"), branch),
|
|
|
|
|
None,
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
|
|
|
|
let db = Omnigraph::open(&uri).await?;
|
|
|
|
|
let snapshot = db.snapshot_of(ReadTarget::branch(branch.as_str())).await?;
|
|
|
|
|
snapshot_payload(&branch, &snapshot)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_snapshot_human(&payload.branch, payload.manifest_version, &payload.tables);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Command::Export {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
branch,
|
2026-04-11 19:01:48 +03:00
|
|
|
jsonl,
|
2026-04-10 20:49:41 +03:00
|
|
|
type_names,
|
|
|
|
|
table_keys,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
let branch = resolve_branch(&config, branch, None, "main");
|
2026-04-11 19:01:48 +03:00
|
|
|
if jsonl {
|
|
|
|
|
eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stdout = io::stdout();
|
|
|
|
|
let mut stdout = stdout.lock();
|
|
|
|
|
if is_remote_uri(&uri) {
|
|
|
|
|
execute_export_remote_to_writer(
|
2026-04-10 20:49:41 +03:00
|
|
|
&http_client,
|
|
|
|
|
&uri,
|
|
|
|
|
&branch,
|
|
|
|
|
&type_names,
|
|
|
|
|
&table_keys,
|
|
|
|
|
bearer_token.as_deref(),
|
2026-04-11 19:01:48 +03:00
|
|
|
&mut stdout,
|
2026-04-10 20:49:41 +03:00
|
|
|
)
|
2026-04-11 19:01:48 +03:00
|
|
|
.await?;
|
2026-04-10 20:49:41 +03:00
|
|
|
} else {
|
2026-04-11 19:01:48 +03:00
|
|
|
execute_export_to_writer(&uri, &branch, &type_names, &table_keys, &mut stdout)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
Command::Read {
|
|
|
|
|
uri,
|
2026-04-11 19:01:48 +03:00
|
|
|
legacy_uri,
|
2026-04-10 20:49:41 +03:00
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
alias,
|
|
|
|
|
query,
|
|
|
|
|
name,
|
|
|
|
|
params,
|
|
|
|
|
branch,
|
|
|
|
|
snapshot,
|
|
|
|
|
format,
|
|
|
|
|
json,
|
|
|
|
|
alias_args,
|
|
|
|
|
} => {
|
|
|
|
|
if alias.is_some() == query.is_some() {
|
|
|
|
|
bail!("exactly one of --alias or --query must be provided");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let alias = resolve_alias(&config, alias.as_deref(), AliasCommand::Read)?;
|
|
|
|
|
let alias_name = alias.as_ref().map(|(name, _)| *name);
|
|
|
|
|
let alias_config = alias.as_ref().map(|(_, alias)| *alias);
|
2026-04-11 19:01:48 +03:00
|
|
|
let target_available = target.is_some()
|
|
|
|
|
|| alias_config
|
2026-04-14 04:12:14 +03:00
|
|
|
.and_then(|alias| alias.graph.as_deref())
|
2026-04-11 19:01:48 +03:00
|
|
|
.is_some()
|
2026-04-14 04:12:14 +03:00
|
|
|
|| config.cli_graph_name().is_some();
|
2026-04-11 19:01:48 +03:00
|
|
|
let (legacy_uri, alias_args) =
|
|
|
|
|
normalize_legacy_alias_uri(legacy_uri, target_available, alias_name, alias_args);
|
|
|
|
|
let uri = uri.or(legacy_uri);
|
2026-04-10 20:49:41 +03:00
|
|
|
let target_name = target
|
|
|
|
|
.as_deref()
|
2026-04-14 04:12:14 +03:00
|
|
|
.or_else(|| alias_config.and_then(|alias| alias.graph.as_deref()));
|
2026-04-10 20:49:41 +03:00
|
|
|
let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target_name)?;
|
|
|
|
|
let query_source = resolve_query_source(
|
|
|
|
|
&config,
|
|
|
|
|
query.as_ref(),
|
|
|
|
|
alias_config.map(|a| a.query.as_str()),
|
|
|
|
|
)?;
|
|
|
|
|
let params_json = merged_params_json(
|
|
|
|
|
alias_name,
|
|
|
|
|
alias_config
|
|
|
|
|
.map(|alias| alias.args.as_slice())
|
|
|
|
|
.unwrap_or(&[]),
|
|
|
|
|
&alias_args,
|
|
|
|
|
load_params_json(¶ms)?,
|
|
|
|
|
)?;
|
|
|
|
|
let target = resolve_read_target(
|
|
|
|
|
&config,
|
|
|
|
|
branch,
|
|
|
|
|
snapshot,
|
|
|
|
|
alias_config.and_then(|alias| alias.branch.clone()),
|
|
|
|
|
)?;
|
|
|
|
|
let query_name = name.or_else(|| alias_config.and_then(|alias| alias.name.clone()));
|
|
|
|
|
let output = if is_remote_uri(&uri) {
|
|
|
|
|
execute_read_remote(
|
|
|
|
|
&http_client,
|
|
|
|
|
&uri,
|
|
|
|
|
&query_source,
|
|
|
|
|
query_name.as_deref(),
|
|
|
|
|
target,
|
|
|
|
|
params_json.as_ref(),
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
|
|
|
|
execute_read(
|
|
|
|
|
&uri,
|
|
|
|
|
&query_source,
|
|
|
|
|
query_name.as_deref(),
|
|
|
|
|
target,
|
|
|
|
|
params_json.as_ref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
};
|
|
|
|
|
let format = resolve_read_format(
|
|
|
|
|
&config,
|
|
|
|
|
format,
|
|
|
|
|
json,
|
|
|
|
|
alias_config.and_then(|alias| alias.format),
|
|
|
|
|
);
|
|
|
|
|
print_read_output(&output, format, &config)?;
|
|
|
|
|
}
|
|
|
|
|
Command::Change {
|
|
|
|
|
uri,
|
2026-04-11 19:01:48 +03:00
|
|
|
legacy_uri,
|
2026-04-10 20:49:41 +03:00
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
alias,
|
|
|
|
|
query,
|
|
|
|
|
name,
|
|
|
|
|
params,
|
|
|
|
|
branch,
|
|
|
|
|
json,
|
|
|
|
|
alias_args,
|
|
|
|
|
} => {
|
|
|
|
|
if alias.is_some() == query.is_some() {
|
|
|
|
|
bail!("exactly one of --alias or --query must be provided");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let alias = resolve_alias(&config, alias.as_deref(), AliasCommand::Change)?;
|
|
|
|
|
let alias_name = alias.as_ref().map(|(name, _)| *name);
|
|
|
|
|
let alias_config = alias.as_ref().map(|(_, alias)| *alias);
|
2026-04-11 19:01:48 +03:00
|
|
|
let target_available = target.is_some()
|
|
|
|
|
|| alias_config
|
2026-04-14 04:12:14 +03:00
|
|
|
.and_then(|alias| alias.graph.as_deref())
|
2026-04-11 19:01:48 +03:00
|
|
|
.is_some()
|
2026-04-14 04:12:14 +03:00
|
|
|
|| config.cli_graph_name().is_some();
|
2026-04-11 19:01:48 +03:00
|
|
|
let (legacy_uri, alias_args) =
|
|
|
|
|
normalize_legacy_alias_uri(legacy_uri, target_available, alias_name, alias_args);
|
|
|
|
|
let uri = uri.or(legacy_uri);
|
2026-04-10 20:49:41 +03:00
|
|
|
let target_name = target
|
|
|
|
|
.as_deref()
|
2026-04-14 04:12:14 +03:00
|
|
|
.or_else(|| alias_config.and_then(|alias| alias.graph.as_deref()));
|
2026-04-10 20:49:41 +03:00
|
|
|
let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target_name)?;
|
|
|
|
|
let query_source = resolve_query_source(
|
|
|
|
|
&config,
|
|
|
|
|
query.as_ref(),
|
|
|
|
|
alias_config.map(|a| a.query.as_str()),
|
|
|
|
|
)?;
|
|
|
|
|
let params_json = merged_params_json(
|
|
|
|
|
alias_name,
|
|
|
|
|
alias_config
|
|
|
|
|
.map(|alias| alias.args.as_slice())
|
|
|
|
|
.unwrap_or(&[]),
|
|
|
|
|
&alias_args,
|
|
|
|
|
load_params_json(¶ms)?,
|
|
|
|
|
)?;
|
|
|
|
|
let branch = resolve_branch(
|
|
|
|
|
&config,
|
|
|
|
|
branch,
|
|
|
|
|
alias_config.and_then(|alias| alias.branch.clone()),
|
|
|
|
|
"main",
|
|
|
|
|
);
|
|
|
|
|
let query_name = name.or_else(|| alias_config.and_then(|alias| alias.name.clone()));
|
|
|
|
|
let output = if is_remote_uri(&uri) {
|
|
|
|
|
execute_change_remote(
|
|
|
|
|
&http_client,
|
|
|
|
|
&uri,
|
|
|
|
|
&query_source,
|
|
|
|
|
query_name.as_deref(),
|
|
|
|
|
&branch,
|
|
|
|
|
params_json.as_ref(),
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
} else {
|
|
|
|
|
execute_change(
|
|
|
|
|
&uri,
|
|
|
|
|
&query_source,
|
|
|
|
|
query_name.as_deref(),
|
|
|
|
|
&branch,
|
|
|
|
|
params_json.as_ref(),
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
&config,
|
|
|
|
|
cli.as_actor.as_deref(),
|
2026-04-10 20:49:41 +03:00
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
};
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&output)?;
|
|
|
|
|
} else {
|
|
|
|
|
print_change_human(&output);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Command::Policy { command } => match command {
|
|
|
|
|
PolicyCommand::Validate { config } => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let engine = resolve_policy_engine(&config)?;
|
|
|
|
|
let policy_file = config
|
|
|
|
|
.resolve_policy_file()
|
|
|
|
|
.expect("policy file should exist after resolve_policy_engine");
|
|
|
|
|
println!(
|
|
|
|
|
"policy valid: {} [{} actors]",
|
|
|
|
|
policy_file.display(),
|
|
|
|
|
engine.known_actor_count()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
PolicyCommand::Test { config } => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let engine = resolve_policy_engine(&config)?;
|
|
|
|
|
let tests_path = resolve_policy_tests_path(&config)?;
|
|
|
|
|
let tests = PolicyTestConfig::load(&tests_path)?;
|
|
|
|
|
engine.run_tests(&tests)?;
|
|
|
|
|
println!("policy tests passed: {} cases", tests.cases.len());
|
|
|
|
|
}
|
|
|
|
|
PolicyCommand::Explain {
|
|
|
|
|
config,
|
|
|
|
|
actor,
|
|
|
|
|
action,
|
|
|
|
|
branch,
|
|
|
|
|
target_branch,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let engine = resolve_policy_engine(&config)?;
|
|
|
|
|
let request = PolicyRequest {
|
|
|
|
|
action,
|
|
|
|
|
branch,
|
|
|
|
|
target_branch,
|
|
|
|
|
};
|
2026-05-27 12:00:52 +02:00
|
|
|
let decision = engine.authorize(&actor, &request)?;
|
|
|
|
|
print_policy_explain(&decision, &actor, &request);
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
},
|
2026-04-25 14:22:14 +03:00
|
|
|
Command::Optimize {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.
Actor identity resolution:
- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
footgun guard fires and the write is denied — silent bypass via
"I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.
Helpers added in main.rs:
- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
opens the DB and installs the PolicyEngine when configured. Without
policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
the flag > config > None precedence.
Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.
Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:
- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
policy installed + no actor → footgun-guard denial; `--as act-bruno`
on protected main → Cedar denial; `--as act-ragnor` (admins-write
rule) on main → permit, write committed.
POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.
docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00
|
|
|
let db = Omnigraph::open(&uri).await?;
|
2026-04-25 14:22:14 +03:00
|
|
|
let stats = db.optimize().await?;
|
|
|
|
|
if json {
|
|
|
|
|
let value = serde_json::json!({
|
|
|
|
|
"uri": uri,
|
|
|
|
|
"tables": stats.iter().map(|s| serde_json::json!({
|
|
|
|
|
"table_key": s.table_key,
|
|
|
|
|
"fragments_removed": s.fragments_removed,
|
|
|
|
|
"fragments_added": s.fragments_added,
|
|
|
|
|
"committed": s.committed,
|
|
|
|
|
})).collect::<Vec<_>>(),
|
|
|
|
|
});
|
|
|
|
|
print_json(&value)?;
|
|
|
|
|
} else {
|
|
|
|
|
println!("optimize {} — {} tables", uri, stats.len());
|
|
|
|
|
for s in &stats {
|
|
|
|
|
if s.committed {
|
|
|
|
|
println!(
|
|
|
|
|
" {:<40} frags {} → {} ✓",
|
|
|
|
|
s.table_key,
|
|
|
|
|
s.fragments_removed + s.fragments_added - s.fragments_added,
|
|
|
|
|
s.fragments_added
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
println!(" {:<40} no-op", s.table_key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Command::Cleanup {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
keep,
|
|
|
|
|
older_than,
|
|
|
|
|
confirm,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
let older_than_dur = older_than.as_deref().map(parse_duration_arg).transpose()?;
|
2026-04-25 14:22:14 +03:00
|
|
|
|
|
|
|
|
if keep.is_none() && older_than_dur.is_none() {
|
|
|
|
|
bail!("cleanup requires at least one of --keep or --older-than");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let policy_desc = match (keep, older_than_dur) {
|
2026-05-24 16:46:00 +01:00
|
|
|
(Some(k), Some(d)) => {
|
|
|
|
|
format!("keep {} versions, remove anything older than {:?}", k, d)
|
|
|
|
|
}
|
2026-04-25 14:22:14 +03:00
|
|
|
(Some(k), None) => format!("keep {} versions", k),
|
|
|
|
|
(None, Some(d)) => format!("remove anything older than {:?}", d),
|
|
|
|
|
_ => unreachable!(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !confirm {
|
|
|
|
|
eprintln!(
|
|
|
|
|
"cleanup is destructive — rerun with --confirm. Policy for {}: {}",
|
|
|
|
|
uri, policy_desc
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let options = omnigraph::db::CleanupPolicyOptions {
|
|
|
|
|
keep_versions: keep,
|
|
|
|
|
older_than: older_than_dur,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut db = Omnigraph::open(&uri).await?;
|
|
|
|
|
let stats = db.cleanup(options).await?;
|
|
|
|
|
if json {
|
|
|
|
|
let value = serde_json::json!({
|
|
|
|
|
"uri": uri,
|
|
|
|
|
"keep_versions": keep,
|
|
|
|
|
"older_than_secs": older_than_dur.map(|d| d.as_secs()),
|
|
|
|
|
"tables": stats.iter().map(|s| serde_json::json!({
|
|
|
|
|
"table_key": s.table_key,
|
|
|
|
|
"bytes_removed": s.bytes_removed,
|
|
|
|
|
"old_versions_removed": s.old_versions_removed,
|
|
|
|
|
})).collect::<Vec<_>>(),
|
|
|
|
|
});
|
|
|
|
|
print_json(&value)?;
|
|
|
|
|
} else {
|
|
|
|
|
let total_bytes: u64 = stats.iter().map(|s| s.bytes_removed).sum();
|
|
|
|
|
let total_versions: u64 = stats.iter().map(|s| s.old_versions_removed).sum();
|
|
|
|
|
println!(
|
|
|
|
|
"cleanup {} ({}) — removed {} versions ({} bytes) across {} tables",
|
|
|
|
|
uri,
|
|
|
|
|
policy_desc,
|
|
|
|
|
total_versions,
|
|
|
|
|
total_bytes,
|
|
|
|
|
stats.len()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
mr-668: CLI omnigraph graphs list/create (PR 8/10)
PR 8 of the MR-668 multi-graph server work. CLI parity for the
v0.7.0 management surface: operators can now manage graphs from
the command line against a running multi-graph server.
omnigraph graphs list --target dev --json
omnigraph graphs create \
--target dev \
--graph-id beta \
--graph-uri /data/beta.omni \
--schema schema.pg
DELETE is intentionally absent — server-side DELETE was deferred from
v0.7.0 scope, and shipping a client subcommand for a server endpoint
that doesn't exist would be dead vocabulary. The help output, the
subcommand enum, and the test that pins it (`graphs_subcommand_help_
lists_list_and_create`) all agree.
CLI architecture (modeled on `BranchCommand`):
- New `Command::Graphs { command: GraphsCommand }` top-level variant.
- `GraphsCommand { List, Create }` enum.
- List: GET `<base>/graphs`. Stdout is `<graph_id>\t<uri>` per line,
or JSON via `--json`.
- Create: reads `--schema <path>` from local disk, inlines as
`schema: { source: <file> }` in the POST body (nested per
MR-668 decision 7). Optional `--policy-file <path>` becomes
`policy: { file: <path> }`. Returns 201 → "created graph X at Y"
or JSON via `--json`.
- Both subcommands reject local URI targets with a clear
"remote multi-graph server URL" error.
New API type imports in the CLI: `GraphCreateRequest`,
`GraphCreateResponse`, `GraphListResponse`, `GraphSchemaSpec`,
`GraphPolicySpec` — all from `omnigraph-server::api`.
Tests:
- cli.rs (4 new, non-network):
* `graphs_subcommand_help_lists_list_and_create` — pins the
deferral of `delete` (catches scope creep).
* `graphs_list_against_local_uri_errors_with_remote_only_message`
* `graphs_create_against_local_uri_errors_with_remote_only_message`
* `graphs_create_with_missing_schema_file_errors` — pins the
IO context in the schema-read error path.
- system_remote.rs (1 new, `#[ignore]` like its peers):
* `graphs_list_and_create_against_multi_graph_server` — spawns a
multi-mode server, calls `graphs list` (sees `alpha`),
`graphs create` (adds `beta`), `graphs list` again (sees both),
and confirms the new graph is reachable via its cluster route.
CLI suite: 62 tests green (58 existing + 4 new). The new ignored
end-to-end test runs locally with `cargo test --ignored`.
LOC: +159 main.rs (enum + handlers), +88 cli.rs (unit tests),
+131 system_remote.rs (integration test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:54:21 +02:00
|
|
|
Command::Graphs { command } => match command {
|
|
|
|
|
GraphsCommand::List {
|
|
|
|
|
uri,
|
|
|
|
|
target,
|
|
|
|
|
config,
|
|
|
|
|
json,
|
|
|
|
|
} => {
|
|
|
|
|
let config = load_cli_config(config.as_ref())?;
|
|
|
|
|
let bearer_token =
|
|
|
|
|
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
|
|
|
|
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
|
|
|
|
if !is_remote_uri(&uri) {
|
|
|
|
|
bail!(
|
|
|
|
|
"`omnigraph graphs list` requires a remote multi-graph server URL \
|
|
|
|
|
(http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \
|
|
|
|
|
directly."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
let payload = remote_json::<GraphListResponse>(
|
|
|
|
|
&http_client,
|
|
|
|
|
Method::GET,
|
|
|
|
|
remote_url(&uri, "/graphs"),
|
|
|
|
|
None,
|
|
|
|
|
bearer_token.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
if json {
|
|
|
|
|
print_json(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
for entry in payload.graphs {
|
|
|
|
|
println!("{}\t{}", entry.graph_id, entry.uri);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-10 20:49:41 +03:00
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
|
|
|
|
use super::{
|
|
|
|
|
DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file, load_cli_config,
|
|
|
|
|
load_env_file_into_process, normalize_bearer_token, parse_env_assignment,
|
|
|
|
|
resolve_remote_bearer_token,
|
|
|
|
|
};
|
|
|
|
|
use omnigraph_server::load_config;
|
|
|
|
|
use reqwest::header::AUTHORIZATION;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn apply_bearer_token_adds_header_when_configured() {
|
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let request = apply_bearer_token(client.get("http://example.com"), Some("demo-token"))
|
|
|
|
|
.build()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
request
|
|
|
|
|
.headers()
|
|
|
|
|
.get(AUTHORIZATION)
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("Bearer demo-token")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn apply_bearer_token_leaves_request_unchanged_when_not_configured() {
|
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let request = apply_bearer_token(client.get("http://example.com"), None)
|
|
|
|
|
.build()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(request.headers().get(AUTHORIZATION).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn normalize_bearer_token_trims_and_filters_blank_values() {
|
|
|
|
|
assert_eq!(normalize_bearer_token(None), None);
|
|
|
|
|
assert_eq!(normalize_bearer_token(Some(" ".to_string())), None);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_bearer_token(Some(" demo-token ".to_string())).as_deref(),
|
|
|
|
|
Some("demo-token")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_env_assignment_supports_plain_and_exported_values() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_env_assignment("DEMO_TOKEN=demo-token"),
|
|
|
|
|
Some(("DEMO_TOKEN".to_string(), "demo-token".to_string()))
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_env_assignment("export DEMO_TOKEN=\"quoted-token\""),
|
|
|
|
|
Some(("DEMO_TOKEN".to_string(), "quoted-token".to_string()))
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(parse_env_assignment("# comment"), None);
|
|
|
|
|
assert_eq!(parse_env_assignment(" "), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bearer_token_from_env_file_reads_named_value() {
|
|
|
|
|
let temp = tempdir().unwrap();
|
|
|
|
|
let env_file = temp.path().join(".env.omni");
|
|
|
|
|
fs::write(
|
|
|
|
|
&env_file,
|
|
|
|
|
"FIRST=ignore\nexport DEMO_TOKEN=\" demo-token \"\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
bearer_token_from_env_file(&env_file, "DEMO_TOKEN")
|
|
|
|
|
.unwrap()
|
|
|
|
|
.as_deref(),
|
|
|
|
|
Some("demo-token")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
bearer_token_from_env_file(&env_file, "MISSING").unwrap(),
|
|
|
|
|
None
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_env_file_into_process_sets_missing_values_without_overriding_existing_ones() {
|
|
|
|
|
let temp = tempdir().unwrap();
|
|
|
|
|
let env_file = temp.path().join(".env.omni");
|
|
|
|
|
fs::write(
|
|
|
|
|
&env_file,
|
|
|
|
|
"AUTOLOAD_ONLY=from-file\nAUTOLOAD_PRESET=from-file\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let missing_key = "AUTOLOAD_ONLY";
|
|
|
|
|
let preset_key = "AUTOLOAD_PRESET";
|
|
|
|
|
let previous_missing = std::env::var_os(missing_key);
|
|
|
|
|
let previous_preset = std::env::var_os(preset_key);
|
|
|
|
|
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var(missing_key);
|
|
|
|
|
std::env::set_var(preset_key, "from-env");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
load_env_file_into_process(&env_file).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(std::env::var(missing_key).unwrap(), "from-file");
|
|
|
|
|
assert_eq!(std::env::var(preset_key).unwrap(), "from-env");
|
|
|
|
|
|
|
|
|
|
unsafe {
|
|
|
|
|
if let Some(value) = previous_missing {
|
|
|
|
|
std::env::set_var(missing_key, value);
|
|
|
|
|
} else {
|
|
|
|
|
std::env::remove_var(missing_key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(value) = previous_preset {
|
|
|
|
|
std::env::set_var(preset_key, value);
|
|
|
|
|
} else {
|
|
|
|
|
std::env::remove_var(preset_key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_remote_bearer_token_uses_scoped_env_file_with_global_fallback() {
|
|
|
|
|
let temp = tempdir().unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
temp.path().join("omnigraph.yaml"),
|
|
|
|
|
r#"
|
2026-04-14 04:12:14 +03:00
|
|
|
graphs:
|
2026-04-10 20:49:41 +03:00
|
|
|
demo:
|
|
|
|
|
uri: https://example.com
|
|
|
|
|
bearer_token_env: DEMO_TOKEN
|
|
|
|
|
auth:
|
|
|
|
|
env_file: .env.omni
|
|
|
|
|
cli:
|
2026-04-14 04:12:14 +03:00
|
|
|
graph: demo
|
2026-04-10 20:49:41 +03:00
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
temp.path().join(".env.omni"),
|
|
|
|
|
"DEMO_TOKEN=scoped-token\nOMNIGRAPH_BEARER_TOKEN=global-token\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV);
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
|
|
|
let config = load_config(Some(&config_path)).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_remote_bearer_token(&config, None, Some("demo"))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.as_deref(),
|
|
|
|
|
Some("scoped-token")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_remote_bearer_token(&config, Some("https://override.example.com"), None)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.as_deref(),
|
|
|
|
|
Some("global-token")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
unsafe {
|
|
|
|
|
if let Some(value) = previous {
|
|
|
|
|
std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, value);
|
|
|
|
|
} else {
|
|
|
|
|
std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_cli_config_autoloads_env_file_into_process() {
|
|
|
|
|
let temp = tempdir().unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
temp.path().join("omnigraph.yaml"),
|
|
|
|
|
r#"
|
|
|
|
|
auth:
|
|
|
|
|
env_file: .env.omni
|
2026-04-14 04:12:14 +03:00
|
|
|
graphs:
|
2026-04-10 20:49:41 +03:00
|
|
|
demo:
|
|
|
|
|
uri: s3://bucket/prefix
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
temp.path().join(".env.omni"),
|
|
|
|
|
"AUTOLOAD_FROM_CONFIG=loaded\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let key = "AUTOLOAD_FROM_CONFIG";
|
|
|
|
|
let previous = std::env::var_os(key);
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
|
|
|
let config = load_cli_config(Some(&config_path)).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
config.resolve_target_uri(None, Some("demo"), None).unwrap(),
|
|
|
|
|
"s3://bucket/prefix"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(std::env::var(key).unwrap(), "loaded");
|
|
|
|
|
|
|
|
|
|
unsafe {
|
|
|
|
|
if let Some(value) = previous {
|
|
|
|
|
std::env::set_var(key, value);
|
|
|
|
|
} else {
|
|
|
|
|
std::env::remove_var(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|