Skip to content

Publish a client crate: HTTP client and wire types without the engine #1098

Open

Description

@dremnik

Applications that talk to a shared omnigraph-server (we're moving a task tracker onto one) want a typed Rust client, but today the only options are the CLI's internal client or omnigraph-api-types, which depends on omnigraph-engine and omnigraph-compiler for its engine-result → DTO mappings. Using the types pulls the whole engine into a client's build.

Proposal: (1) move the engine/compiler From mappings in omnigraph-api-types behind an engine feature (or into omnigraph-server), so the crate is plain serde/utoipa types; (2) extract omnigraph-cli/src/graph_http.rs into an omnigraph-client crate on top of it, covering /query (with snapshot), /mutate, /mutate/if-graph-commit and stored queries, with the contract header and typed ErrorCodes. The CLI then depends on omnigraph-client.

Benefit: clients build without the engine, and server and clients share one definition of the wire contract instead of hand-copied DTOs that drift across releases.

Activity

  1. ragnorc commented on Oct 21, 2026

    @ragnorc
    Contributor

    Problem

    An application that calls a shared omnigraph-server needs the HTTP JSON types and a small Rust client. Today the wire crate omnigraph-api-types also depends on omnigraph-engine and omnigraph-compiler. A client build therefore compiles the engine, Lance, and DataFusion. The CLI’s HTTP code is not a library. It is pub(crate) and it is tied to CLI configuration.

    The crate text says the types are plain serde and utoipa types. The manifest does not match that text.

    Status

    Checked again immediately before this comment. Issue #1098 is open. It has no comments. Its timeline has one event: the needs-triage label. No pull request is linked. No other issue or pull request uses the scope “client crate” or an engine feature on omnigraph-api-types. This issue stays open.

    Reproduction

    Main SHA: 7fd9cc9037c634e2f3284f1ede55a85a6f7b28e8 (7fd9cc90, “fix(storage): keep a bound handle's re-reads on its own session”).

    Host: Linux, rustc 1.97.1 (8bab26f4f 2026-07-14).

    Claim: the wire crate depends on the engine and the compiler

    Verified.

    crates/omnigraph-api-types/Cargo.toml lines 11–16 have no features. The normal dependencies are omnigraph-engine, omnigraph-compiler, serde, serde_json, and utoipa.

    cargo tree -p omnigraph-api-types --depth 1 --prefix none
    omnigraph-api-types v0.14.0
    omnigraph-compiler v0.14.0
    omnigraph-engine v0.14.0
    serde v1.0.228
    serde_json v1.0.150
    utoipa v5.5.0
    

    Workspace crates that depend on it: omnigraph-cli, omnigraph-server, omnigraph-gqt-core. The engine does not depend on the wire crate.

    .github/workflows/publish-crates.yml publishes omnigraph-api-types only after omnigraph-engine, and the comment there records that dependency. This host got HTTP 403 from crates.io, so the public index was not read.

    Claim: a client that uses the types compiles the engine

    Verified.

    cargo tree -p omnigraph-api-types -e normal --prefix none -f '{p}'
    

    The normal graph has 550 unique packages, including omnigraph-api-types. The compiler’s normal graph has 74. 476 packages are in the wire-crate graph and not in the compiler graph. That set includes utoipa and the engine closure: lance v13.0.0, datafusion v54.0.0, Arrow 58, and the other OmniGraph crates (omnigraph-core, omnigraph-catalog, omnigraph-planner, omnigraph-policy, omnigraph-storage, omnigraph-seams).

    cargo check -p omnigraph-api-types --locked --offline
    

    This finished in 4m 07s. It compiled omnigraph-engine, lance v13.0.0, and datafusion v54.0.0. The existing target/ directory was about 5.5 GB. This was not a clean build. A clean build takes longer. The count is the package tree. A separate scratch crate was not created. An external crate that depends on this package gets this same normal closure.

    Claim: the dependency exists for engine-result to DTO mappings

    Verified, and the coupling is wider than From impls.

    crates/omnigraph-api-types/src/lib.rs imports engine and compiler types at lines 6–19. Line 28 does pub use omnigraph_compiler::settings. That re-export is public, so every user of the crate compiles the compiler. The session-settings RFC keeps the definition in the compiler because a definition in the wire crate would cycle. See docs/rfcs/2026-09-16-session-settings.md, rejected alternative 13.

    These public fields name foreign types. A feature around the impls does not remove the dependency while these fields stay:

    • IngestRequest.mode, GraphBatchLoadQuery.mode, IngestOutput.mode, and GraphBatchLoadOutput.mode are omnigraph::loader::LoadMode (a three-variant serde enum). LoadModeSchema in the same file already copies it for OpenAPI.
    • SchemaApplyOutput.steps is Vec<omnigraph_compiler::SchemaMigrationStep>. That enum carries compiler PropType, Constraint, and Annotation. OpenAPI already documents the field as Vec<Value>.

    SettingsRequest fields are already plain (Option<String> and Option<i64>). SettingsRequest::assignments returns compiler SettingId and SettingValue. The OpenAPI bounds come from SettingId::spec().

    From impls in this crate, engine type to wire type: MergeOutcome, MergeConflictKind, GraphCommitKind, ChangeEntityKind, ChangeOpKind. Compiler type to wire type: SystemColumns, QueryDiagnostic.

    From impls in the other direction, wire type to engine type: EntityKindOutput to ChangeEntityKind, ChangeOpOutput to ChangeOpKind.

    Free functions take or return engine or compiler types. Examples: commit_output, read_output, snapshot_payload, schema_apply_output, load_executed_output, change_scope, change_feed_output, blob_write_output, parse_blob_if_match, param_descriptor, merge_conflict_output, and the load and branch read helpers. QueryRequest, ChangeRequest, ReadOutput, ErrorOutput, and ErrorCode are already local. They still sit in a crate that cannot compile without the engine.

    Rust’s orphan rule: a From impl must live in the crate that owns one of the types. omnigraph-server owns neither the wire type nor the engine type. The impl is illegal there. A third conversion crate has the same limit. A free function can move. A From impl cannot.

    Claim: graph_http.rs is the HTTP client for query, mutate, conditional mutate, and stored queries

    Partly. The file exists. It is not that client.

    crates/omnigraph-cli/src/graph_http.rs (GraphHttpClient, pub(crate)) does the transport admission:

    • It parses http, https, and unix server addresses.
    • It sends omnigraph-http-api: 0.14 on every data request (HTTP_API_CONTRACT is "0.14").
    • It sends omnigraph-if-actor when the CLI has an expected actor.
    • It does a HEAD of healthz before every data request, with a 5 second timeout. Discovery failure sets request_dispatched: false.
    • It requires exactly one contract header. A data response that fails that check sets request_dispatched: true and the text says not to retry.
    • It builds reqwest with redirects off and retry(never()).
    • On HTTP 401 with no bearer, it attaches the CLI MissingCredential value.

    It calls CLI modules: helpers::MissingCredential, helpers::remote_url, operator::InvocationConfig, targets (credential owner and request scope), and command_outcome. Errors use color_eyre. The bearer token is not stored here. The caller puts Authorization on the request. This file reads it back only to redact Retry-After.

    The routes are GraphClient in crates/omnigraph-cli/src/client.rs:

    • mutate posts /mutate, or /mutate/if-graph-commit when a graph-commit precondition is set. The header name is omnigraph-if-graph-commit.
    • invoke_named posts /queries/{name}, or /queries/{name}/if-graph-commit.
    • Query posts /query with QueryRequest, which has snapshot.

    crates/omnigraph-cli/src/helpers.rs decodes the body. A non-success body that is ErrorOutput becomes RemoteErrorCli. A verified HTTP 412 whose precondition_failure.expected matches the sent commit becomes PreconditionFailedCli. Some refusals omit ErrorCode and use a detail field (precondition_failure, recovery_required, and others). A client that matches only ErrorCode misses those.

    crates/omnigraph-cli/src/command_outcome.rs is CLI process bookkeeping. Its first lines say it is not engine settlement. If the command did not prove that work never started, effects is unknown and the action is reconcile. Admission refusals and a verified precondition can be not_started. This policy is not in graph_http.rs.

    A second private client is ServerClient in crates/omnigraph-gqt-core/src/served.rs. omnigraph-gqt-core is publish = false. That client sends the contract header. It does not probe healthz. It does not require the header on the response. It is another copy of the same calls.

    Design

    The observable contract is the HTTP JSON and the headers, at contract 0.14. The engine types are an implementation of that JSON for the server and the embedded CLI. The client build is a different observer. It must not compile the engine.

    Do this in two steps. The first step removes the engine from client builds. The second step adds the client crate.

    Step 1. One wire crate, local public types, conversions behind a feature

    Keep omnigraph-api-types as the only owner of the wire types. RFC 0009 already put the shared DTOs here so the server and the CLI do not each build a body.

    Make every public field a type that this crate owns, with the feature off and with the feature on. Cargo unifies features. If the public field type changes when the feature is on, any crate that enables the feature pulls the engine back into the client build.

    • Replace LoadMode on the wire with one local enum. Use the three snake_case names that LoadMode already serializes. Delete the shadow LoadModeSchema. One From<LoadMode> impl sits behind the feature.
    • Change SchemaApplyOutput.steps to the JSON OpenAPI already documents. Keep SchemaMigrationStep in the compiler. Add one round-trip test, behind the feature, that the wire JSON and the compiler enum match. Do not copy PropType into the wire crate. That would be a second schema language.
    • Put pub use omnigraph_compiler::settings, SettingsRequest::assignments, and the schema_with helpers behind the feature. SettingsRequest itself stays plain. The server enables the feature, so the published OpenAPI bounds still come from SettingId::spec(). The server OpenAPI drift test remains the publisher of the spec.
    • Put every From impl and every conversion function behind one feature named engine, default off. The feature turns on optional dependencies on omnigraph-engine and omnigraph-compiler. omnigraph-server, omnigraph-cli, and omnigraph-gqt-core enable it.

    Do not move the From impls into omnigraph-server. The orphan rule rejects them. Do not move them into the engine either. The engine would then depend on the wire crate. The session-settings RFC rejected that cycle. The engine’s public surface would grow HTTP types. After more changes of that kind, the engine would own the wire document.

    This is a Cargo API break for callers of the conversion functions and of LoadMode on these structs. The HTTP JSON stays the same when the serde names stay the same. Contract 0.14 does not have to change for this split. After the split, the publish workflow can publish the wire crate before the engine.

    What this step gives up: it does not, by itself, give applications a ready-made client. That delay is accepted. A client published in the same change would promise today’s paths while other work is changing those paths.

    Step 2. A small omnigraph-client after the type split

    Do not move graph_http.rs as a file. Split by duty.

    The client crate owns:

    • reqwest with redirects off and retries off
    • the contract header on the request, and a check for exactly one contract header on the response
    • the same discovery rule, or a documented narrower rule that still fails closed
    • POST /query with snapshot, POST /mutate, POST /mutate/if-graph-commit, and POST /queries/{name} (and the conditional stored-query path)
    • decode of ErrorOutput, including detail fields that have no ErrorCode

    The caller owns:

    • the bearer string, or a small token trait
    • omnigraph-if-actor as an assertion the server checks, not as an actor the client assigns
    • files, keyring, WorkOS, and target configuration

    #1024 is a CLI directory-mode bug. It stays in the CLI. #1025 and #978 are future token kinds. A token trait lets them plug in without a second client. #1019 and #1104 are the access read. Add that method when the route is stable. #1104 is open and moves evaluation toward the wire crate. That evaluation must stay behind the engine feature.

    Unknown outcome. Do not copy command_outcome into the library. That type is one CLI process. The client rule is smaller. If the request was sent and the result is not a decoded success or a decoded refusal, the outcome is unknown and the client must not retry a write. #1076 and #1078 are open. They add a server disposition (effects: none, partial, unknown, and an action). That field is not on main at this SHA. When it lands, the client returns it unchanged. It must not compute a second outcome. #1093 and #1096 are open. They scope an unknown storage result to a key and a branch. The client still must not retry a write the server did not settle.

    Change feeds. Pass cursors and page tokens through as opaque strings. #1009 says they are checksummed, not signed. A client that parses them takes a security bug. #1056 (wait instead of poll) is not built. Do not add wait in the client.

    MCP. #991 is a different protocol on /mcp. It is out of scope for this client.

    HTTP shape. #1089 is an open RFC. It plans one conventions pass, then a later contract batch that folds /if-graph-commit into a header and renames operations. A stable client that hard-codes those paths becomes a promise against that batch. Prefer the type split now. Publish omnigraph-client on the contract after that batch, or publish it as an unstable crate whose breaks follow HTTP_API_CONTRACT.

    The CLI then depends on the client for the remote arm. The embedded arm stays on the engine. GQT’s ServerClient can call the same client later, so the response contract check has one implementation.

    OpenAPI as the client generator

    openapi.json is derived. omnigraph-server builds it with utoipa from these types. A generator (progenitor or a similar tool) can emit Rust types from that file. It cannot emit the outcome rule, the discovery probe, or the “do not retry” rule. Those rules are not in the spec. A generated type crate would be a second source next to omnigraph-api-types. Keep one type source. Use the spec as a test that the client calls documented operations.

    Test plan

    • In CI, cargo tree -p omnigraph-api-types -e normal with default features must not contain omnigraph-engine, omnigraph-compiler, lance, or datafusion.
    • omnigraph-server, omnigraph-cli, and omnigraph-gqt-core enable the feature. Their existing tests stay the owners. The server OpenAPI drift test must still pass, and the settings schema must still match SettingId::spec().
    • One round-trip test locks SchemaMigrationStep JSON to SchemaApplyOutput.steps.
    • When the client crate exists, extend the CLI transport tests. Cover the contract header, a missing header, a repeated header, and no retry after a mutate is sent. Do not add a second HTTP harness.

    Liability

    Removed: client builds no longer compile Lance and DataFusion. The publish order no longer ties the wire crate to the engine. One conversion stays next to the wire types.

    Added if the public types differ by feature: Cargo feature unification silently returns the engine to client builds. The public types must not differ.

    Added if omnigraph-client is stable before the #1089 path batch: every path and the retry rule become a compatibility promise. That is the wrong promise to add while the HTTP conventions RFC is open.

    Added if schema steps are copied as a second compiler enum: two schema languages. The JSON-plus-round-trip choice avoids that.

    Lance

    Lance is not relevant. Cargo.toml pins lance at =13.0.0. Lance v13.0.0 is the latest stable release (2026-10-07). Later tags are v14.0.0 betas, starting 2026-10-12. No open OmniGraph pull request changes the pin. This defect is the wire crate’s dependency edge. A Lance change does not remove that edge.

    Related work

    • RFC 0009, unified access paths: one wire crate for server and CLI.
    • Session-settings RFC: settings definition stays in the compiler. The re-export is why the compiler dependency is not only a mapping.
    • #1089 HTTP API conventions (open RFC).
    • #1076, #1078 error disposition and CLI exit (open, not on this main). #1017 is the graph-access RFC they name.
    • #1093, #1096 storage outcome scope (open).
    • #1104 access command (open). #1019 access route. #1024 credential directory mode. #1025 OAuth. #978 delegated credentials.
    • #1009 continuation tokens. #1056 change-feed wait.
    • #991 MCP. Out of scope.

    Limitations

    The 4m 07s figure is one cargo check on this host, not a clean build. crates.io was not read (HTTP 403). disposition was read from the open pull requests. It is not in the tree at this SHA. Lance 14 beta pull requests were not reviewed one by one. None of them can delete this Cargo edge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageAwaiting maintainer triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions