Skip to content

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

Merged
ragnorc merged 69 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-access-v2
Oct 21, 2026
Merged

ragnorc merged 69 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-access-v2

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

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's AccessOutput plus protocol, target and source.

Target How it answers
Server GET /graphs/{id}/access with the target's token; --as is sent as Omnigraph-If-Actor
Direct root with --graph The applied cluster ledger, evaluating the graph's bundle for --as or operator.actor
Direct graph inside a cluster layout The ledger at its root
Standalone direct graph Opened read-only through the bound storage; with no policy, every action is allowed
Catalogue, managed Refused with target_incompatible before any I/O
  • One evaluator. AccessOutput::evaluate moves to api-types, and the server's route and the CLI both use it. The route's output is unchanged.
  • Absent versus damaged. Omnigraph::exists and exists_with_storage tell a missing graph (not_found) from a damaged one, which keeps the open's own internal.
  • Typed refusals. The CLI raises those codes through a small CodedRefusal, so a JSON caller reads the same code from either transport. access is classified as a read, so its failures carry disposition with effects: none.
  • Protocol. version --json reports protocols.access: 1.

Backing issue / RFC

Checklist

  • Tests:
    • cli_data::access_reports_what_the_caller_may_do_at_a_target covers the root, graph-store, standalone, server and refused targets, and the not_found and internal cases. With the old NotFound match restored, the damaged store reports not_found, and the test fails.
    • The engine lifecycle test covers exists on both open paths.
    • forbidden_apis rows cover the two new public reads.
  • Public docs: the access section of docs/user/cli/targets.md, the CLI reference row, the policy guide link, the skill's command index and condensed table, and changelog.d/cli-access-command.added.md.
  • Reviewed against docs/dev/invariants.md: no new write path. The direct arm opens read-only through the target's captured storage binding (invariant 10).

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.
  • Engine lifecycle (1) and forbidden_apis (35); server auth_policy::access… and openapi:: (97); api-types (14) and cluster lib (135).
  • Workspace clippy with -D warnings -W clippy::dbg_macro, cargo fmt --all --check, check-docs.py, typos and check-agents-md.sh are clean.

ragnorc and others added 30 commits October 18, 2026 11:07
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.
ragnorc and others added 7 commits October 20, 2026 12:10
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.
@cursor

cursor Bot commented Oct 20, 2026

Copy link
Copy Markdown

Recommendation

Do not merge yet. The identity path and the shared evaluator are sound. One exit contract is wrong, and the new test locks it in.

omnigraph access says who the caller is at a target and which actions that actor may take. A server target calls GET /graphs/{id}/access with the target token. --as is sent only as Omnigraph-If-Actor. A direct target evaluates the applied graph.{id} bundle, and allows every action when no bundle is installed. Catalogue and managed targets refuse before any I/O. That matches the access section of RFC 1017.

Blocking

A local graph_unavailable does not keep the registry action.

ErrorCode::action maps graph_unavailable to retry. A server answers that code as HTTP 503, with disposition {"effects":"none","action":"retry"} and Retry-After: 60. The CLI keeps that body when it relays the server, and exits 75.

The same code, decided locally, becomes action: refresh and exit 1. CodedRefusal says a classified failure takes the code's action (crates/omnigraph-cli/src/helpers.rs). Failure::classify does that only for Fix when the command did not start. A read always counts as not started, so every other code becomes Refresh (crates/omnigraph-cli/src/command_outcome.rs). exit_for maps (none, retry) to 75 and every other no-effect result to 1.

Classified on 60fea3c9, in omnigraph-cli, then the probe was removed:

  • Local CodedRefusal(GraphUnavailable) on a read: exit 1, {"effects":"none","action":"refresh"}, no retry-after.
  • Relayed server body, same code, status 503, retry-after 60: exit 75, {"effects":"none","action":"retry"}.

access uses this code when the ledger quarantines the graph and when two bundles bind it (crates/omnigraph-cli/src/commands/targets/access.rs). cli_data::access_reports_what_the_caller_may_do_at_a_target asserts exit 1 and effects: none for the quarantine case. It does not assert action. That pins the weaker result.

The class is every local read whose code action is retry: graph_unavailable, service_unavailable, too_many_requests, request_timeout, cancelled, and deadline_exceeded. This command is the first local read that emits graph_unavailable. The next command that does the same inherits the split.

Fix it in Failure::classify, which owns local code-to-action. Keep Retry the same way that arm keeps Fix. Leave access alone. If a quarantine must not be retried, both transports need a different code. The CLI must not invent a second action for one registry code. Then assert disposition.action == "retry" and exit 75 on the quarantine case.

The user doc says a quarantined graph refuses with graph_unavailable, as a server refuses it (docs/user/cli/targets.md). The code matches. The action and the exit do not.

Optional

  1. --target-branch without --branch never reaches evaluate. Clap sets requires = "branch" on AccessArgs, and run_from turns that refusal into exit 64. access_body returns 400 bad_request for the same shape. The shared evaluator does not own the check. A CLI caller and an HTTP caller still see different failures for one mistake.

  2. A direct graph inside a cluster layout answers from the ledger and does not open the store. A member the ledger still serves can report allowed actions while its store is damaged. The standalone path opens first, so a missing store is not_found and a damaged store stays internal. Authorization and health are different questions. Say so in the user doc if callers will treat "allowed" as "this store opens".

  3. A failed cluster-layout probe and not_servable stay uncoded errors, so the JSON body has no registry code. Other local tooling does the same. A probe error is not treated as a standalone graph, which would allow every action. That closed failure is the right choice.

  4. Direct quarantine text includes the recovery reason. The server hides an unavailable graph as not_found unless the actor may read or list graphs (resolve_registered_graph). The direct caller already holds the store. This is not a token leak. Reports use redacted_storage_uri, and the bearer stays on the Authorization header.

  5. A standalone graph reports graph_id: "default". The user doc states that. Keep it as the one synthetic id.

What holds

One evaluator, AccessOutput::evaluate, lives in omnigraph-api-types. The server route and the CLI both call it. The match over policy slots is exhaustive, so a new policy action fails both transports at compile time. omnigraph-api-types now depends on omnigraph-policy. That is the right single place.

Server rules stay in authorize. A static token with no policy gets read. A signed data credential with no policy gets nothing. No actor and no policy is open mode. Direct storage with no bundle allows every action, which RFC 1017 states for direct writes. With a bundle and no actor, direct access returns success and empty lists. A write through enforce still refuses. The integration test checks the empty lists.

--as is the declared actor on direct access (--as, else operator.actor) and an assertion on served access. Without --as the CLI sends no actor header. The integration test checks Bearer and Omnigraph-If-Actor.

Omnigraph::exists is false only for Lance DatasetNotFound. Lance 13.0.0 wraps a missing latest location, and a missing manifest object, as DatasetNotFound (DatasetBuilder::load_by_uri, Dataset::load_manifest). A manifest that names a missing data file still loads, so exists stays true and the open fails. Access then keeps the open's own code.

The command opens read-only. It adds no write path. The JSON report does not carry the token.

Liability

The unique range 93fc09c3..60fea3c9 is 34 files, +1514/−207. That is the command, the shared evaluator, exists, and the quarantine reporting. The larger diff against the PR base is the parent stack (actor header, error bodies, exit table). Judge this command by the unique range.

Five more commands of this kind should call evaluate and CodedRefusal, not grow a second action table. That removes a drift risk. It adds a dependency from API types to the policy crate, which is the cost of one slot table. The disposition bug is the liability that remains: one code, two actions, and a test that records the split.

Validation

Head 60fea3c92af90bbcf6a779322b13634797e814da. Isolated checkout, restored clean after the probes.

  • cargo test -p omnigraph-engine --test integration lifecycle::open_nonexistent_fails_and_exists_reports_only_dataset_absence -- --exact on this head: 1 passed in 0.04s. An earlier run with the bare name and --exact ran 0 tests. That was a filter miss.
  • cargo test -p omnigraph-cli --test integration cli_data::access_reports_what_the_caller_may_do_at_a_target -- --exact on this head: 1 passed in 0.44s. Passed again after the probe below was restored.
  • Same CLI test with a temporary stand-in that treated the open's StorageFailureKind::NotFound as absence (the split exists exists to avoid), then restored: failed at cli_data.rs:5617 with code: not_found where the test expects internal. The body was no graph at .../damaged.omni, disposition {"effects":"none","action":"fix"}.
  • Local versus relayed classification probe in command_outcome (added, run, removed): results in the blocking finding.
  • GQT cannot express CLI identity, exit codes, or the exists-versus-open storage split. The owners are the lifecycle test and cli_data.
  • The parent of 94014b3c has no exists. Its test only checked that open of a missing path returns an error. I did not rebuild that parent.

CI on this commit (run 39491703167): GQT, workspace tests, clippy, rustfmt, S3, Azure, storage upgrade, and DST succeeded. Dependency Guard failed on RUSTSEC-2026-0382, RUSTSEC-2026-0383, and RUSTSEC-2026-0381 (certificate verification and emSign intermediates). CI Gate failed because of that job. Those advisories are outside this command. They still fail the gate.

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.
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.
@ragnorc ragnorc mentioned this pull request Oct 21, 2026
5 of 7 tasks
@ragnorc

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at 4436543, on top of the merge of the new #1078 (2fba7fe).

Blocking: a local graph_unavailable dropped the code's action. Fixed in Failure::classify: a refusal that ran nothing now keeps its code's retry action as it keeps fix, so one code has one action and one exit on both transports. A local quarantine answers {"effects":"none","action":"retry"} and exits 75, as the relayed server body does. access is unchanged.

  • Unit test. command_outcome::a_local_refusal_keeps_its_code_action covers a CodedRefusal read with graph_unavailable (retry, 75), not_found (fix, 1) and internal (refresh, 1).
  • Integration test. The quarantine case in cli_data::access_reports_what_the_caller_may_do_at_a_target now asserts the whole disposition and exit 75 on both the root and graph-store paths.

Optional

  • 2, authorization versus health. The targets guide now says a cluster graph's answer comes from the ledger without opening its store, so an allowed action is authorization, not proof that the store opens.
  • 1, --target-branch without --branch. I left this as is. Clap refusing the command line (64) is the CLI's usage contract for an argument shape, and the server's 400 is its own; moving the check into evaluate would trade one for the other rather than make them one.
  • 3–5 are observations; nothing to change.

The CLI suite (224 lib, 328 integration), clippy -D warnings, fmt and check-docs pass.

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Merge. The blocking exit split is fixed. I found no new blocking item.

omnigraph access says who the caller is at a target and which actions that actor may take. A local graph_unavailable used to say refresh and exit 1. A server says retry and exit 75 for that same code. A caller that retries on 75 did not retry the local refusal.

Failure::classify now keeps a retry action when the command did not start. It already kept fix. One registry code has one action and one exit on a local decision and on a relayed server body. The targets guide says exit 75. It also says that an allowed action on a cluster graph is authorization, not proof that the store opens.

Earlier findings

  • Blocking. A local graph_unavailable dropped the code's retry action. Resolved at 44365430.
  • Optional. --target-branch without --branch is still Clap exit 64 on the CLI and HTTP 400 on the server. Open. The author left that split. A usage error and an HTTP body are different contracts.
  • Optional. An allowed cluster action is not proof the store opens. Resolved in docs/user/cli/targets.md.
  • Optional. A failed cluster-layout probe stays an uncoded error. Unchanged. It does not fall through to a report that allows every action.
  • Optional. Direct quarantine text includes the recovery reason. Unchanged. The report does not leak the token.
  • Optional. A standalone graph reports graph_id: default. Unchanged.
  • CI Gate was red on cargo deny advisories. Resolved on this head. Dependency Guard and CI Gate pass at 0f180fdd.

What I checked

The contract is the disposition row in RFC 1017. Before a write, the action is the code's action. ErrorCode::action is that table. The old classifier replaced every action except fix with refresh.

The new arm reads ErrorCode::action and keeps retry when nothing ran. It does not special-case graph_unavailable.

// 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.

  • The six retry codes report retry and exit 75. They are too_many_requests, service_unavailable, graph_unavailable, request_timeout, cancelled, and deadline_exceeded.
  • Every fix code stays fix and exit 1.
  • Every refresh code stays refresh and exit 1.

A relayed body still uses its own disposition. This arm does not rewrite it. Retry-After remains the header a server sends on 429 and 503. A local refusal has no header. Exit 75 follows effects: none and action: retry.

A bare recovery_required or outcome_unknown, with no detail, still becomes refresh. No caller builds those codes that way. An engine recovery error carries its detail, and the earlier arm selects recover.

The merge 919312c1 conflicted in command_outcome.rs. It kept access as a read, the retry arm, and the unit test. 484b8f34 matched an automatic merge. 0f180fdd only rejoins one paragraph in the CLI reference so the page stays within 350 lines. The words do not change. 268ee918 only records step_unpublished after an earlier publication. It does not change this refusal.

Liability

The 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 retry code locally gets the same exit without a new rule. The change adds no type, no store, and no second table.

Validation

Reviewed head 0f180fdd9db4e48e46229ffcde10e7d19c3e5c85. The classifier before the fix is 2fba7fe64a7e0798852ecd066643194f8af2647d. The diff of command_outcome.rs between those commits is this arm, its comment, and the unit test.

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 44365430 retry arm removed.

  • After, unit. cargo test -p omnigraph-cli --lib command_outcome::tests::a_local_refusal_keeps_its_code_action -- --exact. 1 passed.
  • Before, unit. That test plus the other five retry codes. Failed. GraphUnavailable was {"effects":"none","action":"refresh"}. The test expects retry.
  • After, the same six retry codes, plus not_found and internal. Passed. A probe of every code matched the list above. I removed the probe.
  • After, integration. cargo test -p omnigraph-cli --test integration cli_data::access_reports_what_the_caller_may_do_at_a_target -- --exact. 1 passed, 0.37s.
  • Before, that same integration test. Failed. Exit 1. Body code graph_unavailable, action refresh. The test expects exit 75.

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.

@ragnorc
ragnorc added this pull request to the merge queue Oct 21, 2026
Merged via the queue into ModernRelay:main with commit 092411d Oct 21, 2026
32 checks passed
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