Skip to content

feat(server)!: say on every error body whether the request ran - #1076

Merged
ragnorc merged 38 commits into
ModernRelay:mainfrom
ragnorc:feat/error-outcome
Oct 21, 2026
Merged

ragnorc merged 38 commits into
ModernRelay:mainfrom
ragnorc:feat/error-outcome

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1074 (Omnigraph-If-Actor).

What & why

Every error body says what the request did and what to do next, computed by the server for the request it answered, in a disposition object:

  • effects: none when nothing took effect (a refusal before a write, a typed refusal of its one publication, or a failure the engine proved published nothing); partial when an earlier step of the same operation published and the failing step wrote nothing, such as a load that forked its branch; unknown when the server cannot say.
  • action: fix (correct the credential, input, path or client), refresh (read the state that refused it), retry (busy or unavailable, with Retry-After), recover or reconcile.

A write whose completion the server could not settle answers 500 outcome_unknown. Every code is in one registry with the statuses it is sent under, which openapi.json publishes; codes that were told apart only by their details gain their own (precondition_failed, recovery_required, external_blob_source, change_feed_gap, branch_start_refusal), and 408, 413, 415 and 416 stop answering bad_request. Deployment routes and MCP tool errors answer the same body. CLI data-write failures print the same disposition in place of command_outcome.

Backing issue / RFC

Checklist

  • Change is focused (one logical change)
  • Tests: api_error_tests (each refusal class, settled step after a fork, status and Retry-After per code, branch start refusal), every_error_code_documents_its_status, core completion-evidence tests, boot_settings::deployment_routes_refuse_with_the_error_body, MCP error payloads, OpenAPI schema, CLI disposition across cli_data, cli_queries, parity_matrix, system_remote
  • Public docs: server "Errors and retries", troubleshooting HTTP errors and "Failed data-write commands", MCP results and errors, Blob and branching guides, the skill; changelog.d/error-outcome.breaking.md, error-code-statuses.breaking.md, mcp-error-body.breaking.md
  • Reviewed against docs/dev/invariants.md: a disposition never claims less than the server can prove (invariant 11)

Local verification

  • RUST_MIN_STACK=16777216 cargo test -p omnigraph-server -p omnigraph-cli -- --test-threads=6 (server binary built first, openapi.json regenerated) — all pass
  • clippy for the server, CLI and API types with -D warnings, cargo fmt --all --check, python3 scripts/check-docs.py, typos — clean

Notes for reviewers

Breaking for clients that read command_outcome or outcome, told errors apart by a missing code, conflict or bad_request, or read MCP's {code, message} and outcome_unknown: true.

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.

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

This commit adds one outcome object to every server error body. execution is not_started or unknown. effects is none or unknown. action is retry, refresh, recover, or reconcile. An owned write whose completion is uncertain is reported as outcome_unknown when the action is reconcile. CLI data-write failures print that same object in place of command_outcome. They keep the server's outcome when that request was the command's only effectful one.

Reviewed commit: 558889830d2f10695bf1af1103ae3373844ac272, the last commit, on top of #1074 (bf64f4e2). The parent actor_mismatch refusal stays in the before-effects set, which matches a header checked before graph lookup and effects.

Request changes. One path reports effects: none after a publication. The HTTP load path and the uncertain-completion path match the decision table.

Blocking

MCP load with from drops the compound mark. A typed refusal after the fork has published says the request did not run.

POST /load and POST /load/ndjson call mark_compound_write() on the request task. into_output reads that record on the same task. A typed refusal then says execution: unknown, effects: unknown, action: refresh.

/mcp is admitted as a read. Admission runs the handler inside scope_request on one task. call_tool then does run_owned → spawn_read of execute. server_load runs on that second task. A tokio::task_local does not cross tokio::spawn. mark_compound_write ignores a missing record (let _ = try_with). The error comes back with owned_write set, because OwnedResponse::result copies that fact onto the error. The compound fact stays on the spawned task. api_failure builds the body on the request task, and request_compound_write() there is still false. A typed refusal (read_set_conflict, key conflict, resource limit, and the other typed refusals) takes the single-publication branch: not_started, effects: none, action: refresh, original code kept.

That under-claims a publication. load_as publishes the fork in branch_create_from_as before the payload. On a later failure it strips pre-effect evidence, so the error no longer says "nothing published". The HTTP body then says nothing ran. A caller that trusts not_started will treat the new branch as absent.

A temporary test at this head failed in that shape. Outer scope_request, tokio::spawn of mark_compound_write plus after_owned_write, then into_output on the outer task. Actual body: execution: not_started, effects: none, action: refresh, code: conflict. The same-task test an_error_after_a_write_reports_what_it_may_have_done stays green and does not cover this spawn.

Carry compound on ApiError, the way owned_write already crosses submit. Set it on the error run_json_load and server_load_ndjson return once a prior publication has happened, and read that field in into_output. A missing task-local must not mean "this operation publishes once". That reading is the unsafe direction. Please add the spawned-task regression beside the existing same-task test. GQT cannot see this body.

Optional

  1. The changelog and troubleshooting.md describe a narrower rule than ApiError::outcome. They say an unsettled write is unknown with code: outcome_unknown, and a refusal or lost conflict is not_started. The function also returns execution: unknown for a settled untyped error after a write (the original code stays, action reconcile) and for a typed refusal when compound is set (the typed code stays, action refresh). server.md says every error body carries a code. observation_changed in deployment.rs still clears code on the 503 status-read race. Describe the decision table, and give that 503 a code or name the omission.

  2. outcome.check is absent. Draft RFC rfc: graph access and cross-process writes #1017 at 7c72c7e7ed2b42fb0e5b3f5140d8235444ddcf33 puts branch, head_before, and the attempt id on an unknown write. OutcomeOutput has the three fields only. ApiError carries no attempt id. Add check before this merges with the RFC, or change the RFC if callers settle the write from the branch they already sent.

  3. Both load handlers call mark_compound_write whenever from is set, including when the target branch already exists and branch_create_from_as does not run. A typed refusal of that load is then unknown / refresh, and the load publication was refused before it started. Callers refresh and reconcile, which is the safe direction. It still disagrees with "unknown only when the attempt could not be settled". Mark compound from branch_created, and store it on the error (same fix as the blocking finding).

RFC #1017

Compared with docs/rfcs/2026-10-16-graph-access-serving-and-cross-process-writes.md at 7c72c7e.

The implementation matches these parts: one ErrorOutput plus outcome; the execution, effects, and action values; uncertain completion reported as outcome_unknown on the owned-write path; registry codes for precondition, recovery, external blob source, and change-feed gap.

These parts differ:

  • The code reports unknown for a settled untyped post-write error and for a compound typed refusal. The RFC limits unknown to an attempt the server could not settle, and then requires code: outcome_unknown.
  • outcome.check is missing (optional finding 2).
  • Exit codes follow the previous table. A single served not_started still exits 1. Effects unknown still exits 1. The new exit table is feat(cli)!: exit with what a failed command's outcome says #1078, commit 71dc46fc, which already stacks on this head.
  • The HTTP contract version is unchanged. The RFC asks for one bump covering access, expect-actor, outcome, and the registry. The sibling PRs defer that bump. One bump for the stack is the right place.
  • An uncertain owned write still closes admission, and the process still stops (WriteOwner::finish(true), fatal "owned completion uncertain"). The RFC's keep-serving rule waits on bug: one storage write with an unknown outcome stops the server until a manual unlock #1067. This commit matches that gate.

Managed problem / managed_problem is outside this commit.

Siblings and ordering

The review surface is the last commit: 22 files, +623/−143. #1074 is the parent and composes cleanly. actor_mismatch is in the before-start set.

Liability

owned_write on the error is the fact that survives submit's spawn. The compound fact is a second record, a task-local, written on a different task than the one that renders MCP. The next tool or route that calls a handler from spawn_read misses the same way. Five more publication doors, each with its own mark_* local, means five more silent "publishes once" answers. One field on ApiError, set by the handler that knows a prior publication happened, is the record into_output already trusts for owned_write.

The HTTP single-publication path is the right shape. One decision function. One shared OutcomeOutput for the server and the CLI. The CLI adopts the server outcome for a single effectful request. That removes the private CLI enums. Keep that.

Validation

Head 558889830d2f10695bf1af1103ae3373844ac272. Isolated checkout. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216.

  • cargo test -p omnigraph-server --lib api_error_tests:: — 17 passed, including an_error_after_a_write_reports_what_it_may_have_done.
  • cargo test -p omnigraph-server --test integration -- data_routes::settings_ data_routes::mutate_graph_commit_precondition_issue_365 openapi::change_error_output_schema_matches_every_change_route_detail openapi::error_code_schema_has_expected_variants openapi::openapi_spec_is_up_to_date — 16 passed.
  • cargo test -p omnigraph-cli --lib -- client::tests::command_outcome_keeps_earlier_effects_and_scopes_independent_invocations app::tests::output_failures_retain_dispatch_evidence_without_leaking_to_the_next_invocation app::tests::transfer_receipt_delivery_failure_keeps_write_evidence_without_replay — 3 passed.

Temporary regression, then the tree was restored clean at this SHA. A spawned compound mark rendered on the outer request task returned NotStarted / None / Refresh / Conflict. A second probe, ApiError::internal returned through OperationRuntime::submit inside scope_request, passed as outcome_unknown. Deployment apply uses that path.

GQT cannot express an HTTP or MCP error body, a spawned task, or a deployment route. There is no row, count, or column change to pin with a .gqt case. The regression belongs in api_error_tests.

I did not run the full CLI integration binaries, the GQT corpus, or a live MCP load against a graph. Lance is not on this path.

Inline comments

crates/omnigraph-server/src/lib.rs:1872 (blocking)

request_compound_write() here is the request task's task-local. MCP load sets the mark on a different task.

/mcp is a read, so admission runs the handler inside scope_request. call_tool then does run_owned → spawn_read of execute, and server_load calls mark_compound_write on that spawned task. A tokio::task_local does not cross tokio::spawn. mark_compound_write drops the write when the local is missing (let _ = try_with in operations.rs). The error returns with owned_write set, because OwnedResponse::result copies that fact onto the error before the oneshot. api_failure renders on the request task, where this call is still false.

A typed refusal after load_as has published the fork (branch_create_from_as, then without_pre_effect_evidence) therefore comes out not_started / effects: none / refresh, with the typed code. The fork is reachable. A caller that trusts not_started will treat it as absent.

POST /load on the write door renders on the task that set the mark, and an_error_after_a_write_reports_what_it_may_have_done covers that same-task shape. It does not cover this spawn.

Put compound on ApiError. Set it on the error run_json_load and server_load_ndjson return once a prior publication has happened, and read that field here the way owned_write is read. A missing local must not mean "one publication". Please add a regression that marks compound inside tokio::spawn and renders on the outer scope_request task. At this head that shape returns NotStarted / None / Refresh / Conflict.

docs/user/operations/troubleshooting.md:73 (optional)

This sentence, and the changelog note, say an unsettled write is the unknown case and that its code is outcome_unknown, and that a refusal or lost conflict is not_started. ApiError::outcome also returns execution: unknown for a settled untyped error after a write (the original code stays, action reconcile) and for a typed refusal when compound is set (the typed code stays, action refresh). Please describe that decision table.

server.md (Errors and retries) says every error body carries a code. observation_changed in deployment.rs still sets code to None on the 503 status-read race, and into_output leaves it that way because the completion is settled. Give that 503 a code, or say this status race omits one.

crates/omnigraph-api-types/src/lib.rs:2211 (optional)

Draft RFC #1017 at 7c72c7e gives an unknown write outcome.check with branch, head_before, and the attempt transaction id. This struct stops at action. The error path has no attempt id to copy. Add check before this merges with the RFC, or change the RFC if callers are expected to settle the write from the branch they already sent.

crates/omnigraph-server/src/handlers.rs:2539 (optional)

This runs for every from, including a load whose target branch already exists, so branch_create_from_as does not publish. A later typed refusal is then unknown / refresh even though that load's publication was refused before it started. Callers refresh and reconcile, which is safe, and the body still over-claims.

Mark compound from the receipt's branch_created, and store that fact on the ApiError (see into_output). The task-local alone is invisible to MCP, which calls server_load from spawn_read. The same from.is_some() mark is in server_load_ndjson.

…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`.
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.
@ragnorc

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at e5d4747 (fix 5e46f92, plus the merge of #1074's fixes).

Blocking: MCP compound loads.

  • ApiError carries compound, and the outcome is computed from the error's own fields.
  • ReadObserver::spawn_read gives its task its own request record, and errors leaving it carry that record.
  • mark_compound_write and request_compound_write are gone.
  • Tests:
    • api_error_tests::an_error_after_a_write_reports_what_it_may_have_done now runs both cases through spawn_read.
    • The MCP load with from into a new branch onto an existing id reports unknown/unknown/refresh, and the branch exists.
    • The HTTP twin is in data_routes.

Optional

  1. troubleshooting.md has a five-row decision table matching outcome(). The observation_changed 503 is now service_unavailable, and ApiError.code is no longer optional.
  2. outcome.check is not added here. RFC rfc: graph access and cross-process writes #1017 phase 1 lists it, and it needs an engine attempt id this stack doesn't have yet.
  3. Compound is set only when the load forks: authorize_load_scope reports it from the branch listing it already reads. A refusal on an existing branch is not_started.

Also:

  • Every extractor rejection, plus unrouted 404s and 405s, now answers the error body, through ApiJson/ApiQuery/ApiPath and router fallbacks. A source guard refuses bare extractors, and auth_policy::malformed_and_unrouted_requests_answer_the_error_body covers 16 cases.
  • engine_error_output_after(error, after_write, compound) and engine_read_error_output let the CLI project an embedded error as a request would.
  • The RustFS S3 test now expects code: external_blob_source.

The stack's other changes to this page need its last line.

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

What this change does

A caller that gets an error must know whether the request ran. This commit puts one outcome on every server error body. execution is not_started or unknown. effects matches it. action is retry, refresh, recover, or reconcile. One function, ApiError::outcome, decides that from facts stored on the error. That is the right place. A task-local flag cannot cross spawn_read.

The fix commit 5e46f926 moves the compound fact onto ApiError. spawn_read now keeps a request record and copies the submitted-write fact onto an error that leaves the task. Load handlers set compound from a branch list in authorize_load_scope. The merge e5d4747a brings in the updated expect-actor branch. actor_mismatch stays a before-start refusal. The assertion response header is added beside the contract header. I left the expect-actor behavior to #1074.

Earlier findings

Reviewed commit 55888983. Comment #1076 (comment). The author note #1076 (comment) matches findings 1 and 2. Finding 4 is only the common case.

  1. Blocking. An MCP load lost the compound mark across spawn_read. Resolved. ApiError holds compound. spawn_read scopes the request and calls with_request_facts. The unit test runs the refusal through spawn_read and expects unknown / refresh. The MCP test and the HTTP fork test expect the same, and the branch exists. I ran those tests at e5d4747a. They passed. At 55888983 the same shape fails. See Validation.

  2. Optional. The docs described a narrower rule than outcome, and a status-read race cleared code. Resolved. troubleshooting.md has the five-row table. observation_changed answers 503 with service_unavailable. ApiError.code is required. server.md says every error body carries a code and an outcome.

  3. Optional. outcome.check is absent. Still open. OutcomeOutput still has execution, effects, and action. RFC 7c72c7e7 still asks for branch, head_before, and the attempt id on an unknown write. This stack still has no attempt id. See optional finding 1.

  4. Optional. Both load handlers marked compound whenever from was set. Partly resolved. A load onto a branch that still exists now reports not_started. The HTTP test covers that, and the commit count stays the same. The new flag is the branch list from authorize_load_scope, not the engine's branch_created. That list can be false after a real fork. I ran that case. See the blocking finding.

Blocking

A load can say not_started after its fork published

authorize_load_scope returns !branch_exists from branch_list. Both load handlers copy that bool onto the error with in_compound_write. The engine decides the fork later, in load_as_inner. On failure after branch_created, without_pre_effect_evidence removes the pre-effect mark and does not record the fork. The HTTP bool is then the only signal, and it is stale.

The NDJSON handler takes the list before it reads the body. I paused that body after the list, deleted feature, and then sent an append of a Person that already exists on main. Delete returned 200. The load returned 409 with key_conflict. The outcome was not_started, effects: none, action: refresh. GET /branches listed feature again. The fork is a publication. The body says nothing ran.

The JSON load has the same gap between its list and the engine list. The comment on authorize_load_scope accepts this window and compares it to the branch_create check. The engine calls branch_create_from_as again and checks policy there. The outcome flag has no second check.

This breaks the contract this PR states. A typed refusal is not_started only when that one publication did not happen. It also breaks invariant 11. An outcome must not claim less than the server can prove. The server can prove the fork inside load_as_inner.

Set the compound mark where branch_created is known, on the error load_as_inner returns. Map that mark to ApiError.compound in from_omni. Do not use CompletionEvidence::Uncertain for it. That path rewrites the code to outcome_unknown and the action to reconcile. The table wants the typed code kept and action: refresh.

A pre-list that treats every from as compound hides this race and rejects the existing-branch test, which expects not_started when no fork happens. The engine fact satisfies both tests.

#1078 at 04a9da53 includes this head. Its embedded load copies the same list before load_graph_batch_as_with_receipt (crates/omnigraph-cli/src/client.rs). A handler-only patch leaves that copy. One mark on the engine error serves both.

Add a regression at this layer. The temporary NDJSON test above shows the current head reports not_started after the fork. It was not committed.

Inline, crates/omnigraph-server/src/handlers.rs line 2483 (the comment that accepts this window; Ok(!branch_exists) is line 2530):

Ok(!branch_exists) is a second branch list, taken before load_as_inner decides to fork. I paused an NDJSON body after this list, deleted feature (200), then appended a Person that already exists on main. The load returned 409 key_conflict with not_started / none / refresh, and GET /branches listed feature again.

load_as_inner knows branch_created on that failure. without_pre_effect_evidence then drops the fact. Put a prior-publication mark on that error and map it to ApiError.compound in from_omni. Do not use CompletionEvidence::Uncertain. That path rewrites the code to outcome_unknown.

The JSON load has the same gap after its list. The NDJSON load has it for the whole body read. The branch_create check is not the same window. The engine calls branch_create_from_as again. This flag has no second check.

#1078 at 04a9da53 copies this list in the embedded load. One engine mark serves both. Please add a regression. The temporary test was not committed.

Optional

  1. outcome.check is still missing. The RFC example for an unsettled write carries branch, head_before, and attempt. Add that object with the RFC, or change the RFC if callers settle from the branch they already sent. This stack has no attempt id yet. Do not invent one in this commit.

  2. KeyConflictOutput still says the operation is effect-free when this output is returned. A load that forks and then hits a key conflict returns this detail with effects: unknown. The HTTP test ingest_creates_branch_returns_metadata_and_stamps_actor is that case. Say that a key conflict refuses the row write, and an earlier step of the same request may already have published.

  3. Optional query parameters on GraphBatchLoadQuery, SnapshotQuery, and CommitListQuery changed in openapi.json from a null union to a plain type (string, integer, or LoadMode), still required: false. #[into_params(parameter_in = Query)] caused that. The breaking note does not name it. Name it for generated clients.

  4. ErrorCode::GraphUnavailable says it does not authorize replay. The unavailable-graph error is built with owned_write: false, so outcome is not_started and action: retry. The new table says the same for a 503 before a write. Align the variant text with that action.

Where the code and the RFC differ

RFC head 7c72c7e7, section Outcomes and refusals. The PR says it merges only after that RFC is accepted.

The RFC says a server reports unknown only when it could not settle the attempt, and then the code is outcome_unknown. This code also reports unknown for a settled untyped error after a write, and keeps that code, with action: reconcile. It also reports unknown for a typed refusal after an earlier publication, keeps that code, and uses action: refresh. The troubleshooting table matches the code. The RFC does not. Update rule 6 to this table before acceptance, or narrow the code to the RFC.

outcome.check is in the RFC and not in OutcomeOutput. That is optional finding 1.

Exit codes in the RFC are #1078. That branch now contains e5d4747a (04a9da53). I did not review its exit table here.

The RFC asks for one HTTP contract bump for the stack. This commit does not bump it. The siblings defer the same bump. Keep one bump for the stack.

The RFC, with issue #1067, wants an uncertain owned write to keep serving. This commit still closes admission and stops the process on that write. That matches the gate the RFC already states. I am not asking this commit to change it.

Managed problem bodies stay outside this commit. That matches the RFC split.

Siblings and ordering

#1074 is the second parent of the merge. Review its actor assertion on #1074. This merge keeps actor_mismatch in the before-start set, and identify_response sends omnigraph-http-api-assertions.

#1078 must take this fix if this branch changes. It already copied the stale list. Fix the engine mark here so that update can drop the copy.

#1073, #1075, #1077, #1080, #1081, #1059, #1012, and draft #675 do not own this error body. I found no new conflict with them in this delta. #1080 now contains the exit-table merge, so it also contains this head.

Liability

One outcome function and one OutcomeOutput are the shape to keep. Facts on the error remove the task-local that spawn_read could not see. The branch list puts a second source of truth back. Five more doors that guess "already published" from a pre-check would repeat this under-claim. The extractor change is a real widening. Unknown routes, 405, and a JSON body that used to answer 422 now share the error body. That is tested. It is worth the client break the changelog names.

engine_error_output_after has no caller on this branch. #1078 is the caller. Leave it.

Correctness comes first here. The extra list is the part that costs later change. The outcome object itself does not.

Validation

Head e5d4747a794b0b824da643704e6b7129fdb7bfff. Worktree /tmp/pr-1076. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216.

cargo test -p omnigraph-server --lib -- an_error_after_a_write_reports_what_it_may_have_done an_embedded_engine_error_reports_what_a_served_request_would handlers_take_extractors_that_refuse_with_the_error_body -- --test-threads=8

Result: 3 passed, 0 failed.

cargo test -p omnigraph-server --test integration -- malformed_and_unrouted_requests_answer_the_error_body ingest_creates_branch_returns_metadata_and_stamps_actor mcp_write_tools_are_opt_in_conditional_and_policy_gated openapi::change_error_output_schema_matches_every_change_route_detail openapi::error_code_schema_has_expected_variants openapi::openapi_spec_is_up_to_date data_routes::settings_ -- --test-threads=8

Result: 18 passed, 0 failed.

Regression for the spawn fix. GQT cannot see an HTTP body or a spawned task. The owner is this unit test.

Before, commit 55888983, temporary assertion in an_error_after_a_write_reports_what_it_may_have_done. The child called mark_compound_write inside spawn_read and returned a read-set conflict. Expected unknown / refresh with code conflict.

Command: cargo test -p omnigraph-server --lib an_error_after_a_write_reports_what_it_may_have_done -- --nocapture

Result: failed. Actual outcome not_started / none / refresh. The code assert passed.

A first run used --exact with the short name and ran 0 tests. That run is not evidence.

The edit was restored. It was not pushed.

After, the same test at e5d4747a includes the spawn_read cases and passed in the lib run above.

The fork race is not a GQT case. I added a temporary NDJSON test on e5d4747a, ran it, and restored it. It was not pushed. Delete returned 200. The load returned 409, key_conflict, and not_started / none / refresh, with feature present again.

Limitations

I did not run the RustFS S3 test. It needs OMNIGRAPH_S3_TEST_BUCKET. The diff expects external_blob_source and not_started / refresh for a missing external blob during load.

I did not re-run the CLI lib tests. I read the merge. actor_mismatch stays before effects. The CLI exit table belongs to #1078.

I did not run clippy, the workspace suite, or GQT. I did not read a CI run for this head.

This path does not call Lance. I did not fetch a Lance page.

Recommendation: request changes. Fix the compound mark from the engine's branch_created, and add the regression.

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

The new commit only shortens the errors paragraph. The Rust sources are the same as e5d4747a. The blocking bug is still open.

A caller uses outcome to decide whether a failed load created a branch. This commit changes one paragraph in docs/user/operations/server.md. The compound flag still comes from a branch list taken before the fork. A caller that trusts not_started can miss a published branch. The paragraph does not change that.

Prior findings at 23027f54

  1. Blocking. Still open. A load can say not_started after its fork published. authorize_load_scope still returns !branch_exists from branch_list. Both load handlers still copy that bool with in_compound_write. load_as_inner still sets branch_created, and without_pre_effect_evidence still drops that fact. git diff e5d4747a 23027f54 touches only server.md. The race shown on e5d4747a still applies.

  2. Optional. Still open. outcome.check is absent. OutcomeOutput still has execution, effects, and action. RFC head 7c72c7e7 still asks for branch, head_before, and attempt. This stack still has no attempt id. Leave check with the RFC.

  3. Optional. Still open. KeyConflictOutput still says the operation is effect-free when this output is returned. A load that forks and then conflicts returns this detail with effects: unknown.

  4. Optional. Still open. Optional query parameters on GraphBatchLoadQuery, SnapshotQuery, and CommitListQuery are a plain type with required: false. The breaking note still omits that change. This commit does not touch openapi.json or the changelog.

  5. Optional. Still open. ErrorCode::GraphUnavailable still says it does not authorize replay. The unavailable-graph error is still built with owned_write: false, so the outcome is not_started and action: retry.

Docs delta

e5d4747a..23027f54 is the errors paragraph only. It still says every error body carries a code and an outcome, and it still points at the decision table. The clause "a write the server could not settle is outcome_unknown" matches into_judged_output for the reconcile case. A recovery intent keeps its own code. The linked table states that exception. I found no new claim and no new defect in the wording. The last line stays long so later stack edits can keep "Admitted writes continue after disconnect." #1078 at d5cda7d5 and #1083 already show that same paragraph.

RFC

RFC head is still 7c72c7e7, section Outcomes and refusals. The code did not move, so the disagreements are the same.

The RFC says a server reports unknown only when it could not settle the attempt, and then the code is outcome_unknown. This code also reports unknown for a settled untyped error after a write, keeps that code, and uses action: reconcile. It also reports unknown for a typed refusal after an earlier publication, keeps that code, and uses action: refresh. The troubleshooting table matches the code. Update rule 6 to this table before acceptance, or narrow the code to the RFC.

outcome.check is in the RFC and absent here. That is optional finding 2.

Exit codes in the RFC belong to #1078. This commit does not bump the HTTP contract. Keep one bump for the stack. An uncertain owned write still stops the process. The RFC already waits on #1067 for the keep-serving rule.

Siblings

#1078 at d5cda7d5 still reads the branch list before the embedded load and calls compound() from that list. The mark belongs on the engine error from branch_created, so that copy can go. This docs commit does not change that order. #1073, #1074, #1075, #1077, #1080, #1081, #1083, #1059, #1012, and draft #675 do not own this flag.

Liability

One outcome function and one OutcomeOutput remain the right shape. The branch list is still a second source of truth for "already published". The next door that guesses from a pre-check repeats this under-claim. The paragraph change adds no runtime state.

Validation

Head 23027f54f4ede65d40e0a6997c10ecee16f62ad4. Worktree /tmp/pr-1076. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216.

cargo test -p omnigraph-server --lib -- an_error_after_a_write_reports_what_it_may_have_done an_embedded_engine_error_reports_what_a_served_request_would handlers_take_extractors_that_refuse_with_the_error_body -- --test-threads=8

Result: 3 passed, 0 failed.

cargo test -p omnigraph-server --test integration -- malformed_and_unrouted_requests_answer_the_error_body ingest_creates_branch_returns_metadata_and_stamps_actor mcp_write_tools_are_opt_in_conditional_and_policy_gated openapi::change_error_output_schema_matches_every_change_route_detail openapi::error_code_schema_has_expected_variants openapi::openapi_spec_is_up_to_date data_routes::settings_ -- --test-threads=4

Result: 18 passed, 0 failed.

CI on this exact SHA is green, including Test Workspace, omnigraph-server --features aws, both RustFS jobs, and GQT. I did not re-run the fork race. The Rust files match e5d4747a, where that race returned 409 key_conflict with not_started / none / refresh after the fork. GQT cannot express this HTTP body. These unit tests stay green while the race remains.

Recommendation: request changes. Set the compound mark from branch_created on the error load_as_inner returns, and add the regression.

Inline, crates/omnigraph-server/src/handlers.rs line 2530:

Ok(!branch_exists) still comes from the branch list taken before load_as_inner. A branch deleted after this list, then forked by the load, still reports not_started. Set compound from branch_created on the error load_as_inner returns. Map that mark in from_omni. Do not use CompletionEvidence::Uncertain. That path rewrites the code to outcome_unknown.

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.
@ragnorc
ragnorc requested a review from azimafroozeh as a code owner October 19, 2026 07:09
@ragnorc

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at 1abd5bb (fix), after 722a104 (the merge of #1074's contract bump).

Blocking: the fork is now the engine's fact.

  • CompletionEvidence::PartlyPublished says an earlier step of the owning operation published and a later one failed.
  • load_as_inner marks every failure after branch_created with it through OmniError::after_earlier_publication. That covers the post-fork seam window too, which returned the error unmarked before.
  • The mark outranks the definite BeforeEffect/Unpublished at any nesting depth. before_effect, unpublished and without_pre_effect_evidence keep it, and it never hides Uncertain.
  • from_omni maps it to ApiError.compound and leaves containment where no evidence leaves it. So a typed refusal keeps its code with unknown/refresh, and nothing goes through CompletionEvidence::Uncertain.
  • authorize_load_scope returns () again: its listing only authorizes. Both handlers lost in_compound_write.
  • engine_error_output_after drops its compound argument, because embedded callers read the fact from the error. feat(cli)!: exit with what a failed command's outcome says #1078's merge drops its copied branch list for the same mark.

Regression: data_routes::a_load_that_forks_a_branch_deleted_during_its_body_reports_the_fork reproduces your harness.

  1. It holds the NDJSON body after the route authorizes, which its first poll signals.
  2. It deletes feature through the served engine.
  3. It sends a Person already on main.

It expects 409 key_conflict with unknown/unknown/refresh and feature listed again. The core test an_earlier_publication_outlives_definite_evidence_and_never_hides_uncertainty pins the composition. The existing-branch case in ingest_creates_branch_returns_metadata_and_stamps_actor still reports not_started.

Optional

  1. outcome.check: left out, as you suggest, until writes have an attempt id. RFC rfc: graph access and cross-process writes #1017 rule 6 now says it arrives with the attempt identity of cross-process writes, and that a caller settles from the branch it sent until then.
  2. KeyConflictOutput now says the refused rows wrote nothing, and that an earlier step of the same request may already have published, which outcome states.
  3. The breaking note names the plain query-parameter types for GET /snapshot, GET /commits and POST /load/ndjson.
  4. GraphUnavailable now says the request did not start and may be retried once the graph is served.

RFC differences: rule 6 in #1017 now carries the server's outcome table, with the typed code kept for a refusal after an earlier publication and for a settled untyped error after a write. The contract bump is #1074's, so this branch now speaks 0.14 through the merge.

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.
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 19, 2026
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.
The actor assertion header is `Omnigraph-If-Actor` (`IF_ACTOR_HEADER`).
The error-code registry keeps its shape, with `actor_mismatch` naming the
new header; the published spec is regenerated, and no spelling of the old
header remains outside released history.
A served 401, an unsupported contract and an `actor_mismatch` ask the caller to fix the credential or client; a policy denial still asks for refresh.
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 20, 2026
Brings in ModernRelay#1074's Omnigraph-If-Actor header through ModernRelay#1076, and ModernRelay#1076's
final error-code registry.
@cursor

cursor Bot commented Oct 20, 2026

Copy link
Copy Markdown

This review covers ccdfdbd95663c3c229b473c1af6fb34fa73fb651. The previous review covered 78963e94.

Why

A caller that receives an error has to know whether the request ran, and what to do next. A wrong answer makes that caller miss a published branch, or replay a write that already landed.

What

Every server error body carries disposition. effects is none, partial, or unknown. action is fix, refresh, retry, recover, or reconcile. The CLI prints the same object on a data-write failure. execution is gone. effects: none means the request did not start.

How

ApiError::disposition is the one decision. It reads facts stored on the error. The engine records an earlier publication as CompletionEvidence::PartlyPublished. A settled later step keeps its own proof in step_unpublished. from_omni maps that proof to unpublished and compound. The code stays the step's code. The action stays the code's action. An owned write the server could not settle still becomes outcome_unknown, with effects: unknown and action: reconcile.

Impact

Callers can trust effects: none. A load that forked a branch and then failed a settled step says partial and keeps internal. A refused branch start says branch_start_refusal, effects: none, and action: fix.

Earlier finding

The optional finding at 78963e94 is resolved.

A settled unpublished step, after an earlier publication, was answered as 500 outcome_unknown. after_earlier_publication dropped the Unpublished proof. Commit 023daa75 keeps that proof. unpublished() then after_earlier_publication() of an I/O error is internal, partial, refresh.

Blocking

  1. The merge with current main (85a46138, fix(server): delete a branch named like a static branch route #1091) does not compile.

git merge-tree --write-tree 85a46138 ccdfdbd9 exits 0 and writes tree 0c46a794. There are no conflict markers. The merged server_branch_action still takes axum::extract::Path. This branch removed Path from the server prelude in crates/omnigraph-server/src/lib.rs (Extension, OriginalUri, Request, and State remain). handlers.rs imports that prelude with use super::*. The merged function has no Path in scope.

CI on this head builds that merge. Run 39378123022 fails in Test Workspace, Lint, the aws build, RustFS, Azurite, the format fence, and storage-upgrade compatibility. Run 39378123070 fails GQT served the same way. The error is E0531 / E0425 at crates/omnigraph-server/src/handlers.rs:3110. Clippy then reports E0277 at crates/omnigraph-server/src/lib.rs:3467. server_branch_action is not a Handler, because its second argument does not type-check. The compiler hint use std::path::Path is the wrong type. Import axum::extract::Path in handlers.rs.

  1. The new branch route answers 405 with no error body.

branch_method_not_allowed in the merged handlers.rs (line 3121) returns 405 and Allow only. method_not_served in lib.rs (line 3576) returns ApiError::method_not_allowed. Axum does not call the router fallback when the route has its own fallback. PUT /branches/feature and POST /branches/diff therefore skip the error body this PR requires. Success-path errors of merge, diff, merge-base, and delete still go through ApiError. Keep Allow. Return ApiError::method_not_allowed from branch_method_not_allowed.

Optional

  1. unpublished() leaves an error unchanged once any completion evidence is present (crates/omnigraph-core/src/error.rs). without_pre_effect_evidence on PartlyPublished sets step_unpublished to false and keeps the wrapper. A later unpublished() still does nothing. The load path is safe. commit_detached calls unpublished() first. The loader then calls after_earlier_publication (crates/omnigraph/src/loader/mod.rs). No current caller wraps first and marks unpublished second. Set step_unpublished when unpublished() sees an existing PartlyPublished on an opaque error. The next compound operation then cannot drop a settled step by calling the two methods in the other order.

  2. changelog.d/error-code-statuses.breaking.md says openapi.json names each code's status. The opening sentence of that note, and docs/user/operations/troubleshooting.md, say a code is sent under the statuses the registry lists. branch_start_refusal lists 409 and 410. Say "statuses" in that later sentence too.

  3. The 412 row in docs/user/operations/troubleshooting.md tells the caller to refresh a Blob ETag or re-read the branch. actor_mismatch is also 412. Its action is fix (ErrorCode::action, and the disposition section of the same page). Add the --as correction to that row.

One status list, and two statuses for one code

These agree. 6533ab50 gives each code the statuses it is sent under. Most codes have one. branch_start_refusal has 409 and 410. The detail selects which (ErrorOutput::http_status and ApiError::status). 0c8d35c7 documents that. Several codes already share one status (409, 410, 412, 500, and 503). The commit title "one code per status" describes the other direction. The registry and the guide match the code.

The rename

disposition is the field in the server, the CLI JSON, OpenAPI (ErrorOutput and ChangeErrorOutput require error, code, and disposition), the docs, and the tests. The HTTP contract stays 0.14. Header constants stay lowercase. Published names are Title-Case. This head says Omnigraph-If-Actor (IF_ACTOR_HEADER = omnigraph-if-actor). I found no remaining omnigraph-expect-actor on this head. The merge a59a4c85 brings in #1074 through 0432c9d7. ccdfdbd9 updates the CLI tests that still expected action: refresh for a refused credential, contract, or actor. A policy denial stays refresh.

The #1016 note

Resolved. ApiErrorDetails::class is an exhaustive match. BranchStartRefusal is Refusal. An owned branch-create that is refused answers branch_start_refusal, effects: none, and action: fix. branch_start_refusal_is_typed_409_or_410 and the row in an_error_after_a_write_reports_what_it_may_have_done cover it. not_retained is 410. The other reasons are 409.

Deployment and MCP

0ff41ae4 answers deployment and MCP failures with the same error body. A deployment route with no resolved actor is 403 forbidden, effects: none, action: refresh. A wrong content type stays 415. A cancelled or deadline-exceeded write is outcome_unknown, effects: unknown, action: reconcile, status 500. A cancelled read keeps cancelled or deadline_exceeded and has no HTTP status.

Siblings and RFCs

#1074 at 0432c9d7 is contained in this head. The header name matches.

#1078 at 8cd4fd57 contains 0c8d35c7 and its own test for each refusal's action. It still publishes omnigraph-expect-actor. Rebase it onto this head.

#1081 at 3a3f8375, #1083 at 7175120b, and #1084 at 24be23f4 still have OutcomeOutput.execution. They sit on 78963e94. Rebase them onto disposition.

#1096 edits omnigraph-core/src/error.rs and omnigraph-server/src/lib.rs from a parent that has no PartlyPublished. Rebase it onto this composer.

#1075, #1077, #1090, #1059, and #1012 do not define this error body. #1059 edits CLI --as classification and can conflict in planes.rs on rebase.

Draft #1017 and #1089 already say disposition and Omnigraph-If-Actor. This head matches that wording.

Draft #1093 still describes today's MCP body as outcome_unknown: true and the CLI as execution: unknown. Update that sentence when this PR lands. The proposed 500 outcome_unknown matches the code name this PR already sends.

Liability

One disposition function and one DispositionOutput are the shape to keep. PartlyPublished { step_unpublished } removes the under-claim. The route no longer keeps a second copy of "did the load fork". The cost is one bit and a compose order. Uncertain still wins. The OR of step_unpublished is safe for the calls that exist, because after_earlier_publication adds false and the true bit comes from an inner proof. Five more compound operations that wrap with after_earlier_publication and then call unpublished() would drop the proof again. That is optional finding 1. Fix it in unpublished(), which owns the mark.

The status list on each code is the registry. ApiError::status and ErrorOutput::http_status both special-case BranchStartRefusal through the detail's own status. Keep them on that one function so a third code with two statuses does not grow a third copy.

Tradeoff

Correctness comes first. The extra evidence bit is the simple record of a fact the engine already knows. A coarser outcome_unknown for every post-fork 500 would be smaller and would hide a settled step. That is the wrong trade for a caller who must decide whether to replay.

Validation

Reviewed head ccdfdbd95663c3c229b473c1af6fb34fa73fb651. Worktrees /tmp/pr-1076 and /tmp/pr-1076-before. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216. Temporary probes were removed. Nothing was pushed.

The head itself compiles. The merge with main does not. See blocking finding 1.

Settled step. GQT does not show an HTTP body. The owner is the server unit test.

Before, parent 0ff41ae4, temporary test temp_settled_step_after_earlier_publication_keeps_its_code:

cargo test -p omnigraph-server --lib temp_settled_step_after_earlier_publication_keeps_its_code

Result: failed. code was OutcomeUnknown, effects unknown, action reconcile, message io: detached manifest put failed. Expected Internal, partial, refresh. A first run used --exact with the short name and ran 0 tests. That run is not evidence.

After, on this head, the same shape is in an_embedded_engine_error_reports_what_a_served_request_would. It passed in the lib run below. cargo test --locked -p omnigraph-core --lib a_settled_step_after_an_earlier_publication_keeps_its_proof passed (1 test).

One code under its own status. Before, b13d90f2, the blob If-Match assertion changed to expect PreconditionFailed:

cargo test -p omnigraph-server --lib blob_reader_errors_keep_the_exhaustive_http_mapping

Result: failed. left Conflict, right PreconditionFailed. Status was already 412.

After, the same test on this head passed.

Deployment and MCP bodies. Before, 6533ab50.

cargo test -p omnigraph-server --test boot_settings temp_deployment_errors_use_the_error_body

Result: failed. Status 500. Body: Missing request extension: Extension of type omnigraph_server::identity::AuthenticatedActor. Expected 403 and a JSON forbidden body. The 415 case in that probe did not run.

cargo test -p omnigraph-server --lib a_write_whose_wait_ends_first_reports_an_unknown_outcome

Result: failed. Body was {"code":"cancelled","outcome_unknown":true,...}. status was null. Expected status 500 and error.code outcome_unknown.

After, on this head:

cargo test --locked -p omnigraph-server --lib -- blob_reader_errors_keep_the_exhaustive_http_mapping a_write_whose_wait_ends_first_reports_an_unknown_outcome an_embedded_engine_error_reports_what_a_served_request_would an_error_after_a_write_reports_what_it_may_have_done branch_start_refusal_is_typed

Result: 5 passed. An earlier attempt failed to compile because the shared target still had an older omnigraph-compiler. That attempt is not evidence. cargo clean -p omnigraph-compiler and the rerun above passed.

cargo test --locked -p omnigraph-server --test boot_settings deployment_routes_refuse_with_the_error_body

Result: 1 passed.

cargo test --locked -p omnigraph-cli --test integration as_on_a_served_write_asserts_the_actor

Result: 1 passed. At 0c8d35c7 the same test failed locally: left action fix, right refresh. CI run 39371527905 failed that test on that commit. ccdfdbd9 updates the expectation.

cargo clippy --locked -p omnigraph-server -p omnigraph-cli -p omnigraph-core -p omnigraph-api-types --all-targets -- -D warnings -W clippy::dbg_macro

Result: exit 0 on this head. CI clippy builds the merge with main and fails as in blocking finding 1.

python3 scripts/check-docs.py

Result: Documentation OK, 349 Markdown files.

git merge-tree --write-tree 85a4613831e5047a0c988ea0ad37768fa7dff4bb ccdfdbd95663c3c229b473c1af6fb34fa73fb651

Result: exit 0, tree 0c46a794018f549f0818a29e5b9a769502d3fb5e.

Limitations

I did not compile the merge tree locally. CI did. I did not run the workspace test suite or the GQT corpus locally. Served GQT in CI failed at the same Path compile error. I did not run RustFS or Azure locally. This head pins Lance 11.0.0. main contains the Lance 13 upgrade. CI compiled through the engine and failed in the handler. I did not re-run the fork route test on this head. That test passed at 0c8d35c7, and these last commits do not change the loader.

Recommendation

Do not merge yet. Rebase onto 85a46138. Import axum::extract::Path. Return the error body from branch_method_not_allowed, and keep Allow. The disposition change itself is sound. The earlier optional finding is resolved.

Main replaced the legacy routing flags with explicit targets (ModernRelay#1090). The
actor assertion sits on that structure: `GraphClient::from_target` and the
served cluster client send `--as` as `Omnigraph-If-Actor` on a server
target, where ModernRelay#1090 refused it, and only a managed target still refuses
`--as`. `accepts_actor` keeps deciding which commands take it. The HTTP
contract stays 0.14.
It brings main's explicit targets (ModernRelay#1090) and Lance 13. A target refusal
that ModernRelay#1090 reports with the command's evidence carries `disposition`, as
every other data-write failure does.
…ges within the cap

The served cluster entry now takes the actor `--as` asserts. The server, CLI reference and cluster guides fold the merged text back under 350 lines.
Brings the actor branch's cluster remote test fix and its page trims.
Main's branch action route took axum's `Path` and answered 405 with no body. It takes the typed extractor, and its 405 carries `method_not_allowed` and the disposition beside the `Allow` header.
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 20, 2026
Brings in main through ModernRelay#1076, including ModernRelay#1090's explicit targets. The
exit table keeps its classification: the removed use and profile
commands leave it, and the target and credential commands, which keep
their own failure reports, join the unclassified ones. ModernRelay#1078's tests
move to --target, ModernRelay#1090's usage-error tests expect exit 64, and the
diagnostics checks pass the destination line ModernRelay#1090 prints first.
ragnorc and others added 2 commits October 20, 2026 12:19
…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

This review covers 15eb669979f91f1459be29a464cbb95abfeaecba. The previous review covered ccdfdbd9.

Why

A caller that receives an error has to know whether the request ran. A 405 that skips the guards also tells an anonymous caller which methods a protected path serves.

What

Two fixes land after ccdfdbd9. The branch path's 405 carries the error body beside Allow. A method a protected route does not serve now passes the same guards as a method it serves.

How

server_branch_action takes ApiPath. branch_method_not_allowed returns ApiError::method_not_allowed and sets Allow. guarded installs method_not_allowed_fallback before bearer authentication, the contract check, and the actor assertion. Axum 0.8.9 replaces only a route's default fallback (MethodRouter::default_fallback). A fallback set first stays, and MethodRouter::layer wraps it, so the guards cover that 405. The router-wide fallback still serves the public routes.

Impact

A caller the guards admit can trust effects: none on this 405. An anonymous caller of a protected path receives 401. The method list stays behind the guards.

Earlier findings

  1. Blocking. Resolved. The merge with main did not compile: server_branch_action took Path, and the server prelude no longer has it. The route now takes ApiPath. That type is in the prelude, and the extractor guard accepts it. Parent 25cf087b fails cargo check with E0425 / E0531 at handlers.rs:3111 and E0277 because the function is not a Handler. This head compiles. Test Workspace on this SHA passed.

  2. Blocking. Resolved. The branch path answered 405 with Allow and no body. The body is now the error body. Allow stays. remote_branch_delete_flow_works checks the code and disposition.

  3. Optional. Still open. OmniError::unpublished still returns the error unchanged once any completion evidence is present. A later unpublished() on an existing PartlyPublished does not set step_unpublished. No current caller uses that order. This delta does not change error.rs.

  4. Optional. Still open. changelog.d/error-code-statuses.breaking.md still says openapi.json names each code's status. branch_start_refusal has 409 and 410.

  5. Optional. Still open. The 412 row in docs/user/operations/troubleshooting.md still tells the caller to refresh a Blob ETag or re-read the branch. actor_mismatch is also 412, and its action is fix.

Blocking

None.

Optional

The three notes above. No new one.

Liability

guarded is the one place that puts the method fallback inside the shared guards. The next protected router should call it. MCP stays on route_service, which that outer fallback does not replace, and PUT /mcp with no token is 401. A later MethodRouter added beside MCP, with the layers copied and the fallback left to the router-wide call, would open this leak again.

The branch path keeps its own 405 so Allow can name DELETE plus the action's method. Other routes use method_not_served. Both go through ApiError::method_not_allowed, so disposition has one decision.

Tradeoff

Correctness comes first. The caller learns the methods after the guards admit the request. On the catalogue and deployment routers a wrong method also passes admission, as a method they serve does. That is the same rule, and the cost is one admission slot for a request that does no graph work.

git merge-tree --write-tree b692610c 15eb6699 exits 0 (tree 6cfd75bb). #1100 does not edit the server or this error type.

Validation

Head 15eb669979f91f1459be29a464cbb95abfeaecba. Body-fix parent 25cf087beac9174bfbd4392190e002daeeb37371. Guard-fix parent 3a35355e45201027796166e78189a395a8ef1386. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216. Temporary edits were not pushed. GQT cannot express an HTTP status, a body, or middleware order. The owner is the server integration suite.

cargo check --locked --offline -p omnigraph-server --lib at 25cf087b: failed. Path is not in scope. server_branch_action is not a Handler.

Body regression at 25cf087b, with only the extractor changed to ApiPath so it compiled, and with this head's body assertions added:

cargo test --locked --offline -p omnigraph-server --test integration data_routes::remote_branch_delete_flow_works -- --exact --nocapture

Result: 1 failed. Status was 405 and Allow matched. The body parse panicked: EOF while parsing a value, column 0.

Guard regression at 3a35355e, with the new test and the old router:

cargo test --locked --offline -p omnigraph-server --test integration -- --nocapture an_unserved_method_on_a_protected_route_answers_after_every_guard

Result: 1 failed. PUT /graphs/default/snapshot with no token returned 405 method_not_allowed, effects: none, action: fix. Expected 401. A run that used --exact with the short name executed 0 tests. That run is not evidence.

After, on this head, after cargo clean -p omnigraph-server so a shared target could not reuse the parent binary:

cargo test --locked --offline -p omnigraph-server --test integration -- --nocapture --test-threads=8 an_unserved_method_on_a_protected_route_answers_after_every_guard remote_branch_delete_flow_works malformed_and_unrouted_requests_answer_the_error_body

Result: 3 passed.

CI run 39489959127 on this SHA: Test Workspace, Lint, and GQT ordinary, served, and dst passed. CI Gate failed because Dependency Guard failed. cargo deny reports RUSTSEC-2026-0381, RUSTSEC-2026-0382, and RUSTSEC-2026-0383. This commit does not change Cargo.lock. I did not compile the merge tree with b692610c.

Recommendation

Approve. The two blocking findings are resolved. The guard order is the right layer for the leak. The three optional notes are unchanged.

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.
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 21, 2026
@ragnorc
ragnorc added this pull request to the merge queue Oct 21, 2026
Merged via the queue into ModernRelay:main with commit d111c40 Oct 21, 2026
32 checks passed
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 21, 2026
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