Skip to content

fix(cli)!: send a server token only to the server it is keyed to - #1030

Merged
ragnorc merged 7 commits into
ModernRelay:mainfrom
ragnorc:claude/vibrant-faraday-5ad4b4
Oct 17, 2026
Merged

ragnorc merged 7 commits into
ModernRelay:mainfrom
ragnorc:claude/vibrant-faraday-5ad4b4

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1022

The CLI sent OMNIGRAPH_BEARER_TOKEN to every remote URL that matched no servers: entry, including a literal --server https://… the operator does not control. It also sent it to every declared server that had no keyed token. This change stops the CLI from reading the variable.

Behavior

  • A served request carries only the token keyed to the operator server whose url the request falls under: OMNIGRAPH_TOKEN_<NAME> first, then ~/.omnigraph/credentials. Every other request carries no Authorization header. Nothing is refused before sending, because anonymous servers are valid.

  • When such a request gets HTTP 401, the error says how to bind a credential:

    • no credential is stored for server `prod`: run `omnigraph login prod` or set OMNIGRAPH_TOKEN_PROD
    • no credential is bound to https://graph.example.com: declare it under `servers:` in ~/.omnigraph/config.yaml, then run `omnigraph login <name>` or set OMNIGRAPH_TOKEN_<NAME>

    The address in the message has any URL userinfo removed. Human output prints the hint on its own line. --json carries it as the CLI's own missing_credential field ({"server": NAME, "hint": …} or {"unbound": ADDRESS, "hint": …}); the server's body, including error, is printed unchanged.

  • Two servers: names that spell the same OMNIGRAPH_TOKEN_<NAME> (a-b and a_b, prod and PROD) are refused when the operator config loads, since one variable would bind both servers.

  • A token is matched on where the request is sent, not on the address as written. A request matches a server when it has the same scheme, host and port (or the same Unix socket), and its path lies under the server's url once . and .. segments resolve. Before, https://host/tenant-a/../tenant-b, or a socket path ending in ../other.sock, carried a declared server's token to a different path prefix or socket. That defect predates this PR; review found it.

  • A served --graph must be one path segment of ASCII letters, digits, - or _ (the graph-id alphabet). Any other value is refused before a request is sent, so no URL-parser spelling (.., %2e%2e, \, a tab-split .\t., %2F) can move a request off the graph's route.

  • Managed credentials (login --api, OMNIGRAPH_CONTROL_TOKEN) are unchanged.

Implementation

  • helpers::served_client(server_root, url) replaces resolve_remote_bearer_token. It is now the only place a served client gets its credential. Its callers are served reads and writes, graphs list, cluster plan/apply/status --server and operator aliases.
  • A client without a credential keeps the reason. GraphHttpClient::send attaches it to a 401 response. The shared refusal decoder stores it in RemoteErrorCli::missing_credential, which Failure and the read error path serialize next to the server's body; plain-text refusals and Blob read errors append it to their message.
  • graph_http::RequestTarget computes where a request goes using the transport's own parsing: the endpoint, plus the path segments. OperatorConfig::find_server_for_target picks the deepest declared server whose target leads the request's. resolve_server_flag accepts a --graph only as one plain segment.

Docs

  • RFC 0007 D5 rule 3 is amended, with a decision-log entry.
  • The "Use a server" section of the CLI guide states the rule.
  • The CLI reference, the troubleshooting table and the skill follow it.
  • The clusters guide examples now use a declared production server with OMNIGRAPH_TOKEN_PRODUCTION.
  • changelog.d/cli-unkeyed-bearer-token-removed.breaking.md is the breaking note, with migration steps.
  • changelog.d/cli-credential-transport-target.fixed.md covers the transport-target match and the --graph refusal.

This touches no Lance domain: it only changes how the CLI picks a credential.

Tests

  • main_tests::resolve_remote_credential_sends_no_unkeyed_token_issue_1022. Before the fix it failed: an unregistered URL received Some("global-token").
  • system_local::local_cli_keyed_credentials_authenticate_url_matched_server_issue_1022. Before the fix it failed: a declared server with no keyed token authenticated through the unkeyed variable. It now also pins both hints.
  • cli_data::data_write_outcomes_and_retry_permission_issue_466_issue_976 pins missing_credential in --json output for every write command and checks that the server's body is otherwise unchanged.
  • blob_cli::tests::remote_unauthorized_names_the_missing_credential covers Blob reads.
  • operator::tests::servers_sharing_a_token_variable_are_refused covers the name-collision refusal.
  • cli_data::served_credential_follows_the_sent_url_issue_1022 covers the three dot-segment cases at the wire: a path-prefixed url followed by .., a --graph that is not one plain segment (seven values, including .\t. and %2F), and a socket path that resolves to another socket. Before the fix it failed: the request to /tenant-b carried Bearer tenant-a-token. operator::tests::find_server_for_target_matches_where_the_request_is_sent pins the matcher.
  • Tests that relied on the fallback now bind their token to a declared server through TestServer::credential or KeyedServer.

Local results:

  • CLI unit tests: 167 passed.
  • CLI integration: 323 passed with --test-threads=6. At full parallelism, 18 tests that spawn a server timed out under machine load; they passed on rerun.
  • Server integration: 265 passed.
  • cargo fmt --all --check, cargo clippy -p omnigraph-cli --all-targets --locked -- -D warnings -W clippy::dbg_macro, check-docs.py, check-agents-md.sh, typos and check-fix-regression.py all pass.

The CLI fell back to OMNIGRAPH_BEARER_TOKEN for every remote URL that
matched no `servers:` entry, including a literal `--server https://...`
the operator does not control, and for every declared server without a
keyed token. It no longer reads that variable. A served request carries
the token keyed to the operator server whose `url` it falls under
(OMNIGRAPH_TOKEN_<NAME>, then the credentials file) and nothing else.

`served_client` pairs each served HTTP client with that credential. A
client without one keeps the reason; when its request gets a 401, the
refusal names the server to declare or the `omnigraph login` command to
run. The address in that message drops URL userinfo.

Tests that relied on the fallback bind their token to a declared server
through a test operator home. RFC 0007 D5 rule 3 is amended; the CLI
guide, CLI reference, clusters guide, troubleshooting table and skill
describe the keyed rule, and a breaking release note says how to migrate.

Fixes ModernRelay#1022
Comment thread crates/omnigraph-cli/src/graph_http.rs Resolved
Comment thread docs/user/cli/index.md Resolved
Comment thread crates/omnigraph-cli/src/helpers.rs Resolved
Comment thread crates/omnigraph-cli/src/helpers.rs Resolved
@cursor

cursor Bot commented Oct 17, 2026

Copy link
Copy Markdown

Review: COMMENT. Merge after one blocking fix.

Recommendation: The fix for #1022 is correct, and the regression is proven before and after. Fix the rustfmt failure (B1) and then merge. The three optional items can go to follow-ups. O1 is the most important of them.

Reviewed head: 903cda0861b71cf6a28df2b000b7cd09917e944b. Merge base: 9c2b3679e8f6.

What and why

  • What: The CLI no longer reads OMNIGRAPH_BEARER_TOKEN. A served request carries only the token keyed to the operator server whose url the request URL falls under. That token comes from OMNIGRAPH_TOKEN_<NAME> first, then from ~/.omnigraph/credentials.
  • Why: Before this change, the unkeyed token went to every remote URL with no servers: match, and to every declared server with no keyed token. The CLI could send a secret to a server the operator does not control.
  • Impact: This is a breaking change for users who export OMNIGRAPH_BEARER_TOKEN, but the break is loud.
    • A server that requires auth returns 401 for a missing token (require_bearer_auth, omnigraph-server/src/handlers.rs:424-446). The CLI adds a hint to that 401.
    • A server with no tokens configured ignores the token in both versions.
    • So the change cannot silently turn an authenticated actor into an anonymous one.

Contract check

  • RFC 0007 D5 rule 3 says "a token is sent only to the server it is keyed to". The unkeyed fallback broke this rule. The PR removes the root cause, not a symptom. The RFC amendment, the decision-log entry and the breaking changelog note are present.
  • served_client is now the only place a served client gets its credential. Every apply_bearer_token call site gets its token from it: reads, writes, graphs list, cluster plan/apply/status --server and aliases.
  • Redirects are off (graph_http.rs:227, Policy::none()), so a 3xx response cannot move the token to another host.
  • login only writes the credentials file. It sends nothing.
  • Managed credentials (--api, OMNIGRAPH_CONTROL_TOKEN) are unchanged.
  • Invariant 10 is unchanged: the server still resolves the actor from the token.

Software liability: A bearer token sent to the wrong server is a credential disclosure. The receiver can replay it as the operator's actor until someone rotates it. The old fallback made this easy by mistake, for example by pasting a --server https://… URL from a README. This PR closes that path and strictly reduces exposure. Migration is a one-time config change, and the 401 hint explains it.

Blocking (1)

  • B1. Format (rustfmt) fails, so CI Gate is red. The cause is crates/omnigraph-cli/src/graph_http.rs:315. I reproduced it locally with cargo fmt --all --check. Run cargo fmt --all.

Optional (3)

  • O1. Pre-existing; recommended follow-up. The matcher compares the raw URL string, but the request goes to the parsed URL. With dot segments, a declared server's token reaches a sibling path prefix or a different Unix socket. I reproduced this on head and on base, with a literal URL, with a named server plus --graph ../.., and with unix://…/og.sock/../evil.sock. The new doc sentence "only to URLs under its url" is false for these inputs. Fix: match on parsed URLs and reject . and .. in --graph. Details are in the inline comment on helpers.rs:337.
  • O2. The hint is appended to the server's error text (helpers.rs:733), while --json errors are documented as the server's own body. A separate CLI-owned field would keep that body unchanged.
  • O3. a-b and a_b (and prod and PROD) map to the same OMNIGRAPH_TOKEN_… variable. Consider refusing such names at config load.

Regression proof

I added temporary probe tests to crates/omnigraph-cli/tests/integration/cli_data.rs. They use only the existing IntentApiFixture, so the same file compiles on base and on head. Each probe records the Authorization header of the data request. I did not push the probes, and I restored the tree afterwards.

Environment: toolchain 1.97.1, RUST_MIN_STACK=16777216, CARGO_BIN_EXE_omnigraph-server set to the built server binary.

Command: cargo test -p omnigraph-cli --locked --test integration -- cli_data::probe_1022 system_local::local_cli_keyed_credentials_authenticate_url_matched_server_issue_1022 --test-threads=1

Case Base 9c2b367 Head 903cda0
A. Literal --server <url> with OMNIGRAPH_BEARER_TOKEN set (the #1022 report) FAIL: Some("Bearer leaked-default-token") PASS: no header
B. Declared server, no keyed token, OMNIGRAPH_BEARER_TOKEN set FAIL: Some("Bearer leaked-default-token") PASS
C. Control: OMNIGRAPH_TOKEN_PROBE reaches its own server PASS PASS
The PR's system_local::…_issue_1022 (head version run on base) FAIL at its first new assertion: the read succeeded with the unkeyed token PASS
D1/D2. Dot-segment escape (O1) FAIL: the token leaks FAIL: the token leaks

These also pass on head: tests::resolve_remote_credential_sends_no_unkeyed_token_issue_1022, blob_cli::tests::remote_unauthorized_names_the_missing_credential, cli_data::data_write_outcomes_and_retry_permission_issue_466_issue_976 and cli_data::remote_json_errors_preserve_server_codes_and_details. I ran the Unix-socket case of O1 by hand, with the head binary and a socket listener.

Limits

  • The PR's unit test uses new symbols, so it does not compile on base. I did not count it as before/after proof. Probes A and B and the PR's integration test give that proof.
  • I ran focused tests only, not the full workspace suite. On this head, CI shows every other required check green.
  • To make sure each run used the correct build, I checked the binaries. The base binary does not contain the new hint string, and the head binary does.
  • My first local run of the server test timed out. The cause was my setup, not the PR: the support module looks for the server in <workspace>/target, not in CARGO_TARGET_DIR. Setting CARGO_BIN_EXE_omnigraph-server fixed it.

Other open PRs and direction

Credential selection compared the address text with each declared
server's `url`, but the transport sends to the parsed URL, with `.` and
`..` segments resolved. So `https://host/tenant-a/../tenant-b`, or a
socket path ending in `../other.sock`, carried a declared server's
token to a different path prefix or socket.

`RequestTarget` is the transport's own derivation of where a request
goes: the scheme, host and port (or the socket path), and the path
segments. A credential matches when the request's target has the same
endpoint and lies under the server's own target; the deepest server
wins. `--graph` refuses a `.` or `..` path segment, in every spelling
the URL parser resolves, before a URL is composed.
Comment thread crates/omnigraph-cli/src/helpers.rs Resolved
url: &str,
) -> Result<(crate::graph_http::GraphHttpClient, Option<String>)> {
let http = crate::graph_http::GraphHttpClient::new(server_root)?;
Ok(match resolve_remote_credential(server_root, url)? {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional (O5), follow-up. The token is matched here against one URL, url. The callers then attach it to every URL that they compose later. There are nine apply_bearer_token call sites: six in client.rs, two in helpers.rs and one in cluster_remote.rs. So the rule "a token goes only where its server is" holds per client, not per request.

This is safe today. I checked remote_url with the pinned url 2.5.8:

  • extend encodes % and / in each pushed segment, so %2e%2e and a/b stay inside one segment.
  • A tab-split .\t. segment can still pop one segment. remote_url("http://h/tenant-a", &[".\t."]) returns http://h/.
  • Every production call site pushes a literal first segment ("commits", "branches", "cluster", …). So a user value can pop at most that literal, and no request leaves url.

So the guarantee comes from caller discipline, not from the transport. After five more routes, each author must keep that discipline. A structural option: keep the matched server's RequestTarget with the token in GraphHttpClient. Then add the header in send only when the built request's target is_under it. The rule would then hold for every future route, and the per-call apply_bearer_token plumbing would go away. This PR does not need it.

@cursor

cursor Bot commented Oct 17, 2026

Copy link
Copy Markdown

Re-review: COMMENT. Fix one small blocking item (B2), then merge.

Reviewed head: bb0b308eb67af54b7e04f340ebf922272f8768ec. The delta since the last review (903cda08) is 41d49de4 and bb0b308e. Merge base: 9c2b3679e8f6. This bot cannot submit a "request changes" state, so this is a comment. My recommendation is "request changes" for B2 only.

Why, what, how, impact

  • Why: The earlier matcher compared the address text with each server's url. The transport sends the request to the parsed URL. Because of this, a .. segment could carry a declared server's token to a sibling path prefix or to another Unix socket (earlier O1).
  • What: bb0b308e adds RequestTarget (graph_http.rs:93-153). It describes where a request goes, as the transport sees it: the scheme, host and port (or the socket path), plus the path segments after the parser resolves . and ... find_server_for_target (operator.rs:175-192) picks the deepest declared server whose target leads the request's target. --graph now refuses dot segments (helpers.rs:442-458).
  • How: The credential match uses the same functions as the send: ServerTransport::parse and http_url. GraphHttpClient::request uses them too. So the match and the send cannot disagree about the destination. This fixes the root cause, at the layer that owns the destination.
  • Impact: The token now follows the real destination for every spelling that the parser normalizes. This includes a spelling that the PR test does not list: I proved the tab-split .. case in the proof table. The change adds no new state and no new config. It adds one crate-private type.

Earlier findings

  • B1 (rustfmt): resolved in 41d49de4. Format (rustfmt) and CI Gate are green on bb0b308e.
  • O1 (dot-segment token leak): resolved for the credential in bb0b308e. The proof is below. The new --graph refusal is incomplete (B2).
  • O2 (the hint is appended to the server's error text) and O3 (a-b and a_b map to one OMNIGRAPH_TOKEN_… name): still open and unchanged. Both stay optional. I do not repeat them here.

Contract check of the new matching

  • Scheme, host, port: host_str() gives the parsed host, so case, IDNA and IPv4 forms are normalized. port_or_known_default() fills in 80 and 443. https://t.example:443/… matches https://t.example/. https never matches http. A unit test covers this.
  • Path boundary: The match uses a segment prefix, not a string prefix. So /tenant-ab does not match /tenant-a. A unit test covers this.
  • ., .., %2e and \: The parser resolves these before the match. Unit and integration tests cover them.
  • Tab, LF and CR: The parser removes them (url 2.5.8 src/parser.rs:329-330, and the WHATWG URL Standard step "remove all ASCII tab or newline"). So .\t. is ... The match sees the resolved path, so no token leaks. I ran this; see the table.
  • Unix sockets: The endpoint is the socket path from the parsed root, percent-decoded, and compared component by component. A symlink or another spelling of the same socket does not match. That fails closed: the CLI withholds the token, the server returns 401, and the hint explains why. A composed URL that leaves the socket root is refused before it is sent (graph_http.rs:78-86).
  • Percent-encoded / (%2F): The match runs on encoded segments. See O4.
  • Redirects: still Policy::none() (graph_http.rs:289).
  • Other callers that select credentials: Every served credential comes from served_client (helpers.rs:366-375). Its callers are resolve, resolve_with_policy, registry_client (also the multi-graph probe), execute_operator_alias and cluster_remote. Managed --api credentials are separate and unchanged. No other production code sets Authorization on a served request.
  • Composed sub-routes: The token is matched once, for the graph URL. The callers then add segments with remote_url. Today no request leaves the graph URL, but that is caller discipline. See O5.

Blocking (1)

  • B2. A tab-split .. in --graph is not refused. --graph $'.\t./.\t./tenant-b/graphs/knowledge' exits 0, and the request goes to /tenant-b/graphs/knowledge/queries/q, off the graph route. The CLI withholds the token, so no credential leaks. But three places say that such a value is refused: the code comment ("in every spelling the URL parser resolves"), docs/user/cli/index.md:203, and changelog.d/cli-credential-transport-target.fixed.md. Fix: accept a graph as one path segment from an allow-list, not a deny-list of parser spellings. The inline comment on helpers.rs:452 has the code.

Optional (2 new)

  • O4. A %2F-encoded slash keeps .. inside one segment. With --server tenant-a --graph '..%2F..%2Ftenant-b', the CLI sends Bearer tenant-a-token to /tenant-a/graphs/..%2F..%2Ftenant-b/queries/q. I ran this. For the CLI, that path is under tenant-a, so the result agrees with the documented rule. omnigraph-server routes on the raw path, so this request does not reach another tenant there. But a reverse proxy that decodes %2F before it resolves dot segments could route the request to /tenant-b. I did not test any proxy. The allow-list in B2 also closes O4.
  • O5. The check applies to the client, not to each request. served_client matches the token for one URL. The nine apply_bearer_token call sites then attach it to each URL they compose. This is safe today only because every call site pushes a literal first segment. A structural follow-up is in the inline comment on helpers.rs:371.

Tradeoffs and software liability

  • Removed: a string-prefix matcher that disagreed with the transport. The transport's parser is now the one source of truth for the destination. A false doc claim ("only to URLs under its url") is also gone.
  • Added:
    • RequestTarget and Endpoint: about 60 lines, crate-private. They derive the match from the transport, so they are justified.
    • A re-parse of each servers: entry, once per command. The cost is O(servers) and negligible.
    • refuse_dot_segments: a deny-list that copies parser rules. That is the same pattern that caused O1, and B2 shows its cost. An allow-list is smaller and cannot fall behind the parser.
  • Fail-closed direction: A mismatch always withholds the token. Examples: a symlinked socket path, a %2D spelling, a trailing-dot host, an unparsable servers: entry. The 401 hint then names the cause. One small edge: for an unparsable declared entry, the Unbound hint says "declare it under servers:", which can mislead.

Regression proof

Environment: toolchain 1.97.1, RUST_MIN_STACK=16777216. I used one clean worktree per commit and one shared target directory. I added temporary probes to crates/omnigraph-cli/tests/integration/cli_data.rs with the existing IntentApiFixture. I restored both trees and pushed nothing.

The before-fix commit is 41d49de4, the PR head just before bb0b308e. The reviewed head is bb0b308e.

Case 41d49de4 (before) bb0b308e (head)
PR test cli_data::served_credential_follows_the_sent_url_issue_1022, grafted onto 41d49de4 FAIL at the /tenant-a/../tenant-b step: Some("Bearer tenant-a-token") on /tenant-b PASS
The same test, Unix-socket section only FAIL: Some("Bearer og-token") on the other socket PASS (as part of the full test)
Probe: a tab-split .. in --graph carries no token FAIL: Bearer tenant-a-token on /tenant-b PASS: no header
Probe: a tab-split .. in --graph is refused (B2) FAIL FAIL: exit 0, request sent to /tenant-b/…
Probe: a %2F-encoded .. (O4, observation only) not run exit 0, Bearer tenant-a-token on /tenant-a/graphs/..%2F..%2Ftenant-b/queries/q

Commands (on the reviewed head unless the row says otherwise):

  • cargo test -p omnigraph-cli --locked --test integration -- cli_data::served_credential_follows_the_sent_url_issue_1022 cli_data::review_probe_tab_dot_graph_carries_no_token cli_data::review_probe_tab_dot_graph_is_refused --test-threads=1, on both commits.
  • cargo test -p omnigraph-cli --locked --bin omnigraph: 166 passed, 9 ignored. This includes operator::tests::find_server_for_target_matches_where_the_request_is_sent, tests::resolve_remote_credential_sends_no_unkeyed_token_issue_1022 and client::tests::graph_http_over_a_unix_socket_sends_root_relative_targets_issue_979.
  • cargo test -p omnigraph-cli --locked --test integration -- cli_data::: 96 passed.
  • cargo test -p omnigraph-cli --locked --test integration -- system_local:: system_remote:: cli_cluster::, with CARGO_BIN_EXE_omnigraph-server set to the built server: 125 passed, 7 ignored.
  • url 2.5.8 behavior (.\t., %2e%2e, %2F, and remote_url segments): a scratch program against the pinned crate.

Limits

  • GQT is not suitable here. The contract is the CLI's Authorization header and request path at the wire, and a .gqt case cannot observe them. cli_data with IntentApiFixture is the cheapest existing owner, and the PR uses it.
  • The new unit test uses new symbols, so it does not compile before the fix. I did not count it as before/after proof.
  • On bb0b308e, CI is green for every check, including CI Gate, Test Workspace, Lint (clippy), Format (rustfmt), the GQT jobs and Fix Regression Gate. Locally I ran focused suites only, not the full workspace.
  • I did not test a reverse proxy (O4) or Windows. The Unix endpoint is cfg(unix).

Other open PRs and direction

  • Refresh skills and operator guides for 0.13 #892 (head 2990fa3a) still adds export OMNIGRAPH_BEARER_TOKEN='secret'. The earlier note stands: it needs the keyed form after this PR merges.
  • rfc: named targets and CLI coherency #675 (RFC 0059) asks to "preserve URL base-path boundaries and canonical Unix-socket identity". This PR now does the base-path part. For sockets, it compares paths after URL normalization. It does not canonicalize the filesystem path. That fails closed, but RFC 0059 should say which identity it means.
  • This PR changes no Lance surface. Lance 11.0.0 and later bumps do not interact with this code. No DST storage-cost row applies, because no storage read or write changes.

ragnorc and others added 2 commits October 17, 2026 08:52
The dot-segment check listed the spellings the URL parser resolves and
missed one: the parser strips tab, LF and CR first, so `.\t.` is `..`.
A `%2F`-encoded slash also kept `..` inside one segment for a proxy that
decodes it. A graph name is now one path segment of ASCII letters,
digits, `-` or `_` (the graph-id alphabet), so no parser spelling can
move the request off the graph's route.
A 401 to a request that carried no credential appended the CLI's hint to
the server's `error` text, so `--json` no longer printed the server's
body as sent. The hint now travels as the CLI's own `missing_credential`
field (`{"server" | "unbound": ..., "hint": ...}`) next to the unchanged
body, and human output prints it on its own line.

Two `servers:` names that spell the same `OMNIGRAPH_TOKEN_<NAME>`, such
as `a-b` and `a_b` or `prod` and `PROD`, are refused when the operator
config loads, since one variable would bind both servers' credentials.
@cursor

cursor Bot commented Oct 17, 2026

Copy link
Copy Markdown

Re-review: APPROVE. B2 is fixed by design. No blocking findings remain.

Reviewed head: 40cdec8650b5c6f45e182f6b4908b39e5944e811. The head moved during this review. I started at ea826c06. Then the author pushed 509048d5 (O2 and O3) and the merge 40cdec86. This review covers the new head. Last review: bb0b308e.

Why, what, how, impact

  • Why: The CLI sent a server token to a URL that a string-prefix check accepted, not to the URL that the transport actually sent. A served --graph value could also move the request off the graph route.
  • What: The CLI matches a token against where the request goes. A served --graph must be one plain path segment. A 401 without a credential now keeps the server's error text and adds a separate missing_credential field. Two servers: names that map to one OMNIGRAPH_TOKEN_<NAME> are refused.
  • How: require_graph_segment is an allow-list (ASCII letters, digits, -, _, not empty). MissingCredential serializes as {"server" | "unbound": …, "hint": …}. validate_server_token_names runs when the operator config loads.
  • Impact: A served --graph with a dot segment, a tab, LF, CR, /, \ or %2F fails before any request is sent. --json callers get the server body as sent. A config with colliding server names fails loudly when it loads. That is a fail-closed change for a config that was ambiguous before.

Status of earlier findings

Item Status Evidence
B1 rustfmt Resolved earlier cargo fmt --all --check exit 0 on the head
O1 Resolved earlier not repeated
B2 tab-split .. in --graph Resolved by design in 3b027b4b before/after table below
O4 %2F inside one segment Resolved by the same allow-list before/after table below
O2 hint appended to the server's error Resolved in 509048d5 before/after table below
O3 a-b and a_b share one token variable Resolved in 509048d5 before/after table below
O5 the token check applies to the client, not to each request Still open, optional see below

B2 by design. The old code listed parser spellings to refuse. The new code accepts only bytes that the URL parser cannot change in a path segment: helpers.rs#L461-L473. url 2.5.8 removes tab, LF and CR, and it resolves ., .., %2e and \. None of these bytes pass the allow-list. So a future parser spelling cannot reopen the gap. resolve_server_flag calls the check for every graph source before it composes {base}/graphs/{graph}: --graph, defaults.default_graph and an alias graph: (helpers.rs#L415). The probe covers all three sources. The CLI rule accepts more than the cluster id rule (omnigraph-cluster/src/config.rs validate_id also requires a letter or _ first). So the CLI does not refuse a graph id that a cluster can serve. The violated contract was RFC 0007 D5 rule 3 (a token goes only to the server it is keyed to), which supports invariant 10 in docs/dev/invariants.md (trust is established at the boundary). The full class is "a graph value that the parser rewrites into another route". The allow-list closes the full class at the layer that composes the URL.

O2 and O3. The server body now stays unchanged in RemoteErrorCli.output, and the CLI keeps its own reason beside it (helpers.rs#L580-L606, helpers.rs#L760-L796). Both output paths carry it: write failures through Failure.missing_credential (command_outcome.rs#L160-L162), and other refusals through RemoteRefusalOutput (main.rs#L219-L247). The collision check uses token_env_name itself, so the check and the lookup cannot disagree (operator.rs#L244-L257, called at operator.rs#L334). load_operator_config_at is the only load path on the head.

Merges. ea826c06 and 40cdec86 merged cleanly. I rebuilt 40cdec86 from its two parents locally and got the same tree. The main side changes no CLI credential code. I found no new defect from either merge.

Blocking

None.

Optional

  • O5 (still open). served_client matches the token for one URL. The apply_bearer_token call sites in client.rs, cluster_remote.rs and helpers.rs then attach it to each URL that they compose. Today every caller adds literal segments under the graph URL, so no token leaves the matched server. This stays caller discipline, not a type rule. A follow-up can carry the matched RequestTarget in the client and check each composed URL once, in one place.
  • O6 (new, small). The graph id rule now exists in two places: the CLI allow-list and the cluster validate_id. The CLI rule is a superset, so it fails in the safe direction. If the graph id rule changes, both must change. One shared definition (for example in omnigraph-api-types) would make one source of truth. This is not needed for this PR.
  • O7 (new, pre-existing on main). A server root with a query string drops the graph from the route. With servers: tenant-a: url: http://…/tenant-a?x=1 and --graph knowledge, the CLI sends POST /tenant-a/queries/q?x=1/graphs/knowledge. The cause is the string join format!("{base}/graphs/{graph}"), which main also has (helpers.rs:351 on d883c49e). The token goes to the correct server (tenant-a), so this is a routing defect, not a credential leak. A follow-up can refuse a query or fragment in a servers: URL, or compose with Url::path_segments_mut. I do not ask for it in this PR.

Tradeoffs and software liability

  • Removed liability: The deny-list that copied parser rules is gone. That pattern caused O1 and B2. The allow-list is about a dozen lines and cannot fall behind the parser. The CLI no longer edits the server's error text, so --json output has one owner per field.
  • Added surface: One public JSON field, missing_credential, and one config load rule. Both are documented in docs/user/cli/index.md, docs/user/operations/troubleshooting.md, RFC 0007 and the changelog notes. The cost is O(servers) work once per command. That cost is negligible.
  • Cost of the allow-list: A graph id with . or non-ASCII characters is refused for served use. No server rule accepts such an id today, so this does not remove a working case.
  • Five more changes of this kind: Each new CLI output reason would add one more field beside ErrorOutput. That stays readable while the CLI owns the field. If more fields come, a single cli object beside the server body would scale better. This is a note, not a request.
  • Fail-closed direction: Every mismatch withholds the token or refuses the command before it sends a request. No path that I traced sends a token wider than before.

Validation

All local runs used clean worktrees at the exact commits, a shared target directory, and a forced rebuild of the worktree sources before each run.

Check Before Head 40cdec86
B2 and O4 probe (temporary, not committed): 3 values × 3 graph sources. It asserts exit 1 and zero requests at the fixture bb0b308e: 9 of 9 not refused. Example: exit 0, POST /tenant-b/graphs/knowledge/queries/q. "" sent POST /tenant-a/graphs/queries/q with Bearer tenant-a-token 9 of 9 refused before send, exit 1, zero requests, message a graph name is one path segment …
PR test cli_data::served_credential_follows_the_sent_url_issue_1022 bb0b308e with the head's version of this test: FAILED. It fails first on the new refusal wording for ../../tenant-b/…, so the probe gives the behavioral proof ok
O3: operator::tests::servers_sharing_a_token_variable_are_refused ea826c06 with the test added: FAILED, unwrap_err() on an Ok config ok
O2: cli_data::data_write_outcomes_and_retry_permission_issue_466_issue_976 ea826c06 with the head's test: FAILED, missing_credential was None ok
O2: system_local::local_cli_keyed_credentials_authenticate_url_matched_server_issue_1022 ea826c06 with the head's test: FAILED, the server error text had the hint appended ok
cargo test -p omnigraph-cli --locked --bin omnigraph — 167 passed, 9 ignored
cargo test -p omnigraph-cli --locked --test integration -- cli_data:: — 97 passed
… -- system_local:: system_remote:: cli_cluster:: (with CARGO_BIN_EXE_omnigraph-server set) — 125 passed, 7 ignored
cargo fmt --all --check — exit 0
cargo clippy -p omnigraph-cli --all-targets --locked -- -D warnings -W clippy::dbg_macro — exit 0
GitHub checks on 40cdec86 — 29 success, 4 skipped, 0 pending

Regression owner: GQT cannot express this contract. The behavior is the HTTP target and Authorization header that the CLI sends, and the CLI JSON error shape. GQT runs queries against an engine or a served graph and checks rows and errors. So the cheapest owners are the existing CLI integration tests and the operator unit tests. The PR extends those tests. It adds no parallel setup.

Limits:

  • I did not run the full workspace test graph locally. CI on the exact head covers it.
  • I did not test a reverse proxy or Windows. The Unix socket endpoint is cfg(unix).
  • I made no performance claims. The added work is one byte scan per graph value and one map per config load.

Other open PRs and direction

@ragnorc
ragnorc merged commit 2ce88df into ModernRelay:main Oct 17, 2026
34 checks passed
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 18, 2026
ModernRelay#1036)

* fix(cleanup): finish a table whose interrupted sweep removed its index directory

The collector deletes a swept version's files before its manifest, so a
pass that stops in between leaves the manifest for the next pass. On a
local store that next pass failed for the table: `delete_paths` called
`remove_dir_all` again on the `_indices/<uuid>` directory the stopped pass
had removed, and Lance's local provider reports an absent directory as
`NotFound`. The table kept the error and deleted nothing on every later
run. Object stores list the prefix empty and succeed, so S3, Azure and
the DST memory store converged already.

An absent index directory is now already deleted, as an absent file
already was. The files-before-manifest order is unchanged, and the
deletion set still holds only paths no retained root marks.

The failpoints test stops a pass at `cleanup.sweep_pre_manifest_delete`
after it removed an index directory only the pruned pin names, then
proves the next pass removes that manifest, reports no error, and leaves
every retained root's paths in place.

* docs(cleanup): state the sweep order before the retry outcome

Reword the maintenance guide's sentence on an interrupted cleanup so the
deletion order and the next run's outcome read as two steps, and point
the release note at the guide's Cleanup section.

* test(cleanup): interrupt the sweep between manifests and before dead-staging manifests

Cover the directory-touching windows of the collector's sweep on a local
store, where Lance reports removing an absent directory as NotFound:

- a pass stopped between two manifests of the published sweep, with an
  index directory among the files it already deleted;
- a pass stopped after the dead stagings' files and before their
  manifests, through the new seam cleanup.sweep_pre_dead_manifest_delete.

Each retry reports no error, removes the remaining manifests and keeps
every retained root's paths.

* test(cleanup): run the sweep crash windows on S3 and Azure

The three collector-retry fixtures in failpoints.rs become helpers over a
graph uri. The file tests keep their names and call them on a temporary
directory; one RustFS/S3 test and one Azure/Azurite test run all three, each
on a fresh graph root, and both CI jobs require them by name.

The first fixture now also stops a pass at cleanup.sweep_pre_file_delete,
the one sweep window no failpoint test reached: the pass deletes nothing
and the next pass plans the same sweep. Its retry asserts what the other two
already did, no error on any table and nothing left to remove.

Index-directory presence goes through the storage adapter's exists rather
than std::fs, so the same assertion holds on an object store. The RFC's
crash-window gate is satisfied and leaves blocked_on; the evidence bullet
names the owners per backend.

* test(dst): reach the dead-staging sweep window

Register cleanup.sweep_pre_dead_manifest_delete in the crash-window
catalog (appended, 53 windows) and give it a milestone recipe: insert,
index build, insert, cleanup. The reach census's setup crash stops the
index build before its manifest commit, stranding a detached staging; the
second insert moves main's head past the head the staging recorded, so
the recipe's cleanup judges it dead and crosses the window.

A window inside cleanup's per-table pass does not fail the op: cleanup
records the table's error and returns Ok. The harness treated that as a
plain success, so no retry was judged and the report kept no trace of
the window. crash_op now returns CleanupDeferred when the op's deferrals
name the scheduled window; the universe records deferred:<window>@op<i>
and runs the obligation pass a crashed cleanup gets (a clean rerun that
defers nothing, the collector oracle, the world read). This covers
cleanup.table_gc and the three earlier sweep windows as well.

The pin dst_dead_staging_sweep_window_is_reached_and_retried probes the
window and stops the recipe's cleanup at it; the sensitivity proof gains
a deferred-cleanup case. Both go red with the routing reverted.

* test(dst): schedule setup crashes in the hunt and the fleet

cleanup.sweep_pre_dead_manifest_delete needs an earlier crash's
aftermath: an index build stopped before its manifest commit. Only the
reach census added that crash, by spending the universe's one
crash_on_match slot on it, so the hunt and the fleet scheduled the window
on a cleanup with no dead staging and it never fired there.

Scenario::setup_crash is a second slot, matched before crash_on_match and
always a real crash, even under probe_only; crash_on_match starts matching
after it. harness::setup_crash_for replaces the test-only census_setup
and keeps only its live entry; window_needs_wide and
window_needs_schema_ops include the setup's needs. The reach census, the
hunt and the fleet's window arm take the setup from it, and the hunt
counts a hit from the window's own verdict instead of crashes > 0, which
a setup crash alone would satisfy. The pin now stops the cleanup through
the fleet arm's scenario instead of a pinned op index.

* feat(release): publish daily pinnable edge builds and stamp and attest every release (ModernRelay#1031)

* feat(release): publish daily, pinnable and attested edge builds

Edge releases now build once a day from the newest audited commit on main
and publish each build as its own prerelease, edge-<commit date>-<short
commit>, which never changes once published; the rolling edge release copies
it and only moves forward. Both release workflows share one archive build
that stamps the commit and channel into the binaries, runs each archive on
its platform and verifies the exact set, and both attest build provenance.
Each edge release states its storage format and HTTP contract next to the
latest stable release's, then the notes recorded since that release.

* fix(release): attest only the commit a release run is for

The provenance predicate takes its source commit from the run's OIDC sha
claim, not from the checkout. An edge run now builds only its own commit,
waiting up to 90 minutes for that commit's audit instead of walking back to
an older one, and both publishers refuse to attest a build of any other
commit; a stable backfill from v0.14.0 is dispatched at its tag.

* fix(release): keep edge runs from overlapping

A newer edge run could move the rolling tag after an older run published
but before its installer checks ran, failing a release that succeeded.
Whole runs now share one concurrency group.

* feat(release): stamp and attest the container image

The image's bookworm-built binaries now carry the same commit and stable
channel stamp as the release archives, checked inside the image before it is
pushed, and the pushed GHCR digest carries a build provenance attestation
under the same rule: only for the run's own commit, and from v0.14.0 a
backfill is dispatched at its tag.

* fix(release): address review of the edge release rework

- Refuse an edge build while the package version is not above the latest
  stable release: X.Y.Z-edge+<commit> sorts before X.Y.Z, so the ordering
  claim held only after main advanced its version.
- Require the commit's CI Gate as well as its vocabulary audit, waiting up to
  180 minutes, since the build becomes a pinnable, attested release; a run
  cancelled because main moved on now ends the edge run with a notice.
- Keep the newest edge build of every storage format beyond the retained 30,
  read from a marker line in the release text, so the build that can export
  an abandoned development format survives.
- Report one build identity from omnigraph-api-types: the CLI, /healthz and
  the MCP server info share the stamped version and its compile-time checks.
- Compose the edge release text from the real release-note preview in CI, and
  hold the two copies of the v0.14.0 attestation cutoff to one value.

* feat(load): plan CSV overwrites and reuse validation reads (ModernRelay#1026)

* fix(cli): accept --as on commit publish and drop (ModernRelay#1029)

* fix(cli): accept --as on commit publish and drop

The plane guard classified every `commit` subcommand as a read and refused
`--as`, so `commit publish` and `commit drop` against a direct graph could
only attribute `operator.actor`, although both dispatch through the write
resolver and the engine's `_as` entry points. The guard now matches the
`commit` subcommands exhaustively: publish and drop take `--as` on a direct
target, while list, show and changes still refuse it. A served publish or
drop still refuses `--as` before any request. Guard errors name the
subcommand (`commit list`, ...).

The published commit keeps the actor of the write that created it; the
branching guide now says so.

* test(cli): pin created_at and fold the served refusal into its owner

The publish regression now also checks that the published commit keeps
the proposal's `created_at`, the second half of the branching guide's
claim. The served `--as` refusal for `commit publish` and `commit drop`
moves into `as_on_a_served_write_is_rejected` as two more argument rows
instead of a parallel fixture. Rewrap the edited guide paragraph.

* docs(changelog): say a published commit keeps its timestamp too

The branching guide and the regression test state both the actor and the timestamp; the release note now says the same.

* fix(cli)!: send a server token only to the server it is keyed to (ModernRelay#1030)

* refactor(cli): centralize invocation and command boundaries (ModernRelay#1047)

* refactor(cli): capture operator config during client setup

* refactor(cli): reuse config for aliases and served clusters

* refactor(cli): isolate invocation parsing

* refactor(cli): make scope resolution pure

* refactor(cli): separate application completion from process exit

* refactor(cli): extract query family and typed preparation

* refactor(cli): capture presentation and preserve delivery outcomes

* test(cli): qualify captured presentation across execution

* refactor(cli): isolate graph history execution and output

* fix(cli): track dedicated commit write outcomes

* docs(cli): describe history delivery and outcome boundaries

* refactor(cli): isolate transfer execution and delivery

* refactor(cli): isolate schema lifecycle and lint execution

* refactor(cli): isolate maintenance execution and delivery

* refactor(cli): isolate cluster control execution and delivery

* refactor(cli): complete invocation config and I/O boundaries

* fix(cli): preserve failures and recovery across output errors

* docs(cli): consolidate server credential guidance

* feat(commits): page a branch's commit list (ModernRelay#1013)

* feat(commits): page a branch's commit list

GET /commits returned a branch's whole lineage, and building it read all of
__history. It now takes `limit` (1 to 1,000) and an opaque `page_token`;
a paged answer names `head_commit_id`, the head its first page resolved,
and a `next_page_token` while older commits remain. Later pages answer
for that head's first-parent chain even after the branch moves, and the
token carries a digest of the branch, never its name. Without either
parameter the answer is unchanged.

A page walks first parents by falling generation from the head or the
token's next commit: the head and buffer cost nothing, and an older commit
is read by id with the rest of its __history extent, so a page costs the
extents its own commits occupy and never the history below them.

`omnigraph commit list --limit/--page-token` and the MCP `commits_list`
tool page the same way.

* test(commits): register the paged commit list as a read-only surface

The public and coordinator API registries in forbidden_apis classify every
async entry point; Omnigraph::list_commits_page and
GraphCoordinator::first_parent_page read and publish nothing.

* test(commits): track the reworded commit list description in the vocabulary inventory

The listCommits description still names the graph-manifest version in its
ordering sentence; its prose digest changed with the paging text.

* test(commits): pin commit-list page storage calls in the DST cost golden

The newest and the oldest page of main's commit list on a reopened handle,
after 12 and 36 publishes under a 2048-byte history release. Both rows
match at the two depths: a page reads the extents of its own commits, not
the history above or below them.

* fix(commits): prove a commit-list continuation for a branch-scoped caller

A commit-list page token is checksummed, not signed, so the commit a
continuation starts at is the caller's claim. A caller allowed to read
main only could re-sign a main token to list a secret branch's commits,
which GET /commits?branch=secret and GET /commits/{id} both refuse it.

Under a policy, a continuation by a caller without the graph-wide read gate
(the gate of GET /commits/{commit_id}) now goes through
list_commits_page_on_branch: the token's commit must be the first commit
below its generation on the branch's first-parent chain from the head, or
the page is refused with 400. That proof walks the chain, so such a
continuation costs what the whole listing costs; a caller with graph-wide
read keeps the page cost. The list_commits_page doc says what the token
does not prove.

Pages through both doors concatenate to the listing on an upgraded root,
whose converted commits carry pre-upgrade ids.

* fix(commits): check a scoped commit-list continuation cheaply, through a read check

list_commits_page_on_branch becomes list_commits_page_readable, which takes
the same ReadCheck as diff_page_readable. A continuation's commit is on the
branch's history when the branch's __manifest holds it (its head or buffer),
when a branch the caller may read published it, or else when a walk down
the branch's first-parent chain finds it. So a branch-scoped caller paging
its own branch no longer walks the history on every page; only a commit
neither cheap rule covers costs the walk. The page is read once and the
check uses its first commit. An unknown commit is refused exactly as an
unreadable one is.

A missing commit met inside the walk is an integrity error, not a 404.
The server shares one read decision between the diff and commit-list
doors. The API documents that a continuation's head_commit_id is the one
its token carries, and openapi.json describes the scoped caller's cost.

* feat(release): publish an edge container image from each edge build (ModernRelay#1060)

* feat(release): publish an edge container image from each edge build

The daily edge run now also publishes a container image of the same build.
It packages the published build release's Linux x86_64 archive, checked
against its checksum and its attestation for this workflow and commit, on
Debian trixie, since the Ubuntu-built archives need glibc 2.39 or newer. The
root Dockerfile takes its base as BASE_IMAGE, bookworm by default, so the
stable image is unchanged.

The image is tagged edge-<commit date>-<short commit> on GHCR and Docker Hub,
checked inside the image for its commit and channel, attested on both
registries, and tagged edge only when the rolling edge release moved. It
never moves latest, and a rerun reuses the image first pushed for the build.

* fix(release): match the image's server features and follow edge as it is now

- Build every release archive with the server's aws feature, as the stable
  image already is, so the edge image packaged from the Linux archive accepts
  OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET like the image it replaces. Lance's
  S3 support already builds the AWS SDK base; the feature adds one crate. The
  gate checker requires both release builds to enable it.
- Move the edge image tags, and check the edge installer channel, only while
  the rolling edge release still names this build, so a rerun of an older
  run's failed job cannot move them behind a newer build.

* fix(release): skip an edge build whose workflows no longer match main (ModernRelay#1061)

GitHub refuses the workflow token a new tag on a commit whose workflow files
differ from main's current ones ("refusing to allow a GitHub App to create or
update workflow"), and the token can never hold the workflows permission.
An edge build of 02026b5 failed this way after a workflow change merged
during its build. The edge run now checks that the build's workflow files
still match main's before building and again before tagging, and otherwise
publishes nothing, with a notice; the image and installer jobs run only for
a published build.

* fix(release): build the Linux x86_64 archive on a pinned Ubuntu 24.04 runner (ModernRelay#1063)

`ubuntu-latest` moves to Ubuntu 26.04 between 2026-10-19 and 2026-11-19
(actions/runner-images#14748), and a run may land on either image during the
rollout. A Linux archive needs the glibc it was linked against, so the x86_64
archive would start requiring Ubuntu 26.04's glibc, breaking Ubuntu 24.04
and Debian 13 users and the trixie edge image. Pin it to ubuntu-24.04, as the
arm64 archive already is (ubuntu-24.04-arm), document the archives' glibc
2.39 floor, and have the gate checker refuse ubuntu-latest for archive
builds.

* fix(release): pull image bases from Docker Hub when logged in (ModernRelay#1065)

The first edge image build failed twice pulling debian:trixie-slim from ECR
Public: "429 Too Many Requests: Data limit exceeded". GitHub-hosted runners
pull it anonymously from shared addresses, whose data limit is often spent,
and the stable image pulls its bookworm base the same way. Both image
workflows now log in to Docker Hub when the DOCKERHUB_* secrets exist and
build on the Docker Official Image; the stable workflow's rust:1-bookworm
builder pull is then logged in too. Without the secrets, the base still
comes from ECR Public.

* fix(schema): drop a property together with the constraints that name only it (ModernRelay#1062)

A property-level @index, @unique or @key is also stored as a type-level
constraint naming the property by stable id. Dropping the property made
the planner see that constraint disappear and refuse the whole change,
because the constraint comparison did not know which properties the
plan drops. An indexed property could therefore never be removed.

The property planner now returns the stable ids of the properties it
drops, and the constraint comparison no longer refuses a removed
constraint when every field it names is one of them. A @key, a
constraint that also names a field that stays (a remaining property or
@src/@dst), and a constraint removed from a property that stays are
still refused. Apply needs no change: the drop's Operation::Project
already removes the index on the dropped column, and the evolved table
declares full-text analyzers from the desired catalog.

ConstraintIR::fields replaces two copies of the same match in
schema_ir.rs and serves the new rule.

Fixes ModernRelay#1014

* feat(query)!: refuse full-text calls without a full-text index (ModernRelay#1066)

* query: refuse full-text calls without a full-text index (T27, FullTextIndexRequired)

A full-text call (`search`, `fuzzy`, `match_text`, `bm25`) reads its
property's full-text index, whose analyzer folds case and stems; without
one Lance scans with a bare, case-sensitive tokenizer, so the same call
answered differently on an indexed and an unindexed property.

- T27 at type checking: a full-text call on a String node property with no
  one-column full-text `@index`/`@key` is refused, from the schema alone.
  `PropType::index_kind` moves to the compiler and index maintenance uses
  it, so the type checker and the index builder cannot drift. An enum's fix
  points at `=`; a non-String field keeps its operand error.
- FullTextIndexRequired at planning (HTTP 409, `full_text_index_required`
  in the error body): the planner reads the coverage of every property a
  full-text call names, filters included, and refuses `absent`. An
  untrained segment, which every write now declares, is `partial` and
  answers; a table with no fragment, or a type the snapshot does not hold,
  counts as covered. Acceptance refuses a plan that records no coverage,
  or `absent`, for a call, and replay re-derives every recorded coverage
  from the pinned dataset.

Regressions: cases/v2/issue_747_full_text_calls_need_a_full_text_index.gqt
and search.rs::a_full_text_index_with_no_segment_refuses_the_call_issue_747,
both red with their check removed.

* docs: rescope RFC 0047 step 3 and document the full-text index requirement

RFC 0047 records step 3 as refusing only an index with no segment, now
that every write declares a declared column's analyzer, with a
decision-log entry, and its rollout table records steps 2 and 2a as
merged. The search guide, troubleshooting page and execution guide
describe T27 and the 409; the release notes call T27 breaking and tell
operators to validate stored queries before upgrading.

* feat(query): the ranked binding roots its component (ModernRelay#1069)

* feat(query): the ranked binding roots its component

A nearest(), bm25() or rrf() order on a binding a traversal reaches was
refused at planning unless that binding was declared first, and a ranked
binding no declaration introduced could not be ranked at all.

Lowering now roots the top-level component holding the bindings the
leading order key ranks at that binding; a ranked binding only a
traversal introduces gets a NodeScan of its checked type, and every
other binding of the component is reached by a forward or reverse
expand. An rrf() whose arms rank two bindings one traversal connects is
the type error T59. The planner codes P001 and P005 are withdrawn; the
planner keeps both checks as internal errors.

* docs(search): rank a traversal-reached binding

* test(cleanup): qualify scoped retries and keep setup pending until a crash

---------

Co-authored-by: Azim Afroozeh <13484327+azimafroozeh@users.noreply.github.com>
Co-authored-by: Andrew Altshuler <andrew@modernrelay.com>
Co-authored-by: aaltshuler <andrew@collectivelab.io>
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.

CLI: OMNIGRAPH_BEARER_TOKEN is sent to any remote URL

1 participant