Repository navigation
feat(cli): say who you are at a target and what you may do - #1104
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.
…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.
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.
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.
The stack's other changes to this page need its last line.
A server that predates `omnigraph-expect-actor` ignores it, so `--as` on a served write would run as the credential's actor. The earlier fence, a response header listing the assertions a server enforces, was a second compatibility record beside the contract version: two servers speaking 0.13 could disagree about the assertion, and every further assertion would extend an enum on every response. The contract moves to 0.14 instead, the one bump the access route, `outcome` and the code registry also ride. A 0.13 server answers discovery with its own contract, so the CLI refuses it before sending, and it refuses a 0.14 request before reading the header. The assertions header, its constant and the CLI's discovery check are gone, and so is the enum on every OpenAPI response. The guides, the developer pages, the skill, the version samples and the fixtures name 0.14, and a breaking note says to upgrade the CLI, server, Studio and HTTP integrations together. The server guide says a repeated, padded or empty header is refused, and that `/mcp` compares it after authentication; the cluster skill names the subcommands that take `--as`.
Brings in HTTP contract 0.14 and the removal of the assertions response header, so this branch speaks the contract its outcome field rides.
…failed A load names whether it forked its branch only from inside the engine: the routes took that fact from the branch listing their authorization reads, before the load lists the branches again and decides. A branch deleted between the two, as one can be while an NDJSON body is still arriving, was forked by the load, and a refusal of its rows then answered `not_started` with no effects although the fork had published. `CompletionEvidence::PartlyPublished` records that an earlier step of the owning operation published and a later one failed. The load marks every failure after its fork with it, the window between the fork and the rows included. It outranks the definite `BeforeEffect` and `Unpublished`, which no longer cover the operation, survives composition, and never hides `Uncertain`. The server reads it as the compound fact and leaves containment where no evidence leaves it, so a typed refusal keeps its code with `unknown` and `refresh`. The routes' list-derived flag and `in_compound_write` are gone, and `engine_error_output_after` drops its `compound` argument: embedded callers get the fact from the error. A route test deletes the branch while the NDJSON body waits and expects the fork reported; the core test pins the composition rules. The key conflict and `graph_unavailable` descriptions say what they now mean, and the breaking note names the plain query-parameter types in openapi.json.
Brings in HTTP contract 0.14 and the engine's mark of an earlier publication. The embedded load no longer lists the branches to predict a fork: the engine marks every failure after its fork with `PartlyPublished`, so `embedded_write_output` reads that mark, and `Evidence.compound` and `command_outcome::compound` are gone. A branch deleted after a list and before the load's own check can no longer report `not_started` after the fork published.
The outcome unit test passed even if `load` predicted a fork for every `--from`, because a policy refusal ignores the prediction. A process test now drives the call site. Onto a branch that exists, `--from` forks nothing, and a refused payload exits 1 with `not_started`. Onto a missing branch the fork publishes first, and the same refusal exits 2 with `unknown` while the branch stays. The troubleshooting guide's "exit alike" sentence is narrowed to those cases: a policy refusal is `not_started` on both planes, and a refusal after a fork that did publish is `unknown` on both.
The access route from ModernRelay#1073 now rides HTTP contract 0.14 with the actor header: its policy guide example sends 0.14, and its OpenAPI operation lists the contract value and the optional omnigraph-expect-actor parameter like every other protected route.
Brings in main through ModernRelay#1074: the access route from ModernRelay#1073 under HTTP contract 0.14.
Brings in main through ModernRelay#1076: the access route from ModernRelay#1073 under HTTP contract 0.14.
The key-conflict description said the refused rows wrote nothing; the OpenAPI vocabulary guard keeps graph vocabulary in the published description, so it now says the refused write changed nothing.
Brings in the key-conflict description in graph vocabulary.
…Actor Every other request precondition that answers 412 is an `If-*` header (`If-Match`, `Omnigraph-If-Graph-Commit`); `Omnigraph-Expect-Actor` echoed HTTP's `Expect`, whose failure is 417. The header is now `Omnigraph-If-Actor` and the constant `IF_ACTOR_HEADER`, with the same contract: it only narrows a request, a mismatch is `412 actor_mismatch` before graph lookup or effects, and it arrives with HTTP contract 0.14. The release note is renamed with it.
…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.
When every applied graph is quarantined, the serving snapshot reader refuses the whole snapshot with `cluster_no_healthy_graphs`, so `access` flattened the refusal into an uncoded error before it reached the quarantine check. It now reads that refusal first: when it is the only error and the reader attributes a diagnostic to the asked graph, the graph answers `graph_unavailable` with those reasons, on a root target and on the graph's store alike. Every other readiness failure keeps the cluster's not-servable error, now built in one place for `policy`, `queries` and `access`.
…s guards The router-wide method fallback replaced each protected route's default fallback, layers included, so a method the route does not serve answered 405 before bearer authentication, the contract check, the actor assertion and admission. Each protected router now installs the fallback before its guards, through one helper that owns their order.
RecommendationDo not merge yet. The identity path and the shared evaluator are sound. One exit contract is wrong, and the new test locks it in.
BlockingA local
The same code, decided locally, becomes Classified on
The class is every local read whose code action is Fix it in The user doc says a quarantined graph refuses with Optional
What holdsOne evaluator, Server rules stay in
The command opens read-only. It adds no write path. The JSON report does not carry the token. LiabilityThe unique range Five more commands of this kind should call ValidationHead
CI on this commit (run I did not measure performance. I did not run the full workspace suite locally. |
The failure logs main added read ApiError's status, which this branch derives from its code.
…lication unpublished() returned an error unchanged once it carried any evidence, so a step marked after its operation recorded an earlier publication lost its own proof. It now sets step_unpublished on that evidence.
The 412 row now says what to do about actor_mismatch, the changelog names branch_start_refusal's two statuses, and the CLI reference says a served blob put, blob clear, commit publish and commit drop send --as as the actor assertion.
Brings in main through ModernRelay#1076: the ModernRelay#1074 squash, ModernRelay#1092 and ModernRelay#1103.
A review found that a refusal the CLI decides itself kept its code's action only when that action was fix. A locally decided graph_unavailable, a quarantined graph for access, became refresh and exit 1, while the same code relayed from a server is retry and exit 75. A refusal that ran nothing now also keeps a retry action, so one code has one action and one exit on both transports. The targets guide says an allowed action is authorization, not proof that a cluster graph's store opens.
|
Addressed at 4436543, on top of the merge of the new #1078 (2fba7fe). Blocking: a local
Optional
The CLI suite (224 lib, 328 integration), clippy |
RecommendationMerge. The blocking exit split is fixed. I found no new blocking item.
Earlier findings
What I checkedThe contract is the disposition row in RFC 1017. Before a write, the action is the code's action. The new arm reads // crates/omnigraph-cli/src/command_outcome.rs
match output.code.map(ErrorCode::action) {
Some(Action::Fix) => Action::Fix,
Some(Action::Retry) if not_started => Action::Retry,
_ => Action::Refresh,
}I classified every registry code as a local read on this head.
A relayed body still uses its own disposition. This arm does not rewrite it. A bare The merge LiabilityThe fix removes a second action for one code. The new arm sits in the classifier that already owns local exits. Another command that emits a ValidationReviewed head GQT cannot express a CLI exit or a disposition. The owner is the CLI. Local isolated checkout of the head. Each before run is that tree with the
CI at this commit: CI Gate pass, Dependency Guard pass, Test Workspace pass. Devin Review skipped because the trial expired. That is not a review of this head. I did not re-run the workspace suite or GQT locally. Those jobs are green on this commit. The delta does not change storage, so I did not re-qualify Lance. I did not push. |
Stacked on #1078 (exit table), itself on #1076 and #1074. Review only the four commits after 93fc09c. This replaces #1081, which was built on #1080's interim targets that #1090 superseded.
What & why
omnigraph access --target NAME [--graph G] [--branch B] [--target-branch T]says who you are at a target and what you may do there, so a client can show the actions it is allowed instead of learning them from a 403. Its output is the access route'sAccessOutputplusprotocol,targetandsource.GET /graphs/{id}/accesswith the target's token;--asis sent asOmnigraph-If-Actor--graph--asoroperator.actortarget_incompatiblebefore any I/OAccessOutput::evaluatemoves to api-types, and the server's route and the CLI both use it. The route's output is unchanged.Omnigraph::existsandexists_with_storagetell a missing graph (not_found) from a damaged one, which keeps the open's owninternal.CodedRefusal, so a JSON caller reads the samecodefrom either transport.accessis classified as a read, so its failures carrydispositionwitheffects: none.version --jsonreportsprotocols.access: 1.Backing issue / RFC
Checklist
cli_data::access_reports_what_the_caller_may_do_at_a_targetcovers the root, graph-store, standalone, server and refused targets, and thenot_foundandinternalcases. With the oldNotFoundmatch restored, the damaged store reportsnot_found, and the test fails.existson both open paths.docs/user/cli/targets.md, the CLI reference row, the policy guide link, the skill's command index and condensed table, andchangelog.d/cli-access-command.added.md.Local verification
cargo build -p omnigraph-server && RUST_MIN_STACK=16777216 cargo test -p omnigraph-cli -- --test-threads=6: 222 lib and 328 integration pass.auth_policy::access…andopenapi::(97); api-types (14) and cluster lib (135).-D warnings -W clippy::dbg_macro,cargo fmt --all --check,check-docs.py,typosandcheck-agents-md.share clean.