Repository navigation
Conversation
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.
|
Recommendation: approve. Merge after RFC #1017 is accepted.
The requested head was Earlier findingsThe review at
New commit
The split is the right one. A config directory and a storage root are different addresses. BlockingNone. Optional
RFC #1017 at
|
…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.
|
Recommendation: approve. Merge after RFC #1017 is accepted. Take the
Reviewed head: Earlier findingsBoth optional findings from that review are resolved.
Disposition
Error codes
Merge with mainCurrent main is The head itself compiles. Local clippy on Siblings and RFCsThis head merges #1080 at #1059's #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 Draft #1017 at Draft #1089 at Draft #1093 at LiabilityOne evaluator and one command remain the source of the access answer. The new method is a second open on the standalone path: Two snapshot readers stay enough. A store calls ValidationLocal. rustc 1.97.1.
Regression. A
CI on BlockingNone in the commits this pull request owns. OptionalNone. |
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`.
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"]}GET /graphs/{graph_id}/access(feat(server): report what a credential may do through an access route #1073), with the target's token, and--asasserted as on every served command.source: direct) for the actor--asoroperator.actornames: a cluster graph by the policy bundle bound tograph.<id>in its applied state (no actor, no actions), and a standalone graph, which has no policy, allows every action, as direct writes do.read/export/changeon the branch and the branch-targeted actions with it as the target;branch_create/branch_mergefrom branch to target) moves intoomnigraph-api-typesasAccessOutput::evaluate, with theAccessActiontoPolicyActionmapping, so the route and the CLI cannot drift.version --jsonreportsprotocols.access.Backing issue / RFC
Checklist
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 driftdocs/user/cli/targets.md, the skill;changelog.d/cli-access-command.added.mdpolicy explaindoes; served decisions stay the server'sLocal verification
cargo build -p omnigraph-server && RUST_MIN_STACK=16777216 cargo test -p omnigraph-cli -- --test-threads=6— 242 lib + 336 integration passRUST_MIN_STACK=16777216 cargo test -p omnigraph-serverand-p omnigraph-api-types— all pass-D warnings,cargo fmt --all --check,python3 scripts/check-docs.py,typos,python3 scripts/check-dependency-sources.py— cleanNotes for reviewers
omnigraph-api-typesgains a direct dependency onomnigraph-policy(already in its tree through the engine) to own the action mapping.