Skip to content

feat(cli): name targets in the operator configuration - #1080

Closed
ragnorc wants to merge 32 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-targets
Closed

ragnorc wants to merge 32 commits into
ModernRelay:mainfrom
ragnorc:feat/cli-targets

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1078 (CLI exit table), itself on #1076 and #1074; review only the last commit.

What & why

The operator configuration can name targets, the addressing RFC 0059 proposes, as an additive first step: each target is one complete binding, and a command selects one with --target NAME, else defaults.target.

target_config_version: 1
defaults:
  target: staging
targets:
  local:   {kind: direct, scope: graph, uri: file:///Users/me/company.omni}
  ops:     {kind: direct, scope: root, uri: s3://company-data/omnigraph, default_graph: knowledge}
  staging: {kind: server, endpoint: https://staging.example, credential: staging, default_graph: knowledge}
  public:  {kind: server, endpoint: https://public.example, auth: anonymous}
  • Resolution. --target is a complete address: it refuses --server, --store, --cluster, --profile and a positional URI beside it, and resolves through the same scope resolver profiles use, so capability checks are unchanged. defaults.target applies when nothing is named, and refuses beside defaults.server/store/default_graph or an ambient OMNIGRAPH_PROFILE. Commands that do not resolve a scope (init, cluster, most local commands) refuse --target rather than ignore it.
  • Credentials. A server target's credential owner keys the existing token chain (OMNIGRAPH_TOKEN_<OWNER>, then the credentials file) and is bound to the target's endpoint, so its token goes only to URLs under it, by the same sent-URL matching servers: use. The file refuses an owner at two endpoints, two owners for one endpoint, and an anonymous target beside a keyed one. login/logout --target store under the owner.
  • target list|show|add|remove. Listing needs no network and prints no secret, with a binding digest. add reads one entry as JSON on stdin; add and remove hold config.yaml.lock, change only that entry's lines, prove by parsing that every other key is unchanged, and replace the file by rename, refusing with config_not_editable when they cannot.
  • credential get --target hands a server target's token to a pipe, never a terminal, for clients such as Studio.
  • Refusals carry registry codes; version --json reports protocols.target and protocols.credential.

Existing servers:, profiles:, clusters: and their flags keep working; retiring them is a later step.

Backing issue / RFC

Checklist

  • Change is focused (one logical change)
  • Tests: in-source targets tests (bindings, file-wide checks, owner matching, resolution, text edits, digests); CLI cli_data::targets_address_commands_and_carry_only_their_owners_token, cli_data::target_commands_edit_one_entry_and_keep_the_rest_of_the_file, cli_data::login_and_credential_get_follow_the_target_credential_owner; the version contract; skill drift
  • Public docs: new docs/user/cli/targets.md, CLI reference rows, user index, skill command index and condensed reference; changelog.d/cli-named-targets.added.md
  • Reviewed against docs/dev/invariants.md: no token in config, a token only to its bound endpoint, CLI-only codes documented as such

Local verification

  • cargo build -p omnigraph-server && RUST_MIN_STACK=16777216 cargo test -p omnigraph-cli -- --test-threads=6 — 242 lib + 335 integration pass
  • RUST_MIN_STACK=16777216 cargo test -p omnigraph-server — all pass (OpenAPI regenerated for the new codes)
  • cargo clippy -p omnigraph-cli -p omnigraph-server -p omnigraph-api-types --all-targets --locked -- -D warnings -W clippy::dbg_macro, cargo fmt --all --check, python3 scripts/check-docs.py, typos — clean

Notes for reviewers

The serialized format is the open question; it follows #675's sketch (kind, scope, uri, endpoint, credential, default_graph) plus auth: anonymous for explicit anonymous access. Managed targets are not included yet. The target command group is the one exception to RFC 0059's "no target administration group" that RFC #1017 argues for.

A request carrying omnigraph-expect-actor is refused with 412
actor_mismatch, after authentication and the contract check and before
graph lookup, body work or effects, when its credential resolves to
another actor. A match changes nothing, so the header only narrows a
request and never supplies an actor. Every protected router and /mcp
check it.

The CLI sends it for --as on served writes and cluster --server
commands, which no longer refuse --as. A mismatch is a not-started
refusal and exits 4, like a failed graph-commit precondition.
Every error body carries an outcome object (execution, effects, action)
that the server computes for the request it answered: not_started with
no effects when it refused before submitting a write, or refused the
write's one publication with a typed reason, which an engine gives only
before it publishes; unknown otherwise, including a typed refusal after
a load forked its branch. The next step a caller takes is retry,
refresh, recover or reconcile. A write whose completion is uncertain
answers outcome_unknown instead of internal. Errors that carried a
reason only in their details gain registry codes: precondition_failed,
recovery_required, external_blob_source and change_feed_gap.

CLI data-write failures print the same object as outcome, in place of
command_outcome, and take the server's when its request was the
command's only effectful one.
The exit follows from the failure's outcome: a command that did not
start exits 1, one whose effects are unknown 2, one refused for a
condition the caller set 4, and one refused before starting with retry
75. A refused command line exits 64 (EX_USAGE) instead of Clap's 2.

Read commands report the same failure envelope as data writes, always
not_started with no effects, so a failed read prints its outcome too.

An embedded engine write's own error now carries the engine's
completion evidence, as a server's does: a typed refusal before any
request, outside a load that forks its branch, did not start, so
embedded load conflicts and failed conditions exit as served ones
instead of claiming unknown effects. Any later error, such as one
rendering a committed receipt, stays unknown. Embedded writes refuse a
noncanonical branch name and read their data file before opening
storage.

Managed commands keep their own exits until the managed relay lands.
A target is one complete binding in ~/.omnigraph/config.yaml: a direct
graph or cluster root, or a server with its credential owner or
anonymous access. --target NAME, else defaults.target, addresses a
command; it refuses every other address and --profile beside it, and
resolves through the scope resolver profiles use. A server target's
owner keys the existing token chain and is bound to the target's
endpoint, so its token goes only to URLs under it; the file refuses an
owner at two endpoints, two owners for one endpoint and an anonymous
target beside a keyed one.

target list|show|add|remove lists the targets without secrets or
network, with a binding digest, and edits one entry at a time: an edit
holds config.yaml.lock, changes only that entry's lines, proves by
parsing that every other key is unchanged, and replaces the file by
rename. login/logout --target store under the target's owner, and
credential get --target hands a server target's token to a pipe, never
a terminal. Refusals carry registry codes (target_unknown,
target_invalid, target_exists, target_is_default, config_not_editable,
target_kind_unsupported, credential_terminal, credential_anonymous,
credential_missing). version --json reports protocols.target and
protocols.credential. Existing servers, profiles and their flags keep
working.
…es it

A server built before `omnigraph-expect-actor` speaks the same HTTP
contract and ignores the header, so `--as` on a served write would run
as the credential's actor. Every response now lists the assertions its
server enforces in `omnigraph-http-api-assertions`, and the CLI refuses
before sending when discovery does not list `expect-actor`.

A served `cluster plan` or `cluster apply` refused for another actor
now exits 4 as a data write does: its requests are observed, and a
refused assertion with nothing effectful sent before it is a condition,
with or without `--json`.
`main` returned an undelivered failure as an error, so the process
exited 1, the status that says nothing changed, after a write whose
receipt met a closed stdout. It now reports the error as before and
exits with the code the outcome chose.

`alias` is a read, as `query` is, so its failures report an outcome
and exit by it. Command classification is now an exhaustive match, so
a new command declares how its failure reads.
…s everywhere

Credential lookup now reads the same validated bindings as the
ownership checks, so a server target that fails validation can no
longer win a request's credential and send another owner's token to
its endpoint.

`queries` and `policy` resolve `--target` and `defaults.target` like
every other command: a target is a complete address that excludes
`--cluster` and `--profile`, only a cluster-root target names a
cluster, and its default graph applies.

`login` and `logout` check after parsing that exactly one of a server
name, `--api` and `--target` names the credential, so a global
`--target` before the subcommand works and a conflict refuses.

A configuration refusal keeps its code through the invocation's
captured configuration, and a target edit is a local write: a failure
after its rename no longer reports that nothing changed.
# Conflicts:
#	crates/omnigraph-cli/src/command_outcome.rs
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.

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Request changes. Do not merge this pull request until the six blocking items below are fixed, and until draft RFC #1017 and RFC 0059 (#675) have accepted the target format. This branch also contains #1074, #1076, and #1078. Those should land first.

Why, what, how, impact

An operator needs one name for one place a command runs, with one credential bound to that place. This change adds targets: to the operator file and --target NAME as a complete address. target list|show|add|remove and credential get read and edit that file. A server target sends its token only to URLs under its endpoint.

That is the right primitive: one binding, turned into the address tuple the CLI already uses. The file editor changes one entry, keeps the rest of the text, and refuses when it cannot. Tokens stay in the existing credential file and OMNIGRAPH_TOKEN_<OWNER>.

The new liability is that several paths still change the address after the target is chosen. A cluster-root target can be dropped, remapped through clusters:, or ignored beside a profile. A cloud URI can look stable while the process environment still chooses the store. A server endpoint can carry a password into target list. Five more target kinds (managed account, managed cluster, catalogue, local servers, access) will copy those forks if they stay.

Blocking

1. schema apply drops a cluster-root target

resolve_scope accepts a direct-root target for a direct command and sets ResolvedScope.cluster. schema apply then calls GraphClient::resolve_with_policy, which passes cluster: None and reads only server, graph, and uri. resolve_uri then reports that no graph was addressed, with outcome not_started and no error code.

Reproduced at this head with defaults.target set to a direct root (uri /tmp/review-1080/repro/cluster, default_graph: knowledge):

omnigraph schema apply --schema s.pg --json exited 1. The message was no graph addressed — pass a positional URI, --store <uri>, ....

The same config makes omnigraph upgrade --check refuse with upgrade refuses cluster-managed graphs.

RFC 0059 lists schema apply as direct-graph only. A root target must be refused as the wrong kind, the way upgrade refuses it. Discarding the resolved root and blaming a missing address hides the target the operator selected.

crates/omnigraph-cli/src/commands/schema/mod.rs (SchemaCommand::Apply), crates/omnigraph-cli/src/client.rs (resolve_with_policy, resolve_with_policy_scope), crates/omnigraph-cli/src/helpers.rs (resolve_uri), crates/omnigraph-cli/src/upgrade.rs.

2. queries and policy use a second profile rule

resolve_scope refuses OMNIGRAPH_PROFILE beside defaults.target. require_cluster_scope does not. A cluster profile wins over defaults.target with no conflict. A profile that is not a cluster is skipped, and defaults.target is used, so an explicit server --profile is ignored.

Reproduced at this head:

  • OMNIGRAPH_PROFILE set to a cluster profile, defaults.target set to another root: queries list opened the profile root (no cluster state ledger on /tmp/review-1080/repro/from-profile).
  • queries list --profile ambient, where ambient is a server profile: the command opened the default target root.
  • OMNIGRAPH_PROFILE=ambient omnigraph query q refused, because that path uses resolve_scope.

docs/user/cli/targets.md says defaults.target refuses beside a set OMNIGRAPH_PROFILE. Commit b3d1ab32 says queries and policy resolve targets like every other command. They need the same conflict check, in require_cluster_scope.

crates/omnigraph-cli/src/commands/control/mod.rs (require_cluster_scope), crates/omnigraph-cli/src/scope.rs (the ambient-profile check).

3. A direct-root URI is looked up in clusters:

scope_from_target maps a direct-root URI to ScopeBinding::Cluster. scope_from_binding then calls cluster_root. A clusters: key equal to that URI replaces the root.

Reproduced: clusters: key /tmp/review-1080/repro/named with root /tmp/review-1080/repro/other, target ops with uri /tmp/review-1080/repro/named. omnigraph optimize --target ops failed on cluster /tmp/review-1080/repro/other.

A target is a complete address. Its URI must stay literal. Profile and --cluster names should still use cluster_root.

crates/omnigraph-cli/src/scope.rs (scope_from_target, the ScopeBinding::Cluster arm), crates/omnigraph-cli/src/operator.rs (cluster_root).

4. Endpoint checks accept a secret and a non-URL

TargetEntry::binding accepts any string whose scheme is http or https. ServerTransport::parse checks the scheme only. target add stored both of these, and target list printed them:

  • {"kind":"server","endpoint":"https://user:secret-token@h.example/g","auth":"anonymous"} listed as https://user:secret-token@h.example/g.
  • {"endpoint":"https://","auth":"anonymous"} listed as https://.

The list is specified to carry no secret. The digest hashes the raw endpoint, so the password is part of the digest input. RequestTarget::of skips an endpoint it cannot parse, so that entry also skips the one-owner checks.

Refuse userinfo. Require an endpoint that RequestTarget::of accepts before the entry is a binding.

crates/omnigraph-cli/src/graph_http.rs (ServerTransport::parse), crates/omnigraph-cli/src/targets.rs (TargetEntry::binding).

5. A cloud URI is stored as complete while the environment still chooses the store

validate_storage_uri accepts s3://bucket/graph and az://.... target add of that URI succeeded. target show printed a digest of the raw URI and local_servers: []. The digest does not change when AWS_ENDPOINT_URL_S3, AWS_ENDPOINT_URL, AZURE_STORAGE_ACCOUNT_NAME, or AZURE_STORAGE_ENDPOINT changes.

s3_from_root_uri_with_scope applies AWS_ENDPOINT_URL_S3 or AWS_ENDPOINT_URL. Azure capture_from_env reads the account name and endpoint from the environment. The Lance object-store guide selects the store from the URI scheme and takes the endpoint and account from the environment or from storage_options. This target format has no storage_options.

RFC 0059 says to pin destination-affecting cloud settings in the binding, and to refuse an incomplete cloud destination. The user guide and the changelog call every entry one complete binding. This pull request should not merge while those docs say that, and while question 1 of RFC #1017 is still open. I did not open a live S3 or Azure store. This finding is from the storage code and the CLI accept path.

crates/omnigraph-cli/src/targets.rs (validate_storage_uri, TargetBinding::digest), crates/omnigraph-storage/src/lib.rs (s3_from_root_uri_with_scope, capture_from_env).

6. login --target prints a servers: note that breaks the file

login --target staging with credential owner staging-owner (absent from servers:) exited 0 and stored the token. stderr then said the token applies once you add servers: staging-owner: url: <server url>. The token already applies through the target. Adding that block with a different URL makes validation fail (one owner at two endpoints), and later commands refuse to load the file. JSON also reports "server" as the owner and "declared": false.

finish_login is the existing servers: path. The login_owner path must not emit that note.

crates/omnigraph-cli/src/commands/local/mod.rs (run_login), crates/omnigraph-cli/src/commands/local/output.rs (finish_login), crates/omnigraph-cli/src/commands/targets/mod.rs (login_owner).

Optional

  • target list aliases use the field server (AliasOutput). RFC rfc: graph access and cross-process writes #1017's protocol 1 example uses aliases[].target. Existing aliases still name servers: entries, so server matches today's file. Protocol 1 is meant to be final when it lands. Omit aliases until they name targets, or document server as the legacy field and bump the protocol when it changes. crates/omnigraph-cli/src/targets.rs.
  • The digest is the raw JSON text. /data/g and file:///data/g digest differently for one store. default_graph is omitted on purpose, and a unit test locks that. Phase 3 destination identity will inherit this. TargetBinding::digest.
  • scope_flag_matrix_matches_capabilities_issue_1023 zips ScopeFlag::ALL (7 flags, including Target) with 6-bool rows. The extra flag is never compared. A wrong flag_applies for --target would still pass. crates/omnigraph-cli/src/planes.rs.
  • docs/user/cli/targets.md says every edit holds an exclusive lock. ConfigEditLock is a no-op off Unix. Two concurrent edits there can drop an entry. The rename does not exclude the other writer. crates/omnigraph-cli/src/targets.rs.

Where the code and RFC #1017 disagree

RFC head reviewed: 7c72c7e7ed2b42fb0e5b3f5140d8235444ddcf33 (docs/rfcs/2026-10-16-graph-access-serving-and-cross-process-writes.md). RFC 0059 head: 323068fac096 on draft #675.

The blocking items 1–5 are disagreements with that text, not only with the user guide. In addition, these gaps are phased and should stay out of this pull request's defect list:

  • Legacy --server, --store, --cluster, --profile, use, and folder context remain. RFC rfc: graph access and cross-process writes #1017 wants them retired at once. Unresolved question 1 still asks RFC 0059 to agree. Keeping them until that answer is the right interim. Say that in the changelog. The user guide already says they keep working.
  • init and cluster refuse --target. The guide says "yet". RFC 0059 allows init to use a direct graph target or a literal URI.
  • No managed targets. No access command (that is feat(cli): say who you are at a target and what you may do #1081). local_servers is always empty (phase 3). version --json reports protocols.target and protocols.credential only. RFC rfc: graph access and cross-process writes #1017 also names serve, studio, and access. Those commands are not in this branch.
  • target show --json wraps one target with protocol and default. The RFC asks for one entry in the list-item shape. The extra fields are additive.
  • RFC evidence names cli_schema_config as the owner of target list, show, add, remove, and credential get. The new tests live in cli_data. Behavior coverage is present. The owner name in the RFC is the mismatch.

Siblings

Reviewed delta is this branch against #1078 head b19f6c2f (commits 178f30e6, b3d1ab32, and merge f7fe98c7). Exit codes, expect-actor, and error outcome belong to #1078, #1074, and #1076. Access belongs to #1073 and #1081. Local servers belong to #1075 and #1077. --as on reads belongs to #1059. Branch head, fork, and creator belong to #1012.

All of those pull requests are still open. Likely conflicts: #1059 in planes.rs; #1012 in cli_data.rs, client.rs, output, openapi.json, api-types, and commands.md; #1073 in ErrorCode and openapi.json. #1081 already contains this branch plus the access route, so a fix here needs a rebase there.

Order: #1074, then #1076, then #1078, then this pull request, after RFC #1017 is accepted. #1081 follows this one.

Liability

One named binding that the existing scope resolver turns into today's address is a smaller surface than a second config language. The editor fail-closes on flow mappings and tabs, and the tests cover a one-entry edit. That removes the risk of a second writer rewriting the whole file.

What this adds, if the six items ship as they are: two resolvers for one rule, a name lookup on a value that is already a URI, an endpoint grammar that is wider than the request matcher, and a digest that cannot see a cloud redirect. The next target kinds will each need a special case in those same places. Fix the address once in scope_from_binding and require_cluster_scope, and require RequestTarget::of plus a pinned cloud destination before an entry is complete.

Validation

Head f7fe98c7fa0c78562246513857f92540324da3fd. Base 0a62bb7b2aa5b99b012f413807c216277f7f9596. rustc 1.97.1. RUST_MIN_STACK=16777216.

Passed at this head:

  • cargo test -p omnigraph-cli --lib targets:: — 8 passed
  • cargo test -p omnigraph-cli --lib scope:: — 20 passed
  • cargo test -p omnigraph-cli --lib invocation::tests::login_and_logout_name_one_credential_wherever_target_appears -- --exact — 1 passed
  • cargo test -p omnigraph-cli --lib planes::tests::scope_flag_matrix_matches_capabilities_issue_1023 -- --exact — 1 passed (this test does not cover --target; see the optional finding)
  • integration cli_data::targets_address_commands_and_carry_only_their_owners_token — passed, 0.17s
  • integration cli_data::target_commands_edit_one_entry_and_keep_the_rest_of_the_file — passed, 0.12s
  • integration cli_data::login_and_credential_get_follow_the_target_credential_owner — passed, 0.09s
  • integration cli_data::served_credential_follows_the_sent_url_issue_1022 — passed, 0.15s
  • integration cli_queries::queries_list_prints_registered_query — passed, 0.10s
  • integration cli_schema_config::version_command_prints_cli_version_http_contract_and_storage_formats_issue_977 — passed, 0.03s
  • integration system_local::local_cli_keyed_credentials_authenticate_url_matched_server_issue_1022 — passed, 0.85s

Credential regression for b3d1ab32. GQT cannot see the operator file or the Authorization header, so the owner is the in-source test. The pull request already contains it.

  • Before: targets.rs from 178f30e6, test from this head. cargo test -p omnigraph-cli --lib targets::tests::a_target_token_goes_only_to_urls_under_its_endpoint -- --exact. Failed. Left Some(Keyed("staging-auth")), right Some(Anonymous { target: "public" }). An invalid server entry still supplied staging-auth to the anonymous target's URL.
  • After: restored targets.rs at f7fe98c7. The same command passed (1 passed).

The manual cases above used OMNIGRAPH_HOME=/tmp/review-1080/repro/home and target/debug/omnigraph built at this head.

Not run: the full CLI suite, clippy, fmt, docs checks, typos, the OpenAPI test, a live S3 or Azure open, GQT, and CI for this commit. The first integration filter used bare test names and ran 0 tests; those runs were discarded and repeated with module::test names.

Write facts survive task boundaries. Whether a request had submitted an
owned write, and whether its load forked a branch, lived in a task-local,
so an MCP tool call, which runs its handler in an owned read task and
builds the error body in another, lost both: a load whose `from` forked a
new branch and then met a key conflict reported not_started with no
effects. ApiError now carries `compound` beside `owned_write`, an owned
read keeps the request's record in its own task, and an error leaving it
takes that record along; the outcome reads the error's own fields. The
outcome unit test gains cases run through an owned read, the MCP write
test a load that forks and then conflicts, and the HTTP load test its
twin.

A load is compound only when it forks. Both load routes marked it whenever
`from` was set, so a refused load onto an existing branch reported unknown
although its one publication never ran. The routes now mark it from the
branch listing their authorization already reads, and only on the owned
write's error. An earlier step's publication also outranks a refusal the
write met before it started. The HTTP load test covers a key conflict onto
an existing branch with `from`, which reports not_started.

Every error the server answers has a code. ApiError's code is no longer
optional, and the deployment status read that races an owner change
answers 503 `service_unavailable` with a retry outcome instead of no code.

Malformed input, unknown routes and unserved methods answer the error
body. Handlers took axum's Json, Query and Path, whose rejections are plain
text (422 for a JSON body of the wrong shape), and unrouted requests had
empty bodies. ApiJson, ApiQuery and ApiPath refuse with ApiError (400, or
the transport status for 413 and 415), the router's fallbacks answer
not_found and method_not_allowed, and a source guard refuses a handler
parameter of axum's own extractors. The three query structs whose
parameter location came from the extractor's name declare it, so their
optional parameters render as the other query parameters do.

Embedded callers can render an engine failure as a served request with
the same facts would: engine_error_output_after takes whether a write was
submitted and whether an earlier step published, and
engine_read_error_output renders a read, which never answers
outcome_unknown.

The RustFS S3 test expected the 424 external Blob source refusal to carry
no code; it now asserts its code and outcome. The troubleshooting guide
and the changelog note state the outcome decision table the server
implements.
…efusals as the server does

The merge keeps this branch's command kinds and ModernRelay#1074's served cluster
`--as` exit: a cluster command is observed without being classified.

An embedded `load --from` marked its write compound whenever `from` was
set, so a refusal before its fork exited 2. It now marks it only when the
branch is missing, as the server's load does, and an embedded write's
own error goes through `engine_error_output_after`: a policy refusal
precedes every publication, the fork's included, and any other refusal
of a compound load follows the fork. A read's engine error keeps its own
code instead of `outcome_unknown`, and the failure reporter no longer
panics on a wrapped engine write error.

The crossversion journeys read a `--json` read's refusal from stdout,
where the failure now goes. The exit documentation says once that a
failed condition exits 4 embedded or served, names the data commands the
outcome table covers and the commands that keep their own exits, and says
that only the argument parser's refusals exit 64.
…teral

A cluster-root target on a command that addresses one standalone graph was
dropped: `schema apply` lost the root and failed asking for an address.
Every scope resolver call now declares whether it reads a root, and one that
does not (`schema apply`, `schema upgrade-system-columns`, `upgrade` and the
data commands) refuses a resolved root by kind. A target's kind refusal
carries `target_kind_unsupported`; a profile's keeps its untyped message.

`queries` and `policy` resolve their cluster through the same scope resolver
as every other command, so `OMNIGRAPH_PROFILE` beside `defaults.target` is a
conflict, and an explicit profile or target that names no cluster root
refuses instead of falling through to the default target. A target's default
graph still applies there and a profile's still does not.

A target's root URI is used as written. Only `--cluster` and profile cluster
names are looked up under `clusters:`, so a `clusters:` key spelled like a
target's URI no longer redirects it.

A server target's endpoint must be a whole server URL, one the request
target parser accepts, with no user, password, query or fragment; `https://`
alone and an endpoint carrying a password no longer pass as bindings. An
incomplete entry is listed with its address redacted. `servers:` urls keep
their existing rules.

A direct target cannot name `s3://` or `az://` storage and refuses with
`target_invalid`: the store's endpoint and account come from the environment
when it opens, and this format cannot pin them in the binding. Cloud storage
keeps its `--store` and `--cluster` addresses.

`login --target` and `logout --target` report the target and its owner, and
a target's credential owner counts as declared, so `login` no longer
suggests a `servers:` entry that would bind the owner a second time.

`target list` no longer lists aliases, which still name `servers:` entries
rather than targets.

A direct target's binding digest hashes its normalized storage, so `/data/g`
and `file:///data/g` are one binding; the default graph stays out of it.

The scope flag matrix test compares `--target` as its seventh column.

`target add` and `target remove` refuse with `config_not_editable` off Unix,
where no lock holds the edit.
…target's default graph

The merge keeps this branch's typed-refusal branch in the failure
reporter beside ModernRelay#1078's projection of embedded engine errors.

`queries` and `policy` keep their explicit `--graph` filter and no longer
take a target's `default_graph`, as RFC 0059 specifies for root tools, so
their all-graph and ambiguity behaviour is the same whatever selected the
root.
@ragnorc

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at 6516703 (fix e2eeff1, plus the merge of #1078's fixes).

Blocking

  1. Root targets dropped. Every scope resolution now declares whether its command reads a cluster root (reads_root); a command that doesn't refuses a resolved root by kind with target_kind_unsupported. That covers schema apply, schema upgrade-system-columns, upgrade and the data commands, so no call site can discard a root. Tests: scope::tests::a_root_refuses_by_kind_where_the_command_reads_no_root, plus the four repros in cli_data::targets_address_commands_and_carry_only_their_owners_token.
  2. Second profile rule. require_cluster_scope now calls resolve_scope with Capability::Control and requires a root, so there's one rule. Both repros refuse. Tests: cli_queries::queries_list_prints_registered_query and scope::tests::cluster_tooling_resolves_only_a_root_by_the_same_rules. Per RFC 0059, root queries/policy now also ignore a target's default_graph and keep their explicit --graph filter.
  3. Root URI looked up in clusters:. The resolver resolves names first and checks kind second, so a target's root URI stays literal. Test: scope::tests::a_target_root_is_used_as_written_while_cluster_names_resolve.
  4. Endpoint secrets and non-URLs. ServerTransport::parse_endpoint requires an address RequestTarget::of accepts, with no userinfo, query or fragment. An incomplete entry is listed redacted, and errors never echo the endpoint. servers: URLs are unchanged, since refusing their userinfo could break existing entries.
  5. Cloud targets. s3:// and az:// direct targets refuse with target_invalid, because this version can't pin their storage settings. --store/--cluster still reach cloud storage. The docs and changelog say so.
  6. login --target note. It reports {target, owner, credentials_path, declared: true} and prints no servers: note. Test: cli_data::login_and_credential_get_follow_the_target_credential_owner.

Optional, all done:

  • Aliases are left out of the protocol-1 listing until they name targets.
  • A direct target's digest hashes the normalized root.
  • The flag matrix gains its --target column.
  • Target edits refuse off Unix with config_not_editable rather than editing without the lock.

RFC 0059 (#675) has since been updated: the end state is one breaking release with no mixed format and with inline targets. This PR stays additive until that is accepted.

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Request changes. One blocking item remains. Merge this pull request after that fix, and after draft RFC #1017 and RFC 0059 (#675) accept the target format. Land #1074, #1076, and #1078 first.

Earlier findings at f7fe98c

All six blocking items and all four optional items from the review at f7fe98c7 are resolved at this head.

  1. Resolved. A command that addresses one graph refuses a cluster-root target with target_kind_unsupported. reads_root is false on the data-client paths, on upgrade, and on schema upgrade-system-columns. scope::tests::a_root_refuses_by_kind_where_the_command_reads_no_root passed. The cli_data refuse_root cases for schema apply, upgrade --check, and schema upgrade-system-columns passed.
  2. Resolved. queries and policy use the same resolver as every other command. require_cluster_scope calls resolve_scope with Capability::Control and returns only the root. cli_queries::queries_list_prints_registered_query covers an ambient profile conflict, an explicit non-root profile, and an explicit cluster profile. The fixture target has no default_graph, so that test does not lock the "ignore default_graph" rule. The code does ignore it: the function returns scope.cluster and drops cluster_graph.
  3. Resolved. A target URI stays literal. a_target_root_is_used_as_written_while_cluster_names_resolve passed: target ops keeps /tmp/x/named, while --cluster of that same string still resolves through clusters:.
  4. Resolved. A server endpoint must be a whole URL that RequestTarget::of accepts, with no user, password, query, or fragment. An incomplete entry is listed through redacted_storage_uri. The integration target add of https://user:secret-token@h.example/g and of https:// passed. A file:// URI with userinfo also refuses at Url::parse (target_invalid), and the secret is absent from the output.
  5. Resolved for this version. s3:// and az:// direct targets refuse with target_invalid. --store and --cluster still reach cloud storage. The guide and changelog.d/cli-named-targets.added.md say so.
  6. Resolved. login --target reports target, owner, credentials_path, and declared: true. It prints no servers: note. The integration login test passed.

Optional items:

  • A, resolved for protocol 1. target list has no aliases field. The guide says aliases are not listed.
  • B, resolved for direct storage. /data/g, file:///data/g, and a trailing slash share one digest. The server-endpoint gap is the new optional item below.
  • C, resolved. The flag matrix has seven columns, and the last column is --target. scope_flag_matrix_matches_capabilities_issue_1023 passed.
  • D, resolved by inspection. Off Unix, ConfigEditLock::acquire refuses with config_not_editable. This host is Linux, so that arm was not executed.

Delta since f7fe98c

e2eeff14 is the fix. It adds reads_root, keeps a target URI literal, requires a whole-URL endpoint, refuses a cloud URI on a direct target, and stops the servers: login note.

65167031 merges feat/cli-exit-table. The conflict resolution keeps the CliRefusal classification and then the #1078 engine-error projection. It drops a target default_graph from queries and policy: require_cluster_scope returns the root string only. The guide matches that. That agrees with RFC 0059 at bf9bcce0 (root queries / policy keep an explicit --graph filter and ignore default_graph).

267c5d1e merges the updated exit-table branch. The combined diff has no conflict hunks. Against its first parent it changes one paragraph in docs/user/operations/server.md from the sibling. No target logic changes there.

Why, what, how, impact

An operator needs one name for one place and one credential. This change adds targets: and --target NAME as a complete address.

The fix makes every scope resolution say whether the command reads a cluster root. A target URI is used as written. A server endpoint is a URL the request matcher accepts. A direct target refuses cloud storage whose endpoint still comes from the environment. login --target writes no line that would break the operator file.

Those six paths now keep the address the target named. schema plan still opens a cluster-root target, and its flag guard then refuses the flags that would finish that address.

Blocking

schema plan accepts a cluster-root target and then refuses --graph and --cluster

schema plan is a direct command. accepts_cluster_addressing omits SchemaCommand::Plan, so the flag guard rejects --cluster and --graph. --target still applies, because a direct command other than init accepts it.

schema plan then calls resolve_maintenance_uri, and that helper sets reads_root: true for every caller:

pub(crate) async fn resolve_maintenance_uri(
    // ...
) -> Result<String> {
    let scope = scope::resolve_scope(
        // ...
        scope::ScopeFlags {
            // ...
            reads_root: true,
        },
    )?;

A root address with reads_root: true becomes a cluster scope. scope_from_target also fills cluster_graph from the target's default_graph when the command passes no graph. resolve_storage_uri then opens cluster state. With no default_graph, resolve_sole_cluster_graph uses the only graph, or it tells the operator to pass --graph. The flag guard refuses that --graph.

The guide says a cluster-root target serves direct maintenance as --cluster does, and that --graph overrides default_graph. It lists schema apply, schema upgrade-system-columns, and upgrade as graph-only. It does not list schema plan. The flag surface and the guide disagree with the resolver.

Measured with /workspace/target/debug/omnigraph at this head:

  • schema plan --cluster /tmp/c --graph knowledge --schema … --json exits 1. The error says `schema plan` is a direct (storage-native) command and that --cluster does not apply.
  • schema plan --target ops --graph other --schema … --json refuses the same way for --graph, with outcome.execution = not_started.
  • schema plan --target ops --schema … --json, with ops set to uri: /tmp/review-1080-not-a-cluster and default_graph: knowledge, has no code. The error is cluster `/tmp/review-1080-not-a-cluster` has no applied state; run `cluster apply` first.

The root was opened as a cluster. optimize --target ops --graph other accepts --graph and fails later on the missing cluster. That path matches its flag surface.

The cause is the shared helper. It declares "this command reads a root" for a command whose flags say the command addresses one storage URI. Any later direct command that shares the helper and rejects --cluster / --graph will take the same back door. Today that command is schema plan. optimize, repair, cleanup, rebuild-full-text-indexes, and lint accept those flags, so they stay consistent.

Until RFC 0059 is accepted, refuse a root on schema plan the way schema apply does. Call the resolver with reads_root: false for that command only. Keep --cluster and --graph refused. Extend the existing refuse_root closure in crates/omnigraph-cli/tests/integration/cli_data.rs (targets_address_commands_and_carry_only_their_owners_token) with schema plan --target ops --schema … --json, and expect target_kind_unsupported.

RFC 0059's accepted end state is the other design: schema plan takes a direct graph, or a direct root plus a selected graph, and that root path does not depend on which old selector supplied it. Do that in one change with the flag matrix and the guide, when the RFC is accepted. This additive pull request should keep the current flag surface.

Optional

A server endpoint digest is the raw string

TargetBinding::digest normalizes a direct URI through normalize_root_uri. A server digest hashes the endpoint string as stored. Credential checks already treat https://a and https://a/ as one endpoint (targets_tests.rs, case "same endpoint"). RequestTarget treats the default port as the same endpoint (operator.rs, find_server_for_target_matches_where_the_request_is_sent).

Measured at this head, these four endpoints produce four digests:

  • https://h.example → sha256:60e4660ff552f9c2926ef419e5aef3486418a6989df7cdd2bb218283d5adb2ba
  • https://h.example/ → sha256:6a8083f9d7f979baa4a47010c213fca2e3ed18fa2130c5aa4a079191e1e540a3
  • https://h.example:443 → sha256:b2ab951587c0c99524276c4871b03c60f37a6ce9492b85c12e91605014241ece
  • https://H.example → sha256:39aaa77a7683c4f37c95b6945f9d61404eb69371a33f6ada5ebbbfa448935cde

Protocol 1 already prints binding. RFC 1017 phase 3 names the local-server election lock by that digest. Digest the same identity RequestTarget uses, before a client stores a protocol 1 value. Tokens still follow RequestTarget, so this does not mis-route a credential in this pull request.

Code and the RFCs

RFC #1017 is still 7c72c7e7. RFC 0059 (#675) moved to bf9bcce0 after the previous review. This branch stays additive until those RFCs are accepted. These differences are the phased gap, except the schema plan item above, which disagrees with the guide and the flag matrix on this branch now.

  • Legacy --server, --store, --cluster, --profile, use, and folder context remain. The guide says they keep working.
  • init and cluster refuse --target. RFC 0059 allows init on a direct graph target or an inline URI.
  • There is no inline --target ADDRESS, no target import, and no managed target.
  • local_servers is an empty list on a direct target. Live records belong to phase 3.
  • version --json reports protocols.target and protocols.credential. The RFC also names serve, studio, and access. Those commands are not on this branch.
  • target show --json wraps one target as {protocol, default, target}. The RFC example is one list item. The extra fields are additive.
  • Aliases stay off the list. The RFC 1017 protocol-1 example includes aliases[].target.
  • A direct cloud URI is refused. RFC 0059's end state pins the cloud endpoint and account in the binding. The refusal matches the RFC sentence that an incomplete cloud destination is refused while the environment still chooses the address.
  • Maintenance still auto-selects a sole cluster graph (resolve_sole_cluster_graph). RFC 0059 removes that discovery at the breaking release.
  • schema plan is in the RFC 0059 row "direct graph, or direct root plus selected graph." This branch accepts the root and refuses the graph selector. That row is the blocking item, not only a future mismatch.

Root queries and policy ignore default_graph. That matches RFC 0059.

Siblings, order, overlap

Stack order: #1074 (8a4288ad), #1076 (23027f54), #1078 (d5cda7d5), then this pull request, after RFC #1017 and RFC 0059 are accepted. #1081 (592151d0) contains 267c5d1e, so a fix here needs a rebase of #1081 and then #1083 (d6702d1e).

#1073 is the access route. #1075 and #1077 are the local servers. #1083 is omnigraph serve. Leave their behavior on those pull requests. #1059 overlaps planes.rs (--as on reads). #1012 overlaps CLI listing and cli_data. #1067 is an open issue about a storage write of unknown outcome. It is not a pull request, and it is not this CLI change.

Exit codes, omnigraph-expect-actor, the access route, and local servers stay with their owners.

Liability

One named binding that becomes the existing address tuple is the right primitive. The fix removes six forks: a dropped root, a second profile rule, a clusters: lookup of a URI, an endpoint wider than the request matcher, a cloud URI advertised as complete, and a login note that breaks the file.

reads_root is one boolean on the shared maintenance helper. A command with a different flag surface cannot say "this command addresses one graph" without its own call. schema plan is that command. Five more commands that call the helper unchanged will copy the hole. Refusing the root on schema plan closes the class. The editor, the credential file, and the token-to-endpoint rule stay one source of truth.

Efficiency is unchanged: the extra work is one kind check. The additive format keeps the old selectors until the RFC's one breaking release. That is the right deferral. The shared helper, left as it is, buys one call site and ships two address rules for one command.

Validation

Head 267c5d1e248c624a032a032a288a20f4bef01e77d83b in a detached worktree. rustc 1.97.1. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216.

  • cargo test -p omnigraph-cli --lib -- targets:: scope:: planes::tests::scope_flag_matrix_matches_capabilities_issue_1023 invocation::tests::login_and_logout_name_one_credential_wherever_target_appears -- --test-threads=8 — 34 passed, 0 failed, 224 filtered out.
  • cargo test -p omnigraph-cli --test integration -- the target, login, queries, and version filters — 5 passed. system_local::local_cli_keyed_credentials_authenticate_url_matched_server_issue_1022 failed once because the test looked for omnigraph-server under the worktree target/ and then fell through to cargo run inside the 30s ready wait.
  • Rerun with CARGO_BIN_EXE_omnigraph-server=/workspace/target/debug/omnigraph-server (hyphenated name passed through env, because it is not a shell identifier) — 1 passed in 0.30s.
  • CLI probes above for schema plan, optimize, file:// userinfo, and the four endpoint digests, using /workspace/target/debug/omnigraph.

GQT cannot express an operator file, a CLI flag, or an Authorization header. The owner is omnigraph-cli library tests and tests/integration. The six earlier fixes have tests on this head, and those tests passed. The pre-fix commit f7fe98c7 was not rebuilt in this session. The previous review recorded the manual failures there. No temporary test was added, and the worktree is clean.

Not run: the full CLI suite, cargo fmt, clippy, the docs checks, live S3 or Azure, a non-Unix lock, and CI for this SHA. No performance claim is made.

A server that predates `omnigraph-expect-actor` ignores it, so `--as` on
a served write would run as the credential's actor. The earlier fence, a
response header listing the assertions a server enforces, was a second
compatibility record beside the contract version: two servers speaking
0.13 could disagree about the assertion, and every further assertion
would extend an enum on every response.

The contract moves to 0.14 instead, the one bump the access route,
`outcome` and the code registry also ride. A 0.13 server answers
discovery with its own contract, so the CLI refuses it before sending,
and it refuses a 0.14 request before reading the header. The assertions
header, its constant and the CLI's discovery check are gone, and so is
the enum on every OpenAPI response.

The guides, the developer pages, the skill, the version samples and the
fixtures name 0.14, and a breaking note says to upgrade the CLI, server,
Studio and HTTP integrations together. The server guide says a repeated,
padded or empty header is refused, and that `/mcp` compares it after
authentication; the cluster skill names the subcommands that take `--as`.
Brings in HTTP contract 0.14 and the removal of the assertions response
header, so this branch speaks the contract its outcome field rides.
…failed

A load names whether it forked its branch only from inside the engine:
the routes took that fact from the branch listing their authorization
reads, before the load lists the branches again and decides. A branch
deleted between the two, as one can be while an NDJSON body is still
arriving, was forked by the load, and a refusal of its rows then answered
`not_started` with no effects although the fork had published.

`CompletionEvidence::PartlyPublished` records that an earlier step of
the owning operation published and a later one failed. The load marks
every failure after its fork with it, the window between the fork and the
rows included. It outranks the definite `BeforeEffect` and `Unpublished`,
which no longer cover the operation, survives composition, and never
hides `Uncertain`. The server reads it as the compound fact and leaves
containment where no evidence leaves it, so a typed refusal keeps its
code with `unknown` and `refresh`. The routes' list-derived flag and
`in_compound_write` are gone, and `engine_error_output_after` drops its
`compound` argument: embedded callers get the fact from the error.

A route test deletes the branch while the NDJSON body waits and expects
the fork reported; the core test pins the composition rules. The key
conflict and `graph_unavailable` descriptions say what they now mean, and
the breaking note names the plain query-parameter types in openapi.json.
Brings in HTTP contract 0.14 and the engine's mark of an earlier
publication. The embedded load no longer lists the branches to predict a
fork: the engine marks every failure after its fork with
`PartlyPublished`, so `embedded_write_output` reads that mark, and
`Evidence.compound` and `command_outcome::compound` are gone. A branch
deleted after a list and before the load's own check can no longer
report `not_started` after the fork published.
The outcome unit test passed even if `load` predicted a fork for every
`--from`, because a policy refusal ignores the prediction. A process
test now drives the call site. Onto a branch that exists, `--from` forks
nothing, and a refused payload exits 1 with `not_started`. Onto a
missing branch the fork publishes first, and the same refusal exits 2
with `unknown` while the branch stays. The troubleshooting guide's
"exit alike" sentence is narrowed to those cases: a policy refusal is
`not_started` on both planes, and a refusal after a fork that did
publish is `unknown` on both.
Brings in HTTP contract 0.14 and the engine's fork mark for embedded
loads. The command-outcome helpers keep `local_effect` and drop the
fork prediction, and the skill's `--as` line keeps `--target` and names
the served cluster commands that assert the actor.
@ragnorc
ragnorc requested a review from azimafroozeh as a code owner October 19, 2026 07:33
`schema plan` refuses `--cluster` and `--graph`, yet the shared
maintenance resolver declared that every caller reads a cluster root, so
a cluster-root target was opened as a cluster and then needed the
`--graph` the flag guard refuses. `resolve_maintenance_uri` now takes the
caller's own `reads_root`: `schema plan` passes false and refuses a root
target with `target_kind_unsupported` as `schema apply` does, while
optimize, repair, cleanup, the full-text rebuild and lint keep reading
roots. The guide lists `schema plan` with the graph-only commands.

A server target's binding digest hashes its endpoint as requests are
matched to it (`RequestTarget::canonical`: the host as the URL parser
folds it, the default port filled in, no trailing slash), so
`https://h`, `https://h/`, `https://h:443` and `https://H` are one
binding, before a client stores a protocol 1 digest.
@ragnorc

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at 42d5998, after 7901604 (the merge of the updated #1078).

Blocking: schema plan and a root target. The fix is at the helper, not one call site. resolve_maintenance_uri now takes the caller's own reads_root instead of asserting true for everyone, with a doc comment tying it to accepts_cluster_addressing.

  • schema plan passes false, so a cluster-root target refuses with target_kind_unsupported before anything opens, as schema apply does, and --cluster/--graph stay refused.
  • optimize, repair, cleanup, rebuild-full-text-indexes and lint pass true, matching their flags.
  • A later direct command that calls the helper has to answer the question.
  • cli_data::targets_address_commands_and_carry_only_their_owners_token's refuse_root gains schema plan --target ops --schema … --json and expects target_kind_unsupported with not_started.
  • docs/user/cli/targets.md lists schema plan with the graph-only commands.

Optional: server endpoint digest. TargetBinding::digest now hashes a server endpoint as RequestTarget::canonical: the host as the URL parser folds it, the scheme's default port filled in, no trailing slash. https://h.example, https://h.example/, https://h.example:443 and https://H.example are one digest; another port or another base path is not. a_binding_digest_names_where_a_target_points_and_nothing_else covers all of it. Tokens still follow RequestTarget, so nothing about credential routing changed.

Validation: cargo test -p omnigraph-cli -- --test-threads=6 (249 lib, 339 integration), clippy with -D warnings, check-docs, typos. #1081, #1083 and #1084 take this through their merges.

The access route from ModernRelay#1073 now rides HTTP contract 0.14 with the actor
header: its policy guide example sends 0.14, and its OpenAPI operation
lists the contract value and the optional omnigraph-expect-actor
parameter like every other protected route.
Brings in main through ModernRelay#1074: the access route from ModernRelay#1073 under HTTP
contract 0.14.
Brings in main through ModernRelay#1076: the access route from ModernRelay#1073 under HTTP
contract 0.14.
Brings in main through ModernRelay#1078: 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#1080: the access route as ModernRelay#1073 landed, whose
handler and output type this branch already carries in their evaluated
form (the conflict keeps this side), HTTP contract 0.14, and `schema
plan` refusing a root target.
The key-conflict description said the refused rows wrote nothing; the
OpenAPI vocabulary guard keeps graph vocabulary in the published
description, so it now says the refused write changed nothing.
Brings in the key-conflict description in graph vocabulary.
Brings in the key-conflict description in graph vocabulary.
A server target's binding digest spelled a Unix socket path with its
lossy display, so two socket paths that differ only in bytes no display
can print, `unix:///tmp/%FE.sock` and `unix:///tmp/%FF.sock`, digested
alike although they connect to different sockets. The canonical endpoint
now spells the path's bytes in hex, and the digest test pins that two
such paths are two bindings.

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Comment. The blocking item and the optional item from the review at 267c5d1e are resolved at e67386ed. I do not request more code changes. Merge this pull request after draft RFC #1017 and RFC 0059 (#675) accept the target format, and after #1074, #1076, and #1078 land.

Earlier findings at 267c5d1

Blocking: schema plan accepts a root target — resolved

resolve_maintenance_uri takes the caller's reads_root. schema plan passes false. optimize, repair, cleanup, rebuild-full-text-indexes, and lint pass true. Those five commands accept --cluster and --graph. schema plan refuses both flags.

A root target now refuses with target_kind_unsupported before the cluster opens. cli_data::targets_address_commands_and_carry_only_their_owners_token covers schema plan --target ops.

With that argument set back to true, which is what the helper forced at 267c5d1e and 79016043, the same test failed. The error said the cluster has no applied state and said to run cluster apply first. code was null. At e67386ed the test passed.

Optional: a server digest is the raw endpoint string — resolved

TargetBinding::digest hashes a server endpoint through RequestTarget::canonical. The host is the host the URL parser stores. The default port is filled in. https://h.example, https://h.example/, https://h.example:443, and https://H.example are one digest. Another port, or a base path, is a different digest.

The first spelling of a Unix socket used Path::display. That display drops bytes it cannot print. At 8ef33888, unix:///tmp/%FE.sock and unix:///tmp/%FF.sock both digested to sha256:5fd1c37ac2ed91c0608494d92f25ac3b1bb99f3b1101405d960a19d4f7681cbe. e67386ed spells the socket bytes in hex. The same assertion passes. Tokens still follow RequestTarget, so this change does not move a credential.

Delta since 267c5d1

42d59986 is the reads_root fix and the first canonical digest. e67386ed is the socket-byte spelling.

Three merges bring in #1078: 79016043, 6adf9c71, and 8ef33888. 6adf9c71 and 8ef33888 match a fresh merge. 79016043 keeps local_effect from this branch and drops compound. The parent branch lets the engine mark a load that forked, so the CLI no longer predicts that fork. The skill line keeps --target and the parent's --as text for a served cluster plan and cluster apply. The diff from #1078 tip 0a11b0da to this head is this pull request's target fields, error codes, and local_effect.

Why, what, how, impact

An operator needs one name for one place and one credential. This change adds targets: and --target NAME as that name.

This delta makes the maintenance resolver say whether the command reads a cluster root. A command whose flags cannot finish a root refuses the root. A server digest names the endpoint RequestTarget matches, and a Unix socket digest names the bytes the transport connects to.

The contract is the address the operator named. The extra work is one kind check and one hash. There is no new store and no second credential file.

Blocking

None.

Optional

None.

Code and the RFCs

RFC #1017 head is e15d2f5b, one commit after 7c72c7e7. That commit aligns LocalBinding and the HTTP contract. It does not change the target protocol. RFC 0059 head is still bf9bcce0.

schema plan now matches this branch's flags and docs/user/cli/targets.md. RFC 0059's end state still gives schema plan a direct graph, or a direct root plus a selected graph. Do that with the flag matrix when the RFC is accepted. This pull request keeps the current flags.

These gaps stay phased, and they are not defects in this additive change:

  • Legacy --server, --store, --cluster, --profile, use, and folder context remain. The guide says they keep working.
  • init and cluster refuse --target. RFC 0059 allows init on a direct graph target or an inline URI.
  • There is no inline --target ADDRESS, no target import, and no managed target.
  • local_servers is an empty list on a direct target. Live records belong to phase 3.
  • version --json reports protocols.target and protocols.credential. The RFC also names serve, studio, and access. Those commands are not on this branch.
  • target show --json wraps one target as {protocol, default, target}. The RFC example is one list item.
  • Aliases stay off the list. The RFC protocol-1 example includes aliases[].target.
  • A direct cloud URI is refused. That matches the RFC sentence that refuses an incomplete cloud destination while the environment still chooses the address.
  • Maintenance still selects a sole cluster graph. RFC 0059 removes that selection at the breaking release.

Root queries and policy ignore default_graph. That matches RFC 0059.

Siblings and order

Stack order: #1074 (9666b14f), #1076 (78963e94), #1078 (0a11b0da), then this pull request, after the two RFCs are accepted.

#1081 (fb2ab08bab), #1083 (6ee0c3d1), and #1084 (24be23f4) each contain 8ef33888 and are one commit behind e67386ed. They need the socket-byte commit.

#1073 is merged. #1075 and #1077 own the local servers. #1083 owns omnigraph serve. Leave their behavior on those pull requests. #1059 overlaps planes.rs for --as on reads. This delta does not edit that file. #1012 overlaps cli_data.rs, client.rs, the error-code enum, and openapi.json for branch listing. #1072 is the Lance 13 bump. #1086 is ANN diagnostics. Those two do not change target resolution.

Liability

One named binding that becomes the existing address tuple is the right primitive. This delta removes the forced root read in the shared maintenance helper. Each caller passes its own reads_root. The five commands that read a root pass true. schema plan passes false. The socket digest uses the bytes the transport connects to, so a lossy display cannot merge two sockets.

reads_root repeats accepts_cluster_addressing. The two agree for every caller on this head. The helper does not see the command, so a later caller can pass the wrong value. The comment on the helper states the rule.

The extra work is one kind check and a short hex string for a socket path. The old selectors stay until the RFC's one breaking release. That deferral is the right one. Shipping the root refusal and the byte digest now keeps protocol 1 from storing a colliding identity.

Validation

Reviewed head e67386ed2d1c48b6f5feed9b5284470f1ce508f8. rustc 1.97.1. CARGO_TARGET_DIR=/workspace/target. RUST_MIN_STACK=16777216.

After the fix, local:

  • cargo test -p omnigraph-cli --lib --locked -- targets:: scope:: planes::tests::scope_flag_matrix_matches_capabilities_issue_1023 — 33 passed, 0 failed.
  • cargo test -p omnigraph-cli --test integration --locked -- the target-address test, the target-edit test, and the three schema plan tests — 5 passed.
  • At e67386ed, the digest test, scope::tests::a_root_refuses_by_kind_where_the_command_reads_no_root, and targets_address_commands_and_carry_only_their_owners_token passed.

Before the fix, the same assertions failed for the predicted reason:

  • schema plan with reads_root: true again: targets_address_commands_and_carry_only_their_owners_token failed on schema plan --target ops. code was null. The error said the cluster has no applied state and said to run cluster apply first.
  • Server digest set back to the raw endpoint: https://h.example was sha256:60e4660ff552f9c2926ef419e5aef3486418a6989df7cdd2bb218283d5adb2ba and https://h.example/ was sha256:6a8083f9d7f979baa4a47010c213fca2e3ed18fa2130c5aa4a079191e1e540a3. Those are the digests from the review at 267c5d1e.
  • At 8ef33888, the new socket assertion failed. Both unix:///tmp/%FE.sock and unix:///tmp/%FF.sock digested to sha256:5fd1c37ac2ed91c0608494d92f25ac3b1bb99f3b1101405d960a19d4f7681cbe. At e67386ed that assertion passed.

GQT cannot express an operator file, a CLI flag, or an Authorization header. The owner is the CLI library tests and tests/integration. The temporary edits are gone. The checkout is clean at e67386ed.

CI on this SHA, run 39310139512: Test Workspace passed, Format (rustfmt) passed, and Lint (clippy) passed. The storage-upgrade job, the v5 fence, one S3 job, and the AWS server job were still pending.

Not run here: the full CLI suite, live S3 or Azure, and a non-Unix lock. This review makes no performance claim.

@ragnorc

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favour of #1090, which implements the accepted breaking design of RFC 0059: one --target, no legacy routing, target import. It already carries this PR's target code. The three things from here it doesn't have yet are listed at #1090 (comment): typed target and credential codes, protocols in version --json, and the canonical endpoint digest. They'll land as a patch on #1090's CLI part. The branch stays, because #1081, #1083 and #1084 are built on it until they're rebased onto #1090.

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