* feat(MR-656): inline query strings in CLI and HTTP server
CLI:
- Add -e / --query-string <STRING> to omnigraph read and omnigraph change
- Exactly one of --query, --query-string, --alias is required (3-way XOR)
- Empty --query-string is rejected with a clear error
HTTP:
- New POST /query (read-only, clean field names: query/name/params/branch/snapshot)
- Mutations on /query are rejected with 400 -- use POST /change instead
- ChangeRequest fields polished: query (alias query_source), name (alias query_name)
- POST /read and POST /change remain byte-compatible for existing clients
Tests:
- cli.rs: -e happy-path on read/change, mutex error vs --query, empty -e rejected
- system_local.rs: inline -e read and -e change exercise the local flow
- system_remote.rs: inline -e read/change over HTTP plus direct /query 200/400
- server.rs: /query 200, /query 400 on mutation, /change legacy field alias
- openapi.rs: new /query path, QueryRequest schema, ChangeRequest field-name polish
Docs: cli.md (-e examples), cli-reference.md (read/change rows), server.md (/query)
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* feat(MR-656): rename read/change to query/mutate with deprecation signals
HTTP server:
- Add POST /mutate as canonical write endpoint (pairs with POST /query).
- Mark POST /read and POST /change as deprecated. Three-channel signal:
* OpenAPI: `deprecated: true` on the operation (every codegen flags
the generated SDK method).
* RFC 9745: response `Deprecation: true` header on every response.
* RFC 8288: response `Link: </successor>; rel="successor-version"`
pointing at /query and /mutate respectively.
- Share business logic across /mutate and /change via run_mutate(); the
/change wrapper is the only place that adds the deprecation headers.
- ChangeRequest field aliases (query_source/query_name) preserved.
- AliasCommand serde now accepts `query`/`mutate` alongside `read`/`change`.
CLI:
- Promote `omnigraph query` / `omnigraph mutate` to top-level canonical
subcommands (clap visible_alias keeps `omnigraph read` / `omnigraph
change` working forever).
- Promote `omnigraph lint` / `omnigraph check` to top-level (was nested
under `omnigraph query lint`, which is now a deprecated argv shim that
rewrites to the canonical form).
- Argv-level preprocessing prints a one-line deprecation warning to
stderr when any legacy spelling is used. Canonical names are silent.
Tests:
- Server: /mutate works, /change emits Deprecation+Link headers, /read
emits Deprecation+Link headers, /query carries no deprecation signal.
- OpenAPI: /read and /change flagged deprecated; /query and /mutate not.
- CLI: canonical `lint` matches deprecated `query lint` / `query check`
output; `read` / `change` print deprecation warnings.
Docs:
- cli.md: new canonical examples; "Deprecated names" migration table.
- cli-reference.md: top-level table updated; aliases.<name>.command
accepts both legacy and canonical spellings.
- server.md: endpoint inventory shows /query and /mutate as canonical
and /read and /change as deprecated; dedicated section explains the
three-channel deprecation signal.
- og-cheet-sheet.md: use new `omnigraph lint` / `omnigraph check`.
- openapi.json regenerated.
Migration is purely cosmetic — every deprecated form continues to work
indefinitely; only the spelling changes.
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* fix(MR-656): address Devin Review findings on /query and /change
Two issues raised by Devin Review on PR #110:
1. `POST /query` mutation-rejection error pointed at the deprecated
`/change` endpoint instead of the canonical `/mutate`. Fixed in
three places: the runtime error message in `server_query`, the
utoipa 400-response description, and the handler doc comment. The
`QueryRequest` schema docstrings in `api.rs` got the same update so
the openapi.json bodies match. Server and openapi tests updated.
2. `execute_change_remote` serialized `ChangeRequest` directly, which
emits the new canonical field names `query` / `name` on the wire.
`#[serde(alias = "query_source")]` only affects deserialization, so
a newer CLI talking to an older server would have its `/change`
POST body fail with "missing field: query_source". Fixed by
extracting a `legacy_change_request_body` helper that hand-rolls
the JSON with the legacy keys (`query_source` / `query_name`), the
same byte-stable contract `execute_read_remote` already uses
against `/read`. Added two unit tests on the helper to lock the
wire shape in.
Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
* docs(dev): RFC 001 — inline + stored queries, envelope, MCP
Tracked artifact consolidating the design across MR-656 (this branch),
MR-976 (Phase 1 envelope hardening parent, with MR-977/978/979/980
sub-issues), and MR-969 (stored queries + MCP).
Sections:
* Two paths, one engine — inline `/query` + `/mutate` (this PR) coexist
with stored `/queries/{name}` (MR-969). Same `run_query` / `run_mutate`
backend (the fold-in landed in the previous commit).
* Request envelope ("before") — Idempotency-Key, If-Match, X-Deadline,
X-Trace-Id, expect, dry_run, fields. Phase 1 ships the load-bearing
subset on `/mutate`.
* Response envelope ("after") — audit_id, snapshot_id, commit_id, stats,
warnings. Closes the provenance loop today's `ChangeOutput` leaves
open.
* `.gq` pragmas — `@description`, `@returns`, `@mcp`. Source-of-truth
for the stored-query agent contract; no separate YAML registry.
* Multi-graph MCP — per-graph `/graphs/{id}/mcp/tools` + `/mcp/invoke`.
Token binds to one graph by default; cross-graph agents loop.
* Cedar split — `read`/`change` for inline, `invoke_query` for stored.
Operators deny ad-hoc for agent groups while keeping curated tool
list open.
* Rejected alternatives — per-env override files, compiled bundles,
tool-name prefixing across graphs, body-field graph dispatch.
Index entry added under "Active Implementation Plans" so future agents
land on the RFC before touching queries / mutations / envelope code.
`scripts/check-agents-md.sh` clean (35 links, 34 docs).
* docs(server): clarify why run_query lacks AppState parameter
run_mutate takes state for workload admission; run_query doesn't because
reads aren't admission-gated today. Mark the asymmetry as intentional and
flag the two future events that would grow the signature: Phase 1's
`expect: { max_rows_scanned: N }` budget (MR-976) or per-actor admission
extending to stored-read invocations (MR-969). Prevents the natural
"make these symmetrical" follow-up.
* refactor(server): run_query / run_mutate take &ResolvedActor
Replace `Option<Extension<ResolvedActor>>` in the helpers with
`Option<&ResolvedActor>`. Saves MR-969's stored-query handler from
wrapping a bare actor in axum's `Extension(...)` before calling.
Handler signatures (`server_query`, `server_read`, `server_mutate`,
`server_change`) keep `Option<Extension<ResolvedActor>>` because that
is what axum injects, and unwrap at the call site with
`actor.as_ref().map(|Extension(actor)| actor)`.
Net: -13/+10 LOC, 89/0 server tests pass.
* docs(releases): v0.6.0 — describe inline + canonical-named queries (MR-656)
Extend the v0.6.0 release notes to cover the third piece of work landing
alongside the graph terminology rename and multi-graph server mode:
canonical-named `POST /query` and `POST /mutate` endpoints, the CLI's
new `-e/--query-string` flag, the top-level promotion of `lint` /
`check`, and the three-channel deprecation signal on `/read` and
`/change` (OpenAPI `deprecated: true` + RFC 9745 + RFC 8288).
Additions:
* Top blurb: "Two pieces" -> "Three pieces" with a bullet describing
the rename + inline flow.
* Breaking Changes: new "Query / mutation rename" subsection covering
the `ChangeRequest` field rename (with the back-compat serde aliases
and the CLI's `legacy_change_request_body` byte-stable wire helper)
and the `omnigraph query lint` -> `omnigraph lint` move.
* New: 5 bullets — the two endpoints, the CLI subcommands, the `-e`
flag, the deprecation signal channels, the widened `aliases.<name>.command`
vocabulary.
* User Impact: one bullet making explicit that the rename is cosmetic
on the client side and migration is voluntary.
* Documentation: pointers to the updated `server.md` / `cli.md` /
`cli-reference.md` and the new `docs/dev/rfc-001-queries-envelope-mcp.md`.
+15/-1 lines. `./scripts/check-agents-md.sh` clean.
* refactor(cli): demote `check` from visible_alias to deprecation shim
`omnigraph check` was a clap `visible_alias` on `lint`, advertised in
`--help` as an equivalent canonical name. Per MR-981 §6 (long-form
flags as canonical, short forms as visible aliases), visible aliases
on subcommand names hurt agent CX: agents emit either spelling
depending on training-data drift, and there's no length signal
pointing at the canonical name.
Changes:
* Remove `#[command(visible_alias = "check")]` from the `Lint` variant.
`omnigraph --help` now shows only `lint`.
* Add bare `check` to `rewrite_deprecated_argv` so `omnigraph check
<args>` still works — it rewrites to `omnigraph lint <args>` and
emits a one-line stderr deprecation warning, matching the existing
pattern for `read` / `change` / `query lint` / `query check`.
* Fix the nested `query check` shim to substitute `check` -> `lint` in
the rewritten argv (previously it relied on `check` being a
visible_alias to reach the `Lint` variant).
* New test `deprecated_check_top_level_rewrites_to_lint` covers: bare
`check` produces identical stdout to `lint`, emits the deprecation
warning, and `check` does NOT appear as an alias in `omnigraph
--help`.
* Release notes updated to reflect the deprecation-shim treatment and
cross-reference MR-981 §6 reasoning.
Cargo / Go users typing `check` still work indefinitely; one stderr
nudge per invocation teaches the canonical name. Agents see only
`lint` in `--help --json` so they emit one canonical form.
67/0 omnigraph-cli tests pass; 39 workspace test suites green.
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ragnor Comerford <ragnor.comerford@gmail.com>
Co-authored-by: Ragnor Comerford <hello@ragnor.co>
21 KiB
RFC: Inline + Stored Queries, Request/Response Envelope, MCP
Status: Proposed
Date: 2026-05-28
Tickets: MR-656 (inline -e + URL rename), MR-668 (multi-graph, shipped), MR-976 (Phase 1 envelope parent: MR-977 / MR-978 / MR-979 / MR-980), MR-969 (stored queries + MCP)
Target release: v0.6.x patch series (MR-656 + Phase 1) → v0.7.0 (MR-969 PRs 1-3)
Summary
OmniGraph today exposes POST /read and POST /change with a weakly-contracted body (counts only on writes) and no per-query authorization. This RFC consolidates the work landing across three Linear tickets into one coherent design:
- MR-656: rename
/read→/queryand/change→/mutate, add inline-eCLI flag, ship three-channel deprecation on the legacy URLs. In flight, PR #110. - Envelope hardening (this RFC adds it as a Phase 1 before MR-969): make today's mutation surface agent-grade with idempotency keys, preconditions, deadlines, and a structured response envelope carrying
audit_id,commit_id,snapshot_id, and cost stats. - MR-969: add a stored-query registry,
POST /queries/{name}, a newInvokeQueryCedar action with per-query scope, inline pragmas in.gq(@description,@returns,@mcp), and MCP transport over the same routing primitive.
The bet: inline and stored queries serve different stages of the same lifecycle, run through the same engine code, and are gated by different Cedar actions. HelixDB collapsed to stored-only. Postgres has neither stored-query Cedar nor MCP. The window for an OSS, declarative, agent-grade graph query surface is open.
Motivation
Three problems today:
- Mutation responses are too thin.
ChangeOutput { node_count, edge_count }is the entire memory the API has of what just happened. Nocommit_id, noaudit_id, nosnapshot_id. Agents reporting results have nothing to cite. Humans can't reproduce a read. - No agent-safe surface. Cedar gates
readandchangeat the action level. A token either runs any query or no query of that kind. There is no way to express "this agent can invokefind_userand nothing else." - No discovery primitive. Agents need a tool list. SDKs need a stable contract per operation. Both are absent.
The MR-656 rename solves the cosmetic asymmetry (/read was a poor pair for the future /queries/{name}). The envelope work and MR-969 solve the substantive gaps.
Non-Goals
- Compiled query bundles (HelixDB's
queries.jsonshape)..gqfiles are already declarative; the file is the artifact. - Hot reload of the registry. Restart-only matches the multi-graph operational model from MR-668.
- Per-query rate limits in v1. Existing
WorkloadControllercovers the bulk of the risk. Punt to a future ticket. - Cross-graph tool listing in MCP. Agents loop over per-graph endpoints when they need multi-graph access. Avoid namespacing in the contract.
- Web dashboard / control-plane management of the registry. Operators edit
.gq+policy.yamland restart. - Schema introspection through MCP. Schema is an operator concern; agents see types through declared return shapes on the queries they're allowed to invoke.
- Per-environment override files. Environment-specific differences live in
policy.yaml, which already has per-env variants.
Background
OmniGraph runs on Lance 6.x with a property graph layered on top: typed nodes/edges in per-type Lance datasets, atomic multi-table commits via a __manifest table, branchable and time-travelable through Lance versioning. The HTTP server (omnigraph-server) is Axum + utoipa with bearer-token auth and Cedar policy enforcement at every _as writer.
MR-668 shipped multi-graph mode in v0.6.0. One server process can host 1-10 graphs, with per-graph endpoints under /graphs/{id}/.... Cedar policy resolves against Server::"root" (for management actions) and Graph::"prod" (for per-graph actions).
MR-656 is currently in PR #110 (CONFLICTING / DIRTY against main; rebase planned). It renames the URL surface, adds inline source support, and ships three-channel deprecation (OpenAPI deprecated: true, RFC 9745 Deprecation: true header, RFC 8288 successor Link).
Design
Two paths, one engine
| Dimension | Inline (/query, /mutate) |
Stored (/queries/{name}) |
|---|---|---|
| Source location | Request body | queries/*.gq on disk |
| Parse + typecheck | Per request | Once at server boot |
| Cedar action | read / change |
invoke_query (per-name scope) |
| MCP-exposed | No (not enumerable) | Yes (when @mcp(expose=true)) |
| Output schema | Inferred | Declared via @returns, asserted at boot |
| Audit log shape | Records query hash | Records query name |
| Failure visibility | Runtime 400 | Boot-time refusal |
Both paths converge in the engine:
POST /query ─parse→─┐
POST /mutate ─parse→─┤
├─→ run_query / run_mutate(ast, params, branch) ─→ envelope
POST /queries/{name} ───────┤
POST /mcp/invoke ───────────┘ (MCP adapter on top of the same call)
The MR-656 rebase widens run_query / run_mutate to accept a parsed AST or source string. Inline parses on each call. Stored looks up the pre-parsed AST in the registry. Same execution path beyond that point.
Cedar split (the LLM-safe wedge)
Inline and stored coexist safely because they're gated by different actions:
# Production policy — agents locked to a curated stored-query set
- deny:
actors: { group: agents }
actions: [read, change] # blocks /query, /mutate, /read, /change
- allow:
actors: { group: agents }
actions: [invoke_query]
resource: Graph::"prod"
query_scope: { names: [find_user, list_orders, search_docs] }
The agent's effective surface: three stored queries by name. Cannot compose inline. Cannot enumerate schema. Cannot read arbitrary entities. A developer in the same deployment with dev-engineers group membership might have [read, change, invoke_query] allowed — full access to both paths.
Same server, same data, two completely different API surfaces depending on token. This is the posture MR-969 calls "LLM-safe API surface."
.gq pragmas
Stored queries self-describe at the top of the source file:
@description("Look up a user by ID. Returns name, email, last_login.")
@returns({ name: String, email: String, last_login: DateTime? })
@mcp(expose=true)
query find_user($id: String) {
match { $u: User { id: $id } }
return { $u.name, $u.email, $u.last_login }
}
Three pragmas in v1:
@description("...")— string surfaced inomnigraph queries explainand MCP tool descriptions.@returns({...})— optional output type assertion. Compiler verifies the inferred type matches; mismatch fails server startup.@mcp(expose=true|false, tool_name="alt_name"?)— controls MCP visibility. Default isexpose=false(callable via HTTP, hidden from MCP).tool_namedefaults to the query name.
Pragmas live in source, not in a separate YAML registry. Drop a file in queries/, restart, the registry picks it up. The full agent contract is reviewable in one diff.
Request envelope ("before")
Today's request carries auth + body. The envelope adds five fields, all optional:
POST /graphs/prod/queries/find_user
Authorization: Bearer <token>
Idempotency-Key: 01HXYZ... # mutations only
If-Match: 01HABC... # optimistic concurrency
X-Deadline: 2026-05-28T19:30:00Z # or X-Timeout-Ms: 5000
X-Trace-Id: 01HDEF...
Content-Type: application/json
{
"params": { "id": "u-42" },
"branch": "main",
"expect": "read_only", # scope assertion
"dry_run": false, # mutations only
"fields": ["name", "email"] # result projection
}
Field semantics:
| Field | Applies to | Purpose |
|---|---|---|
Idempotency-Key |
Mutations | Server caches (token, key) → response for 10 minutes. Replays return cached response with Idempotency-Replay: true header. Prevents double-write on retry. |
If-Match |
Mutations | Run only if branch HEAD matches the given commit ID. 412 Precondition Failed otherwise. Enables read-then-write without races. |
X-Deadline / X-Timeout-Ms |
All | Server respects; returns 504-typed error past the deadline. Bounds execution for context-budget-constrained callers. |
X-Trace-Id |
All | Caller-supplied; server echoes back. Lets agents correlate multi-call sequences. |
expect |
All | Caller asserts shape: "read_only", {"max_rows_scanned": 10000}. Server validates against parsed AST or planner estimate; rejects before running. |
dry_run |
Mutations | Returns what would happen without committing. Implemented via scratch branch + diff + discard. |
fields |
Reads | Server returns only listed columns. Saves bandwidth + agent context window. |
All five fields are optional; today's call shape continues working.
Response envelope ("after")
The response envelope replaces today's bare-result shape with a structured wrapper. Every endpoint (inline, stored, MCP) returns the same envelope:
{
"result": { "name": "Alice", "email": "alice@..." },
"audit_id": "01HGHI...",
"snapshot_id": "01HJKL...",
"commit_id": null,
"stats": {
"rows_scanned": 1,
"ms_elapsed": 4,
"bytes_read": 128
},
"warnings": []
}
Response headers:
| Header | When | Purpose |
|---|---|---|
Idempotency-Replay: true|false |
Mutations | Was this response served from the idempotency cache? |
X-Trace-Id |
All | Echo of the request's trace ID, or server-minted if absent. |
Deprecation: true |
/read, /change only |
RFC 9745 signal from MR-656. |
Link: </query>; rel="successor-version" |
/read, /change only |
RFC 8288 successor pointer from MR-656. |
Body envelope fields:
| Field | When | Purpose |
|---|---|---|
result |
All | The actual response payload. Shape determined by the query's return type. |
audit_id |
All | ULID for the audit log entry. Lets the caller cite exactly what ran. |
snapshot_id |
All | Manifest snapshot the query observed. Reproducibility — replay with ?snapshot=<id>. |
commit_id |
Mutations | ULID of the new commit. Null for reads. Lets the caller cite what changed. |
stats |
All | {rows_scanned, ms_elapsed, bytes_read}. Lets agents learn what's expensive. |
warnings |
All | Non-fatal observations: deprecated property access, full-scan despite available index, scan exceeded soft row limit. Empty array when none. |
The envelope is the API's memory of what happened. Without audit_id + commit_id + snapshot_id, agent reports are hearsay and reads are not reproducible. With them, provenance is a first-class property of every response.
MCP integration with multi-graph
MCP routes are per-graph, matching the rest of MR-668's hierarchy:
GET /graphs/{id}/mcp/tools # tool list for this graph, this token
POST /graphs/{id}/mcp/invoke # invoke a tool on this graph
Single-mode collapses to /mcp/tools and /mcp/invoke at the root (same shape, no /graphs/{id} prefix). Both modes route through identical handler code.
Tool list response:
{
"tools": [
{
"name": "find_user",
"description": "Look up a user by ID.",
"inputSchema": { "id": { "type": "string", "required": true } },
"outputSchema": { "name": "string", "email": "string", "last_login": "datetime?" },
"read_only": true
}
],
"graph_id": "prod",
"snapshot_id": "01HJKL..."
}
The tool list is the subset of registered queries where (a) @mcp(expose=true) in source and (b) Cedar permits invoke_query for this token on this name on this graph. Computed per request — cheap because it's just iterating the registry + one Cedar evaluation per name.
Token scoping. Most tokens carry one graph claim. Cross-graph access requires multiple Cedar rules (one per graph) and is uncommon. Agents that genuinely operate across graphs loop over /graphs/{id}/mcp/tools themselves. The contract stays clean; graph renames don't break tool names.
Discovery. Agents are told their MCP URL at provisioning: https://omnigraph.example.com/graphs/prod/mcp. Token authorizes; URL identifies. Same model as every OAuth-style API.
/mcp/invoke is a protocol adapter. Unwrap MCP protocol envelope, call the same code path as /queries/{name}, wrap the response in MCP shape. No new execution semantics.
CLI surface
The CLI mirrors the HTTP routes. Post-MR-656 and post-MR-969:
# Inline (MR-656)
omnigraph query -e 'query test() { ... }' # /query
omnigraph mutate -e 'query bump() { update ... }' # /mutate
# Stored (MR-969)
omnigraph queries list # GET /queries (future)
omnigraph queries explain find_user # show params + return shape + source
omnigraph queries invoke find_user --param id=u-42 # POST /queries/find_user
# Pragma + registry validation
omnigraph lint queries/find_user.gq # parses + verifies pragmas
omnigraph queries lint # validates the whole registry
omnigraph queries invoke reads bearer + URL from omnigraph.yaml like the other remote commands. Local invocations work the same way the existing omnigraph query/mutate do.
Lifecycle
The promotion path from inline to stored is the load-bearing DX story:
1. EXPLORE omnigraph query -e 'query find_user($id: String) { ... }' --params '{"id": "u-42"}'
└─ POST /query, iterate freely
2. STABILIZE write queries/find_user.gq with @description, @returns, @mcp pragmas
└─ git diff shows the full agent contract in one file
3. AUTHORIZE add Cedar rule allowing invoke_query for the appropriate actor group
└─ scope_names: [find_user]
4. DEPLOY restart server
└─ /queries/find_user goes live
└─ /mcp/tools auto-lists it for any token with invoke_query[find_user]
5. RETIRE deny: read change for the agent group
└─ inline access closed; stored remains
└─ MR-969's "LLM-safe API surface" reached
Same .gq source through all five steps. No rewrite. No language shift. The pragmas are the only added syntax between exploration and production.
Migration
Existing callers see no breakage:
POST /readandPOST /changekeep working, now withDeprecation: trueheaders (MR-656).ChangeRequestfield namesquery_source/query_nameaccepted as serde aliases (MR-656).aliases:block inomnigraph.yamlunchanged; bothread/changeandquery/mutateaccepted ascommand:values (MR-656).- New envelope fields are additive; old clients ignoring them keep working.
Idempotency-Key,If-Match,X-Deadlineare opt-in headers; absence is the current behavior.
Callers move at their own pace. The envelope upgrades + URL rename ship in v0.6.x (small PRs). Stored queries + MCP ship in v0.7.0.
Sequencing
Phase 1: envelope (v0.6.x, before MR-969). Four small PRs, ~100-200 LOC each.
- Wrap responses in the structured envelope. Add
audit_id,snapshot_id,commit_id,stats,warnings. Backward-compatible if we keep today's top-level fields and add new ones alongside; cleaner break if we move to nestedresult.*. Pick one and live with it. - Honor
Idempotency-Keyon/mutate(and the deprecated/change). Server-side cache keyed by(token, key). - Honor
If-Matchon/mutate. Wire through to the publisher CAS layer. - Honor
X-Deadline/X-Timeout-Mson every endpoint. Return 504-typed error past deadline.
Phase 2: MR-969 PR 1 (registry). The stored-query registry, /queries/{name} route, InvokeQuery Cedar action with per-name scope, .gq pragma parsing (@description, @returns, @mcp), read-vs-mutate classification at registry load. Inline keeps working unchanged.
Phase 3: MR-969 PR 2 (MCP). /graphs/{id}/mcp/tools and /graphs/{id}/mcp/invoke. Tool schemas projected from declared return types and parameter declarations. Single-graph-scoped tokens.
Phase 4: MR-969 PR 3 (Cedar deny-on-ad-hoc sugar). Small Cedar-language addition so operators can lock down /read / /query while keeping /queries/* open. Independent of PRs 1-2.
Phase 5: deferred.
- Cross-graph MCP namespacing (wait for usage signal).
- Per-query rate limits (extend
WorkloadController). - Schema introspection as a separate Cedar action (3-line PR).
- CLI verb consolidation (
omnigraph call <name>). - Cache warming (HelixDB-style; not load-bearing).
Rejected Alternatives
Per-environment override files (_overrides.yaml). Initial design had a sparse YAML file for per-env tweaks: MCP exposure, row caps, kill-switch, param locks. Rejected because every override candidate either belongs in source (@mcp flag), Cedar policy (per-actor visibility, per-env), or omnigraph.yaml (operator config). Splitting query metadata across files makes it harder to review what an agent can see. Keep source authoritative; let Cedar express the per-env differences.
Compiled query bundle (HelixDB's queries.json). HelixDB compiles their Rust-DSL queries to JSON. Rejected because .gq files are already declarative. The file is the artifact. Reviewers diff source, not bytecode.
Stored-queries-only (HelixDB's posture). Rejected because the personal-graph / dev-iteration use case dies without inline. Inline -e is the REPL for human exploration; stored is the contract for production agents. Both first-class.
Cross-graph tool-name prefixing (prod.find_user). Rejected because graph renames would break agent contracts. Per-graph URLs let graph identity live in the URL, not in tool names.
Body-field graph dispatch ({tool, graph, params}). Rejected because it doubles the contract surface (every tool is identified by two fields). Per-graph URLs are simpler.
Pragmas in YAML instead of source. Rejected because two-file definitions (source + metadata YAML) make diffs harder to review and create drift opportunities. Source is the source of truth.
Pragmas as in-source comments (#[mcp] HelixDB-style). Considered; chose @mcp(...) because comment-flavored pragmas conflate documentation and machine-readable metadata. The @ prefix makes the pragma's role explicit.
Open Questions
-
Envelope breakage vs additive. Phase 1.1 wraps responses in a structured envelope. Do we keep today's top-level fields and add new ones (additive, ugly), or move result to
result.*(clean break, requires SDK updates)? Lean toward additive — let the new envelope coexist with the old shape until v0.7.0, then collapse. -
@returnsstrictness. Should mismatched declared-vs-inferred return type be a boot-time error or a warning? Lean toward error — silent drift defeats the assertion's purpose. Operators who want flexibility omit@returns. -
MCP protocol transport. Streamable HTTP (the new MCP standard) vs stdio (Anthropic's original). Both have Rust crates. Lean toward streamable HTTP since we're already an HTTP server.
-
Stored mutation routing. A
.gqfile that contains both reads and writes — does the registry reject it at load (parse-time D2 rule from MR-656), or accept and classify as "mixed"? Lean toward reject. Mixed queries are a footgun; force operators to split. -
expectfield strictness.expect: "read_only"against a parsed mutating query is an obvious 400. Butexpect: {max_rows_scanned: 10000}requires planner estimates that don't exist today. Either shipexpectwith only the "read_only" assertion in v1 and grow it, or wait for the planner. Lean toward shipping the partial form. -
CLI
queries invokeshape. Today'somnigraph querytakes a file or alias.omnigraph queries invoke find_usertakes a stored query name. Shouldomnigraph query --name find_useralso work (auto-detect)? Cleaner to keep them separate verbs — the stored vs inline distinction is part of the contract.
References
- MR-656: Support inline query strings in CLI and HTTP server
- MR-668: Multi-graph server mode (shipped, PR #119)
- MR-969: Stored queries with MCP exposure and per-query Cedar authorization
- PR #110: feat: inline query strings in CLI and HTTP server
- HelixDB docs: docs.helix-db.com/llms-full.txt —
#[mcp]macro, scoped API keys, stored query model - RFC 9745 (
Deprecationheader) - RFC 8288 (
Linkrelations,successor-version) - MCP spec: modelcontextprotocol.io
- invariants.md — substrate boundaries this work respects
- ../user/server.md — current HTTP surface (post-MR-656 picks up the
/query+/mutaterename and deprecation)