Skip to content

feat(cli): say who you are at a target and what you may do - #1081

Closed
ragnorc wants to merge 68 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-access
Closed

ragnorc wants to merge 68 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-access

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1080 (named targets) and #1073 (the access route), merged in; review only the last commit.

What & why

omnigraph access [--target NAME] [--graph G] [--branch B [--target-branch T]] [--json] says who you are at a target and what you may do there, so a client shows the actions it may take instead of learning them from a 403:

{"protocol":1,"target":"staging","source":"server","actor":"act-me","graph_id":"knowledge",
 "graph_wide":["read","export"],"branch":"main","target_branch":null,"actions":["read","export","change"]}
  • A server answers through GET /graphs/{graph_id}/access (feat(server): report what a credential may do through an access route #1073), with the target's token, and --as asserted as on every served command.
  • For direct storage the CLI decides itself (source: direct) for the actor --as or operator.actor names: a cluster graph by the policy bundle bound to graph.<id> in its applied state (no actor, no actions), and a standalone graph, which has no policy, allows every action, as direct writes do.
  • The decision shape (graph-wide; read/export/change on the branch and the branch-targeted actions with it as the target; branch_create/branch_merge from branch to target) moves into omnigraph-api-types as AccessOutput::evaluate, with the AccessAction to PolicyAction mapping, so the route and the CLI cannot drift.
  • version --json reports protocols.access.

Backing issue / RFC

Checklist

  • Change is focused (one logical change)
  • Tests: cli_data::access_reports_what_the_caller_may_do_at_a_target (cluster policy per actor and branch, no actor, standalone graph, served route with token); the server's access tests through the shared evaluator; the version contract; skill drift
  • Public docs: "What you may do at a target" in docs/user/cli/targets.md, the skill; changelog.d/cli-access-command.added.md
  • Reviewed against docs/dev/invariants.md: direct decisions use the applied policy as policy explain does; served decisions stay the server's

Local verification

  • cargo build -p omnigraph-server && RUST_MIN_STACK=16777216 cargo test -p omnigraph-cli -- --test-threads=6 — 242 lib + 336 integration pass
  • RUST_MIN_STACK=16777216 cargo test -p omnigraph-server and -p omnigraph-api-types — all pass
  • clippy for the three crates with -D warnings, cargo fmt --all --check, python3 scripts/check-docs.py, typos, python3 scripts/check-dependency-sources.py — clean

Notes for reviewers

omnigraph-api-types gains a direct dependency on omnigraph-policy (already in its tree through the engine) to own the action mapping.

A request carrying omnigraph-expect-actor is refused with 412
actor_mismatch, after authentication and the contract check and before
graph lookup, body work or effects, when its credential resolves to
another actor. A match changes nothing, so the header only narrows a
request and never supplies an actor. Every protected router and /mcp
check it.

The CLI sends it for --as on served writes and cluster --server
commands, which no longer refuse --as. A mismatch is a not-started
refusal and exits 4, like a failed graph-commit precondition.
Every error body carries an outcome object (execution, effects, action)
that the server computes for the request it answered: not_started with
no effects when it refused before submitting a write, or refused the
write's one publication with a typed reason, which an engine gives only
before it publishes; unknown otherwise, including a typed refusal after
a load forked its branch. The next step a caller takes is retry,
refresh, recover or reconcile. A write whose completion is uncertain
answers outcome_unknown instead of internal. Errors that carried a
reason only in their details gain registry codes: precondition_failed,
recovery_required, external_blob_source and change_feed_gap.

CLI data-write failures print the same object as outcome, in place of
command_outcome, and take the server's when its request was the
command's only effectful one.
The exit follows from the failure's outcome: a command that did not
start exits 1, one whose effects are unknown 2, one refused for a
condition the caller set 4, and one refused before starting with retry
75. A refused command line exits 64 (EX_USAGE) instead of Clap's 2.

Read commands report the same failure envelope as data writes, always
not_started with no effects, so a failed read prints its outcome too.

An embedded engine write's own error now carries the engine's
completion evidence, as a server's does: a typed refusal before any
request, outside a load that forks its branch, did not start, so
embedded load conflicts and failed conditions exit as served ones
instead of claiming unknown effects. Any later error, such as one
rendering a committed receipt, stays unknown. Embedded writes refuse a
noncanonical branch name and read their data file before opening
storage.

Managed commands keep their own exits until the managed relay lands.
GET /graphs/{graph_id}/access and the MCP access tool return the
caller's resolved actor, the actions its credential allows on the whole
graph, and, for a named branch and target branch, the actions allowed
there. Each action is evaluated through authorize with the branch and
target branch shaped as the route that performs it shapes them, so the
answer matches those routes' decisions. The route needs no action of
its own, reads no graph data and judges branches by name.
A target is one complete binding in ~/.omnigraph/config.yaml: a direct
graph or cluster root, or a server with its credential owner or
anonymous access. --target NAME, else defaults.target, addresses a
command; it refuses every other address and --profile beside it, and
resolves through the scope resolver profiles use. A server target's
owner keys the existing token chain and is bound to the target's
endpoint, so its token goes only to URLs under it; the file refuses an
owner at two endpoints, two owners for one endpoint and an anonymous
target beside a keyed one.

target list|show|add|remove lists the targets without secrets or
network, with a binding digest, and edits one entry at a time: an edit
holds config.yaml.lock, changes only that entry's lines, proves by
parsing that every other key is unchanged, and replaces the file by
rename. login/logout --target store under the target's owner, and
credential get --target hands a server target's token to a pipe, never
a terminal. Refusals carry registry codes (target_unknown,
target_invalid, target_exists, target_is_default, config_not_editable,
target_kind_unsupported, credential_terminal, credential_anonymous,
credential_missing). version --json reports protocols.target and
protocols.credential. Existing servers, profiles and their flags keep
working.
# Conflicts:
#	crates/omnigraph-server/tests/integration/auth_policy.rs
omnigraph access names the caller's actor and the actions the graph's
policy grants it: graph-wide, on --branch, and from it to
--target-branch for branch_create and branch_merge. A server target
asks the server's access route with the target's token. For direct
storage the CLI decides for the actor --as or operator.actor names: a
cluster graph by its applied policy, which allows nothing without an
actor, and a standalone graph, which has no policy, allows every
action, as direct writes do.

The decision shape moves into api-types (AccessOutput::evaluate and the
AccessAction to PolicyAction mapping), so the route and the CLI decide
alike. version --json reports protocols.access.
…es it

A server built before `omnigraph-expect-actor` speaks the same HTTP
contract and ignores the header, so `--as` on a served write would run
as the credential's actor. Every response now lists the assertions its
server enforces in `omnigraph-http-api-assertions`, and the CLI refuses
before sending when discovery does not list `expect-actor`.

A served `cluster plan` or `cluster apply` refused for another actor
now exits 4 as a data write does: its requests are observed, and a
refused assertion with nothing effectful sent before it is a condition,
with or without `--json`.
`main` returned an undelivered failure as an error, so the process
exited 1, the status that says nothing changed, after a write whose
receipt met a closed stdout. It now reports the error as before and
exits with the code the outcome chose.

`alias` is a read, as `query` is, so its failures report an outcome
and exit by it. Command classification is now an exhaustive match, so
a new command declares how its failure reads.
…s everywhere

Credential lookup now reads the same validated bindings as the
ownership checks, so a server target that fails validation can no
longer win a request's credential and send another owner's token to
its endpoint.

`queries` and `policy` resolve `--target` and `defaults.target` like
every other command: a target is a complete address that excludes
`--cluster` and `--profile`, only a cluster-root target names a
cluster, and its default graph applies.

`login` and `logout` check after parsing that exactly one of a server
name, `--api` and `--target` names the credential, so a global
`--target` before the subcommand works and a conflict refuses.

A configuration refusal keeps its code through the invocation's
captured configuration, and a target edit is a local write: a failure
after its rename no longer reports that nothing changed.
# Conflicts:
#	crates/omnigraph-cli/src/command_outcome.rs
# Conflicts:
#	crates/omnigraph-cli/src/command_outcome.rs
A quarantined graph's policy bundle is withheld from the serving
snapshot and an unknown graph has none, so `access` read either as a
graph without a policy and reported every action allowed. It now
checks the snapshot first: a graph that is not applied refuses with
`not_found`, naming the applied graphs, and a quarantined one with
`graph_unavailable` and the cluster's reason, as a server refuses them.
The cluster skill page still said `cluster apply --server` refuses
`--as`; it now names the assertion `cluster plan` and `cluster apply`
send and its exit 4. The release note moves to `breaking`, since a
matching `--as` now runs a write the CLI used to refuse. The server
guide says `/mcp` has no contract check, an empty assertion joins the
malformed-header test, and stale comments name the current behaviour.
The access report's branch slots were lists in the evaluator, so a new
policy action compiled and stayed out of the report. One exhaustive
match on `PolicyAction` now gives each action its slot (source, target,
transition, graph-wide or unreported), shared by the access route and
`omnigraph access`.
Write facts survive task boundaries. Whether a request had submitted an
owned write, and whether its load forked a branch, lived in a task-local,
so an MCP tool call, which runs its handler in an owned read task and
builds the error body in another, lost both: a load whose `from` forked a
new branch and then met a key conflict reported not_started with no
effects. ApiError now carries `compound` beside `owned_write`, an owned
read keeps the request's record in its own task, and an error leaving it
takes that record along; the outcome reads the error's own fields. The
outcome unit test gains cases run through an owned read, the MCP write
test a load that forks and then conflicts, and the HTTP load test its
twin.

A load is compound only when it forks. Both load routes marked it whenever
`from` was set, so a refused load onto an existing branch reported unknown
although its one publication never ran. The routes now mark it from the
branch listing their authorization already reads, and only on the owned
write's error. An earlier step's publication also outranks a refusal the
write met before it started. The HTTP load test covers a key conflict onto
an existing branch with `from`, which reports not_started.

Every error the server answers has a code. ApiError's code is no longer
optional, and the deployment status read that races an owner change
answers 503 `service_unavailable` with a retry outcome instead of no code.

Malformed input, unknown routes and unserved methods answer the error
body. Handlers took axum's Json, Query and Path, whose rejections are plain
text (422 for a JSON body of the wrong shape), and unrouted requests had
empty bodies. ApiJson, ApiQuery and ApiPath refuse with ApiError (400, or
the transport status for 413 and 415), the router's fallbacks answer
not_found and method_not_allowed, and a source guard refuses a handler
parameter of axum's own extractors. The three query structs whose
parameter location came from the extractor's name declare it, so their
optional parameters render as the other query parameters do.

Embedded callers can render an engine failure as a served request with
the same facts would: engine_error_output_after takes whether a write was
submitted and whether an earlier step published, and
engine_read_error_output renders a read, which never answers
outcome_unknown.

The RustFS S3 test expected the 424 external Blob source refusal to carry
no code; it now asserts its code and outcome. The troubleshooting guide
and the changelog note state the outcome decision table the server
implements.
…efusals as the server does

The merge keeps this branch's command kinds and ModernRelay#1074's served cluster
`--as` exit: a cluster command is observed without being classified.

An embedded `load --from` marked its write compound whenever `from` was
set, so a refusal before its fork exited 2. It now marks it only when the
branch is missing, as the server's load does, and an embedded write's
own error goes through `engine_error_output_after`: a policy refusal
precedes every publication, the fork's included, and any other refusal
of a compound load follows the fork. A read's engine error keeps its own
code instead of `outcome_unknown`, and the failure reporter no longer
panics on a wrapped engine write error.

The crossversion journeys read a `--json` read's refusal from stdout,
where the failure now goes. The exit documentation says once that a
failed condition exits 4 embedded or served, names the data commands the
outcome table covers and the commands that keep their own exits, and says
that only the argument parser's refusals exit 64.
An actor whose rules allow each remaining action only through its own
slot (schema apply and the commit controls by target, a fork by target
into an unprotected branch, `invoke_query` graph-wide) now fixes which
slot each action's decision uses, so a swapped source and target fails.

The guide's sample comes from the page's example policy, and it says
what the branch-free decision covers, that rules covering every branch
only together do not reach it, and that `schema_apply` on a branch other
than `main` does not make schema apply possible there.
…teral

A cluster-root target on a command that addresses one standalone graph was
dropped: `schema apply` lost the root and failed asking for an address.
Every scope resolver call now declares whether it reads a root, and one that
does not (`schema apply`, `schema upgrade-system-columns`, `upgrade` and the
data commands) refuses a resolved root by kind. A target's kind refusal
carries `target_kind_unsupported`; a profile's keeps its untyped message.

`queries` and `policy` resolve their cluster through the same scope resolver
as every other command, so `OMNIGRAPH_PROFILE` beside `defaults.target` is a
conflict, and an explicit profile or target that names no cluster root
refuses instead of falling through to the default target. A target's default
graph still applies there and a profile's still does not.

A target's root URI is used as written. Only `--cluster` and profile cluster
names are looked up under `clusters:`, so a `clusters:` key spelled like a
target's URI no longer redirects it.

A server target's endpoint must be a whole server URL, one the request
target parser accepts, with no user, password, query or fragment; `https://`
alone and an endpoint carrying a password no longer pass as bindings. An
incomplete entry is listed with its address redacted. `servers:` urls keep
their existing rules.

A direct target cannot name `s3://` or `az://` storage and refuses with
`target_invalid`: the store's endpoint and account come from the environment
when it opens, and this format cannot pin them in the binding. Cloud storage
keeps its `--store` and `--cluster` addresses.

`login --target` and `logout --target` report the target and its owner, and
a target's credential owner counts as declared, so `login` no longer
suggests a `servers:` entry that would bind the owner a second time.

`target list` no longer lists aliases, which still name `servers:` entries
rather than targets.

A direct target's binding digest hashes its normalized storage, so `/data/g`
and `file:///data/g` are one binding; the default graph stays out of it.

The scope flag matrix test compares `--target` as its seventh column.

`target add` and `target remove` refuse with `config_not_editable` off Unix,
where no lock holds the edit.
…target's default graph

The merge keeps this branch's typed-refusal branch in the failure
reporter beside ModernRelay#1078's projection of embedded engine errors.

`queries` and `policy` keep their explicit `--graph` filter and no longer
take a target's `default_graph`, as RFC 0059 specifies for root tools, so
their all-graph and ambiguity behaviour is the same whatever selected the
root.
Brings in the key-conflict description in graph vocabulary.
Brings in the key-conflict description in graph vocabulary.
Brings in the key-conflict description in graph vocabulary.
`access --store <root>/graphs/<id>.omni` found the cluster by its storage
layout, then read the ledger through the root's configuration, so an
unapplied `storage:` in its `cluster.yaml` pointed the read at another
root: the command refused a graph that exists, or reported another
cluster's policy, while `--cluster` of the same root answered right. The
store arm now reads the applied ledger at the discovered root itself
(`read_storage_snapshot_or_report`), and the access test edits the
configuration to name another storage root and expects the same answer.

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: approve. Merge after RFC #1017 is accepted.

omnigraph access tells a client who it is at a target and which actions it may take, so the client can show those actions before a write. A server target calls GET /graphs/{id}/access with the target token, and sends --as as omnigraph-expect-actor. Direct storage evaluates the installed policy for --as, or for operator.actor when --as is absent. A graph with no policy allows every action after the store opens.

The requested head was fb2ab08b. The pull request moved to 3a3f8375 during this review. This review covers 3a3f8375. That commit is fix(cli): read a store's cluster ledger where its storage is, on top of fb2ab08b.

Earlier findings

The review at 592151d0 asked for six changes. All six are resolved at this head.

  1. Blocking. The store arm reported every action for a path it did not open. Resolved in 30e4e3a5. store_access uses cluster_graph_for_uri. A layout member reads that cluster's applied ledger. Any other store opens read-only, and every action is allowed only after the open succeeds. A missing dataset returns not_found. cli_data::access_reports_what_the_caller_may_do_at_a_target locks the cluster-member JSON and the missing store.
  2. Text output. Resolved in 6171d4bd. on {branch} lists the branch actions. from {branch} into {target_branch} lists branch_create and branch_merge.
  3. Docs. Resolved in 30e4e3a5 and 599a2b4d. The command page states the no-bundle allow, and it states that schema_apply on a side branch does not make schema apply possible.
  4. Skill. Resolved in b4ac85d8. The global --as line and the condensed access line include access.
  5. Protocol constant. Resolved in 05666fa6. ACCESS_PROTOCOL is separate from TARGET_PROTOCOL.
  6. Two bundles. Resolved in 8367892d. The refusal is graph_unavailable. The unit test covers a ledger cluster apply does not produce.

New commit

3a3f8375 fixes a hole in 30e4e3a5. The store arm found the cluster by layout, then read the ledger through the configuration reader. An unapplied storage: in cluster.yaml sent that read to another root. The command then refused a graph that exists, or it could report another cluster's policy. --cluster of the config directory still follows storage:. The store arm now calls read_storage_snapshot_or_report and reads the ledger at the storage root. The same test edits storage: to an empty directory and expects the original report.

The split is the right one. A config directory and a storage root are different addresses. read_serving_snapshot_or_report keeps the config path. read_storage_snapshot_or_report is the storage path. Both share report_snapshot.

Blocking

None.

Optional

  1. require_standalone_graph treats every storage NotFound as "no graph". A missing dataset is DatasetNotFound, and not_found is the right code for that case. Lance 11 also classifies a bare NotFound, RefNotFound, VersionNotFound, and IndexNotFound as the same kind. open_dataset says a bare NotFound is a live manifest that names a missing object, and that this failure stays loud. This arm turns that class into not_found. The command still fails closed. Keep not_found for dataset absence. Let every other open error keep the engine classification. OmniError::Storage becomes internal on that path. Do not match the message text. OmniError::storage drops the Lance variant, so carry the absence fact out of the open.
  2. The summary on store_access still says a layout member answers as --cluster answers. After 3a3f8375 the two reads differ when storage: names another root. docs/user/cli/targets.md still presents the two addresses as one answer. Name the difference in both places.

RFC #1017 at e15d2f5b

The access section matches this head for a server target, a cluster root, a cluster member addressed by its store, and a standalone graph that opens.

These texts still differ:

  • The sample omits target_branch. The command and the user page always emit it, and the value is null when absent. The extra field is compatible. Update the sample in the RFC.
  • The evidence line names integration/cli_schema_config.rs. The owner is cli_data::access_reports_what_the_caller_may_do_at_a_target. This branch has no local-server access test. Local servers belong to the later serve pull requests.
  • The earlier note that this branch left the HTTP contract unchanged is closed. HTTP_API_CONTRACT is 0.14 here, from the parent stack (76cc9412), not from this pull request's own commits.
  • Legacy --server, --store, --cluster, and --profile remain. The RFC keeps them until RFC 0059. The command page documents them.

Siblings

This branch contains #1073 and merges #1080 through 8ef33888. #1080's head e67386ed is one commit ahead of that merge. It digests a Unix socket endpoint. That commit stays on #1080. The next merge of feat/cli-targets brings it.

#1059 changes which commands accept --as. Keep access on the accept side at rebase.

#1074, #1075, #1076, #1077, #1078, #1083, and #1084 continue this RFC on their own branches. Local servers stay in #1075, #1077, and #1083. #1012, #1072, #1086, and #675 do not define this command.

Merge c11bfd2a kept AccessOutput::evaluate in the access handler. Merge fb2ab08b kept the key-conflict sentence "The refused write changed nothing" and kept schema plan's root refusal.

Liability

One evaluator and one command replace a client that learned actions from a 403. The store arm no longer treats "nothing was loaded" as "every action". A layout member and a config-addressed cluster graph share snapshot_access. A standalone graph pays one read-only open. That open takes the process-local schema gate and drops it. It does not fence another process. A client runs the command once per screen.

The remaining false label is the wide NotFound match. It refuses, and it names corruption as absence. Fix that class at the open, where Lance still knows dataset absence from a missing object.

Two snapshot readers are enough. Further commands that hold a storage root should call read_storage_snapshot_or_report. Commands that hold a config directory should call read_serving_snapshot_or_report.

Validation

Local. rustc 1.97.1. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216.

At fb2ab08b, before the head moved:

  • cargo test -p omnigraph-api-types --lib tests::every_reported_access_action_has_a_slot -- --exact --test-threads=1 — 1 passed, exit 0.
  • cargo test -p omnigraph-cluster --lib -- graph_layout_gating_does_no_io_for_non_cluster_shapes cluster_root_detected_only_when_state_ledger_present cluster_root_probe_keeps_local_enotdir_loud cluster_root_probe_rejects_unsupported_shaped_uri -- --test-threads=1 — 4 passed, exit 0.
  • cargo test -p omnigraph-cli --lib -- access_reads_policy_only_for_a_graph_the_cluster_serves skill::tests:: -- --test-threads=1 — 7 passed, exit 0.
  • cargo test -p omnigraph-cli --test integration -- cli_data::access_reports_what_the_caller_may_do_at_a_target -- --exact --test-threads=1 — 1 passed, exit 0.
  • cargo test -p omnigraph-server --test integration -- auth_policy::access_reports_the_callers_own_actions_as_routes_decide_them -- --exact --test-threads=1 — 1 passed, exit 0. 3a3f8375 does not change the server.

At 3a3f8375:

  • The same CLI integration filter — 1 passed, exit 0.
  • cargo test -p omnigraph-cli --lib -- access_reads_policy_only_for_a_graph_the_cluster_serves -- --exact --test-threads=1 — 1 passed, exit 0.

Regression. A .gqt case cannot see a CLI process, an exit code, or an operator file. The owner is cli_data. The assertions are in the pull request. Temporary edits were restored. The tree is clean at 3a3f8375.

  • Store allow-all, the behavior before 30e4e3a5. Same integration filter. Failed, exit 101, at cli_data.rs:6210. The store report had graph_id default, all ten graph_wide actions, and change on main. The cluster report had graph_id knowledge, graph_wide ["read"], and actions ["read"]. The same binary, omnigraph access --store /tmp/no-such-omnigraph-graph-1081-re --branch main --json, exited 0 with graph_id default and ten graph_wide actions. The missing-store assertion is later in the same test and did not run. After restore, the test passed, exit 0.
  • Text printer, the behavior before 6171d4bd. Same integration filter. Failed, exit 101. Stdout was on main: ... branch_create, branch_merge and had no from main into feature line. After restore, the test passed, exit 0.
  • Two bundles, the behavior before 8367892d. cargo test -p omnigraph-cli --lib -- access_reads_policy_only_for_a_graph_the_cluster_serves -- --exact --test-threads=1. Failed, exit 101. downcast_ref::<CliRefusal>() was None. After restore, 1 passed, exit 0.
  • Config-root ledger read, the behavior before 3a3f8375. The clean head passed first, exit 0. With cluster_access put back, the same filter failed, exit 101. After storage: named an empty directory, the command returned cluster ... is not servable: no cluster state ledger for a graph whose ledger was still beside the store. The source was then restored to the commit that passed.

CI on 3a3f8375 at review time: clippy, rustfmt, GQ Logic Tests, the GQT jobs, the server aws job, and the S3 and Azure jobs passed. Test Workspace was still in progress. The full local workspace suite, local clippy, and local fmt did not run. No local S3 or Azure. Runtime was not measured.

Inline comment, crates/omnigraph-cli/src/commands/targets/mod.rs:553 (RIGHT):

Optional. This match treats every storage NotFound as "no graph" and returns not_found.

Lance 11 wraps only Error::NotFound from resolve_latest_location as DatasetNotFound (DatasetBuilder::load_by_uri). A later miss stays Error::NotFound. classify_lance_error also puts RefNotFound, VersionNotFound, and IndexNotFound in StorageFailureKind::NotFound. open_dataset says a bare NotFound is a live manifest that names a missing object, and that this failure stays loud. branch_ref_error says a generic object miss stays a storage failure.

A missing dataset is DatasetNotFound. not_found is the right code for that case. Keep not_found for that absence. Let every other open error keep the engine classification. OmniError::Storage becomes internal on that path. OmniError::storage drops the Lance variant, so do not match the message text. Carry the absence fact out of the open.

The command still fails closed. It does not allow every action.

Inline comment, crates/omnigraph-cli/src/commands/targets/mod.rs:510 (RIGHT):

Optional. This sentence says a layout member answers as --cluster answers. 3a3f8375 splits the two reads. --cluster follows storage: in cluster.yaml. This arm reads the ledger at the storage root the layout names. The answers differ when that storage: value names another root. Say that here, and in docs/user/cli/targets.md, where the two addresses still read as one answer.

…o fix

The error body's `outcome` collided with `ChangeOutput.outcome` and
`BranchMergeOutput.outcome`. It is now `disposition`, with two fields:
`effects` (`none`, `partial`, `unknown`) and `action` (`fix`, `refresh`,
`retry`, `recover`, `reconcile`). `execution` is gone: `effects: none`
already says the request did not start.

`partial` is the compound case a load that forks its branch meets: an
earlier step published and the failing step was a typed refusal that wrote
nothing. `fix` is a refusal the caller corrects (a credential, input, path
or client); `refresh` stays for refusals by state (conflicts, preconditions,
policy). Each code's action comes from `ErrorCode::action`, which the
server's one disposition function and the CLI's own account share.

The CLI prints `disposition` in place of `outcome`, and its human mode
prints a `disposition:` line.
`ErrorCode` is declared from one list that names each code's wire spelling,
its one HTTP status and its meaning. An `ApiError` holds a code and answers
under that code's status, so a status can no longer drift from its code:
a Blob `If-Match` 412 answers `precondition_failed`, and 408, 413, 415 and
416 answer `request_timeout`, `payload_too_large`, `unsupported_media_type`
and `range_not_satisfiable` instead of `bad_request`. A write the server
could not settle answers 500 `outcome_unknown` whatever failed underneath.

Every 429 or 503 whose action is `retry` carries `Retry-After`, a graph that
is not served and closed admission included.

The published `ErrorCode` lists every code with its status and meaning,
`error`, `code` and `disposition` are required, every protected operation
documents the router's 404 and 405 (and a per-graph one the unknown graph),
and header parameters are spelled `Omnigraph-Http-Api` and
`Omnigraph-Expect-Actor`; the wire constants stay lowercase.
Deployment handlers took `Extension<AuthenticatedActor>`, which a server
without authentication answers with axum's plain-text 500, and mapped a
body rejection to 400 by hand, losing its 413 or 415. They now take the
optional actor and refuse with a 403 error body, and decode the body with
the typed extractor after authorization. Every other handler that mapped a
JSON or query rejection by hand takes the typed extractor too, and the
source guard refuses axum's extractors inside `Result` or `Option` and any
rejection type outside the extractor module.

MCP tool errors carry the same `ErrorOutput` as HTTP, under `error`, with
the HTTP `status` the same failure answers. The MCP layer's own refusals
use registry codes: `unauthorized`, `too_many_requests`, `internal`, and
the MCP-only `cancelled`, `deadline_exceeded` and `result_too_large`, which
carry no status. A write that may have happened answers `outcome_unknown`
in place of the `outcome_unknown: true` flag, and a resource read's JSON-RPC
error carries the same body as `data`.
…ication

`after_earlier_publication` stripped the later step's own `Unpublished` or
`BeforeEffect` proof, so a detached-commit failure the engine had settled
answered `outcome_unknown` once a load had forked its branch.
`PartlyPublished` now carries `step_unpublished`: composition keeps the
step's definite proof beside the earlier publication, `Uncertain` stays
sticky, and `without_pre_effect_evidence` still drops the proof for a
composer that did more work since.

The server answers a failure the engine proved published nothing with its
own code and `effects: none`, or `partial` after an earlier step published;
only an uncertain step is `unknown` and `outcome_unknown`.
Whether a detail is a refusal before publication, recovery work or a read's
refusal is decided in one exhaustive `match`, so a new detail fails to
compile until it is classified instead of falling to `unknown` and
`reconcile`. The refusal-class test builds its cases as values, which
clippy accepts.
Main's `branch_start_refusal` detail answers under its own registry code,
`branch_start_refusal`, sent with 409, or 410 for `not_retained`: a body's
details choose among its code's statuses (`ErrorOutput::http_status`). A
refused branch start is a typed refusal before publication, so its effects
are none and its action is `fix`.
`branch_start_refusal` is sent with 409, or 410 for `not_retained`, so the registry, the HTTP errors guide and the release note no longer claim one status per code.
Brings in main through ModernRelay#1076, including branch creation at a commit,
and the disposition that replaces the outcome. The embedded branch
create keeps its engine-write mark on main's new call. The exit table
reads `disposition`: no effects exit 1, unknown or partial effects
exit 2, a busy refusal 75 and a failed condition 4. A load whose fork
published before its rows were refused now reports partial effects.
`access --store` mapped every storage `NotFound` from the read-only open
to `not_found`. Lance gives that kind to a missing dataset, but also to a
live manifest naming a missing object, a missing ref, version or index,
so a damaged graph was reported as an absent one.

`Omnigraph::exists` opens the root's `__manifest` dataset through the
core chokepoint and answers false only for Lance's `DatasetNotFound`.
The CLI refuses `not_found` on that answer and otherwise opens the
graph and lets the open's own failure stand: a missing manifest data
file is now `internal`.

forbidden_apis registers `exists` as a read-only surface, because the
public graph API registry classifies every public async method.
A store in a cluster's layout reads the applied ledger at the root it
sits under. `--cluster DIR` reads it where the `storage:` of
`DIR/cluster.yaml` points. The answers differ when that `storage:`
names another root, as an edit not yet applied does. The comment on
`store_access` and the access section of the targets page said the two
addresses give one answer.
Brings in main through ModernRelay#1078, including branch creation at a commit and
the disposition. The target and credential codes join the error-code
registry as CLI-only entries with no HTTP status, each asking the
caller to fix the request, and the access tests read `disposition`.
An actor mismatch, a malformed request, an unsupported contract and a
missing credential ask the caller to fix the request; a policy denial
asks for a refresh. An embedded load whose fork published before an
error the engine cannot prove wrote nothing reports unknown effects.
@cursor

cursor Bot commented Oct 20, 2026

Copy link
Copy Markdown

Recommendation: approve. Merge after RFC #1017 is accepted. Take the Path import from #1076 before this branch merges onto main.

omnigraph access tells a client who it is at a target and which actions it may take. The client can show those actions before a write. A server target calls GET /graphs/{id}/access with the target token. Direct storage evaluates the installed policy for --as, or for operator.actor when --as is absent. A graph with no policy allows every action after the store opens.

Reviewed head: 8cc5a930e643109766e9bd0e226572d0a34550f3. The previous review covered 3a3f8375.

Earlier findings

Both optional findings from that review are resolved.

  1. 03ca5e1b keeps not_found for a store that holds no graph. Omnigraph::exists opens the root's __manifest dataset through open_dataset_if_present. That helper returns None only for Lance DatasetNotFound. Lance 13.0.0 wraps Error::NotFound from resolve_latest_location as DatasetNotFound in DatasetBuilder::load_by_uri. A later miss stays Error::NotFound. The CLI returns not_found only when exists is false. Every other failure keeps the engine code. OmniError::Storage becomes internal on that path. A missing __manifest/data file is internal.

  2. 90a78f35 names the two ledger reads. A store in a cluster layout reads the applied ledger at the root it sits under. --cluster DIR reads the ledger where storage: in DIR/cluster.yaml points. The answers differ when that storage: names another root. The comment on store_access and the access section of docs/user/cli/targets.md both say this.

Disposition

Command::Access is a read (command_outcome::kind). A failure goes through Failure::classify, which sets disposition. The access test reads disposition.effects. 977fcce8 uses the same word on the targets page: a target or credential refusal has no effects, and an edit that fails after the rename has unknown effects. No CLI source still reads outcome.execution.

Error codes

8cc5a930 lists the nine target and credential codes in openapi.json. They match ErrorCode in registry order. Each has an empty status list and the label no HTTP status. The published sentence says a code with no status never appears in an HTTP response. These codes follow that rule. They do not add a second meaning to an existing status. branch_start_refusal remains the one code sent as 409 or 410. The spec's contract value is 0.14 on every operation. openapi.json contains no 0.13. The freshness test matches the generated document. Main is still contract 0.13. The 0.14 bump comes from the parent stack, and these CLI codes ride that spec. They are not sent on HTTP, so they do not need a further bump.

Merge with main

Current main is 85a46138. git merge-tree --write-tree 85a46138 8cc5a930 exits 0 and writes tree 72f5745d. There are no conflict markers. The merged server_branch_action still takes axum::extract::Path (handlers.rs line 3043). This branch removed Path from the server prelude. handlers.rs imports that prelude with use super::*. The merged function has no Path in scope. CI on this head builds that merge. Run 39382697947 fails Lint, Test Workspace, the aws build, RustFS, Azurite, the format fence, and storage-upgrade compatibility with E0531 / E0425 at handlers.rs:3043, then E0277 because server_branch_action is not a Handler. Run 39382697959 fails GQT served the same way. The compiler hint use std::path::Path is the wrong type. Import axum::extract::Path in handlers.rs. The same break is the blocking finding on #1076 at ccdfdbd9. This pull request does not own that route. The merged branch_method_not_allowed still returns 405 with Allow and no error body. That also belongs to #1076.

The head itself compiles. Local clippy on 8cc5a930 passes.

Siblings and RFCs

This head merges feat/cli-exit-table through 8cd4fd57 and reads disposition. Current #1078 is e5b11dd6. That head, with #1074 at 0432c9d7 and #1076 at ccdfdbd9, renames the actor header to Omnigraph-If-Actor. This head still sends omnigraph-expect-actor. Rebase onto that rename before merge. The CLI and the server on this head agree with each other.

#1080 at e67386ed is still one commit ahead: the Unix-socket binding digest. The next merge of feat/cli-targets brings it.

#1059's consumes_actor has no Access arm, because that command is not on its base. On rebase, access must stay on the accept side. --as is the actor the report is for.

#1075, #1077, #1083, #1084, #1090, #1096, #1012, #1092, and #1097 do not define this command. #1090 and #1096 will conflict on rebase in the CLI and in error.rs. They do not change the absence rule.

Draft #1017 at 99e595fe still matches the command for a server target, a cluster root, a store in a cluster layout, and a standalone graph that opens. Its sample still omits target_branch. The command and the user page always emit it, and the value is null when absent. The extra field is compatible. Update the sample in the RFC. The access section still says the route was added at contract 0.13. This branch speaks 0.14 from the parent stack.

Draft #1089 at 2b90ba4c asks the 0.14 batch to document each code's statuses and to use Omnigraph-If-Actor. The codes here match that status rule. The header name waits on the rebase above.

Draft #1093 at e3b7f138 still describes an MCP body as outcome_unknown: true and the CLI as execution: unknown. This head uses disposition. Update that sentence when the stack lands.

Liability

One evaluator and one command remain the source of the access answer. exists is the absence fact, taken from the dataset-open chokepoint before OmniError::storage collapses Lance variants into one NotFound kind. A damaged graph no longer reads as an absent graph. The command still fails closed.

The new method is a second open on the standalone path: exists opens __manifest and drops it, then open_read_only opens the graph. A graph removed between the two calls is reported as internal, because the second open's DatasetNotFound collapses to storage NotFound. Both answers refuse. The method comment states that limit. Callers that need the same distinction should call exists rather than match StorageFailureKind::NotFound.

Two snapshot readers stay enough. A store calls read_storage_snapshot_or_report. A config directory calls read_serving_snapshot_or_report.

Validation

Local. rustc 1.97.1. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216. Head 8cc5a930.

  • cargo test -p omnigraph-cli --test integration --locked -- cli_data::access_reports_what_the_caller_may_do_at_a_target -- --exact --test-threads=1 — 1 passed, exit 0.
  • cargo test -p omnigraph-engine --test integration --locked -- lifecycle::open_nonexistent_fails_and_exists_reports_only_dataset_absence -- --exact --test-threads=1 — 1 passed, exit 0. An absent root makes exists false. After init, exists is true. After __manifest/data is removed, exists stays true and open_read_only is storage NotFound.
  • cargo test -p omnigraph-api-types --lib --locked -- every_error_code_documents_its_status -- --exact --test-threads=1 — 1 passed, exit 0.
  • cargo test -p omnigraph-server --test integration --locked -- openapi::openapi_spec_is_up_to_date -- --exact --test-threads=1 — 1 passed, exit 0.
  • cargo clippy --workspace --all-targets --locked -- -D warnings -W clippy::dbg_macro — exit 0. This is the head, not the merge with main.
  • python3 scripts/check-docs.py — exit 0, 353 Markdown files. The docs tools came from scripts/requirements-docs.txt.

Regression. A .gqt case cannot see a CLI process, an exit code, or a deleted Lance data file. The owner is cli_data. The pull request already contains the assertion. The same assertion was applied temporarily on 3a3f8375 and then removed. Nothing was pushed. The worktree is clean at 8cc5a930.

  • Before, at 3a3f8375, with only that assertion added. Same CLI filter. Failed, exit 101, at cli_data.rs:6258. The code was not_found. The message said no graph at the damaged store and named a missing __manifest/data/*.lance file. That is a live manifest that names a missing object.
  • After, at 8cc5a930. The same filter — 1 passed, exit 0. The assertion expects internal and disposition.effects none.

CI on 8cc5a930 fails where it builds the merge with main, for the Path error above. Local clippy and the focused tests on the head pass. No local S3 or Azure run. Runtime was not measured.

Blocking

None in the commits this pull request owns.

Optional

None.

@ragnorc

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #1104. #1090 replaced the interim targets this branch carried, so #1104 ports only the access command onto main's target model. The review findings addressed here (the store arm reading the applied ledger, and not_found only for an absent store) carry over with their tests.

@ragnorc ragnorc closed this Oct 20, 2026
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 20, 2026
Brings in main through ModernRelay#1081, including branch creation at a commit
and the disposition. The local server codes join the error-code
registry as CLI-only entries. A serve refusal and the loopback guard
state a disposition with no effects and their code's own action:
`retry` for local_server_starting, `fix` for the other serve
refusals and `refresh` for the guard's `forbidden`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant