Client-side validation: catch bad requests before they leave the machine
Problem
Today the CLI does exactly one check on a request body: json.Valid
(internal/openapi/body_input.go:79). Everything else is the server's job. That
means a malformed request costs a full round-trip, and the caller — increasingly
an AI agent — only learns what was wrong from a 400.
Looking at the shapes of errors the API returns to CLI callers, most fall into a
few recurring families, and a large majority of them are decidable locally:
1. Unknown / misspelled keys. The API rejects with
Bad Request: Unrecognized key: "<key>". This shows up constantly with
plausible-but-wrong names — pagination params, body fields that were renamed,
fields copied from a sibling endpoint, or read-only fields echoed back from a GET
into a POST.
2. Missing or mistyped required fields.
<field>: Invalid input: expected string, received undefined,
expected array, received undefined, <field> is required.
3. Enum violations. Invalid option: expected one of "a"|"b"|"c".
4. Bounds violations. <field> must be N characters or fewer,
Page size cannot exceed N, Invalid UUID.
5. Cross-field invariants. formatResults cannot be provided without resultType; order is missing query presentation IDs: …; <field> must be a top-level property of the request body, not nested inside <parent>.
6. Semantic-model errors on query run. No such field "<view>.<field>",
No such topic "<name>", Bad sort field <field>, The following fields are outside of the '<topic>' topic: …, Cannot use string filter on field "<field>" of type "number".
7. Server state and permissions. not found, already in use,
permission denied, warehouse errors.
Families 1–5 are decidable offline from api/openapi.json, which we already
embed. Families 6 and 7 are out of scope for this issue — see Not in scope
below.
Why it's worth doing
- Agents are the main caller now. A 400 costs an agent a turn; a local error
with a suggestion costs it nothing. agent_help.go already tries to prevent
some of these in prose — and family 5 shows prose doesn't hold. Checks hold.
- We already have the schema. The embedded spec describes required fields,
types, enums, and bounds for every operation, and internal/openapi/schema.go
already walks and $ref-resolves it for --schema. Most of the machinery
exists.
- These are mechanical mistakes, not authoring mistakes. Families 1–5 are all
cases where the caller knew what it wanted and got the envelope wrong: a
misspelled key, a missing field, a stale enum value. Nothing about the user's
intent is ambiguous, so a local check can be confident — and confidence is what
makes it safe to fail the call before sending it.
Proposal
Three layers, independently shippable, listed in the order I'd build them.
Layer 1 — --dry-run from the embedded spec
Every generated command gets --dry-run: validate the body and exit, no auth, no
network. Register it the way RegisterSchemaFlag registers --schema
(internal/openapi/schema.go:102) — that already wraps RunE to short-circuit
before the request, including the collision-avoidance fallback when a spec
parameter owns the name.
Requirements:
- Report every violation at once, not just the first. A caller fixing one
field at a time is barely better than round-tripping.
- Strict unknown keys by default. The spec sets
additionalProperties: false
in only a small fraction of its object schemas, so an off-the-shelf validator
would pass nearly every family-1 error. Treat undeclared properties as errors,
with a nearest-name suggestion (unknown field "pagesize" — did you mean "pageSize"?).
- Same error envelope as everything else. One JSON document on stderr,
non-zero exit, matching the contract in CLAUDE.md so agents parse one format.
- Never fatal by default. Opt-in flag only. A stale embedded schema that
blocks a valid request is worse than no validation at all; that risk sets the
whole design.
Known gap: QueryRunBody.query is declared in the spec with a description and no
type or properties, so Layer 1 cannot see inside it. That's where most of
family 2 on query run hides. Worth typing upstream.
Spec accuracy becomes a prerequisite. Local enforcement is only as good as
the embedded spec. Today a drifted enum or bound is merely inaccurate
documentation; once Layer 1 lands it becomes a locally blocked valid request,
which is the worst outcome this design can produce. That raises the bar on
make sync-spec discipline, and means the spec should be treated as a contract
we keep current rather than a best-effort description.
Layer 2 — hand-written cross-field invariants
A small table of predicates keyed by operationId, for the rules JSON Schema
can't express (family 5). Maybe a dozen of them. Nearly free, and it converts
existing agent_help.go prose into enforcement.
Layer 3 — model YAML validation
The product maintains JSON Schemas (draft 2020-12) for the model YAML kinds —
top-level model, topic, composite topic, view, relationship — plus a
property-documentation file with descriptions and examples. Given those, the CLI
can validate model YAML locally, before upload:
omni model validate model/
omni model validate topics/orders.topic.yaml --kind topic
Kind inferred from filename convention, --kind to override.
This complements the existing omni models validate rather than replacing it:
that one validates YAML already pushed to a branch; this one runs before the
upload, offline, in a pre-commit hook or CI.
Prerequisite: these schemas are not published today. A client-side validator
necessarily puts the rules on the client, so building this layer means
distributing them — either embedded in the binary or fetched and cached. That
needs a decision before the work can start.
If they can be published, vendor and embed them: a make sync-schemas target
alongside sync-spec, same pinned-ref discipline, go:embed. The validator then
stays fully offline, which is the point.
Note that gitignoring the files and embedding them at build time does not
sidestep the question — go:embed compiles the bytes into every released binary,
which is itself a public artifact.
One design note: the model YAML filter grammar and the query run API filter
grammar are different languages with different vocabularies. Don't build one
filter validator for both.
Not in scope
Family 6 (semantic-model errors on query run) is deliberately excluded,
though it's the most common class of query run failure.
Everything above is a static check against a schema the binary already carries:
no network, no state, no staleness. Validating field and topic references
requires live model metadata — an authenticated call, a cache, and an
invalidation policy — so --dry-run would mean something different on
query run than everywhere else.
It's also where a wrong local rejection is most likely. Model metadata goes stale
as soon as anyone edits a branch, and falsely blocking a valid query is the
failure mode this design is most concerned with avoiding.
Worth its own issue. It needs its own answers on caching, refresh, and what to do
when metadata can't be fetched.
Family 7 (server state and permissions) is not locally decidable at all.
Ordering
1 → 2 → 3, with Layer 3 gated on the schema distribution decision above. Layers 1
and 2 have no external dependency and can start immediately.
Layer 1 is the best ratio of value to work: it reuses machinery that exists, adds
no dependency, and covers four error families. Layer 2 is nearly free once 1
lands. Layer 3 is the biggest payload to vendor and helps a narrower audience
(model authors) — valuable, but an authoring-experience investment rather than an
error-rate fix.
Client-side validation: catch bad requests before they leave the machine
Problem
Today the CLI does exactly one check on a request body:
json.Valid(
internal/openapi/body_input.go:79). Everything else is the server's job. Thatmeans a malformed request costs a full round-trip, and the caller — increasingly
an AI agent — only learns what was wrong from a 400.
Looking at the shapes of errors the API returns to CLI callers, most fall into a
few recurring families, and a large majority of them are decidable locally:
1. Unknown / misspelled keys. The API rejects with
Bad Request: Unrecognized key: "<key>". This shows up constantly withplausible-but-wrong names — pagination params, body fields that were renamed,
fields copied from a sibling endpoint, or read-only fields echoed back from a GET
into a POST.
2. Missing or mistyped required fields.
<field>: Invalid input: expected string, received undefined,expected array, received undefined,<field> is required.3. Enum violations.
Invalid option: expected one of "a"|"b"|"c".4. Bounds violations.
<field> must be N characters or fewer,Page size cannot exceed N,Invalid UUID.5. Cross-field invariants.
formatResults cannot be provided without resultType;order is missing query presentation IDs: …;<field> must be a top-level property of the request body, not nested inside <parent>.6. Semantic-model errors on
query run.No such field "<view>.<field>",No such topic "<name>",Bad sort field <field>,The following fields are outside of the '<topic>' topic: …,Cannot use string filter on field "<field>" of type "number".7. Server state and permissions.
not found,already in use,permission denied, warehouse errors.Families 1–5 are decidable offline from
api/openapi.json, which we alreadyembed. Families 6 and 7 are out of scope for this issue — see Not in scope
below.
Why it's worth doing
with a suggestion costs it nothing.
agent_help.goalready tries to preventsome of these in prose — and family 5 shows prose doesn't hold. Checks hold.
types, enums, and bounds for every operation, and
internal/openapi/schema.goalready walks and
$ref-resolves it for--schema. Most of the machineryexists.
cases where the caller knew what it wanted and got the envelope wrong: a
misspelled key, a missing field, a stale enum value. Nothing about the user's
intent is ambiguous, so a local check can be confident — and confidence is what
makes it safe to fail the call before sending it.
Proposal
Three layers, independently shippable, listed in the order I'd build them.
Layer 1 —
--dry-runfrom the embedded specEvery generated command gets
--dry-run: validate the body and exit, no auth, nonetwork. Register it the way
RegisterSchemaFlagregisters--schema(
internal/openapi/schema.go:102) — that already wrapsRunEto short-circuitbefore the request, including the collision-avoidance fallback when a spec
parameter owns the name.
Requirements:
field at a time is barely better than round-tripping.
additionalProperties: falsein only a small fraction of its object schemas, so an off-the-shelf validator
would pass nearly every family-1 error. Treat undeclared properties as errors,
with a nearest-name suggestion (
unknown field "pagesize" — did you mean "pageSize"?).non-zero exit, matching the contract in CLAUDE.md so agents parse one format.
blocks a valid request is worse than no validation at all; that risk sets the
whole design.
Known gap:
QueryRunBody.queryis declared in the spec with a description and notypeorproperties, so Layer 1 cannot see inside it. That's where most offamily 2 on
query runhides. Worth typing upstream.Spec accuracy becomes a prerequisite. Local enforcement is only as good as
the embedded spec. Today a drifted enum or bound is merely inaccurate
documentation; once Layer 1 lands it becomes a locally blocked valid request,
which is the worst outcome this design can produce. That raises the bar on
make sync-specdiscipline, and means the spec should be treated as a contractwe keep current rather than a best-effort description.
Layer 2 — hand-written cross-field invariants
A small table of predicates keyed by
operationId, for the rules JSON Schemacan't express (family 5). Maybe a dozen of them. Nearly free, and it converts
existing
agent_help.goprose into enforcement.Layer 3 — model YAML validation
The product maintains JSON Schemas (draft 2020-12) for the model YAML kinds —
top-level model, topic, composite topic, view, relationship — plus a
property-documentation file with descriptions and examples. Given those, the CLI
can validate model YAML locally, before upload:
Kind inferred from filename convention,
--kindto override.This complements the existing
omni models validaterather than replacing it:that one validates YAML already pushed to a branch; this one runs before the
upload, offline, in a pre-commit hook or CI.
Prerequisite: these schemas are not published today. A client-side validator
necessarily puts the rules on the client, so building this layer means
distributing them — either embedded in the binary or fetched and cached. That
needs a decision before the work can start.
If they can be published, vendor and embed them: a
make sync-schemastargetalongside
sync-spec, same pinned-ref discipline,go:embed. The validator thenstays fully offline, which is the point.
Note that gitignoring the files and embedding them at build time does not
sidestep the question —
go:embedcompiles the bytes into every released binary,which is itself a public artifact.
One design note: the model YAML filter grammar and the
query runAPI filtergrammar are different languages with different vocabularies. Don't build one
filter validator for both.
Not in scope
Family 6 (semantic-model errors on
query run) is deliberately excluded,though it's the most common class of
query runfailure.Everything above is a static check against a schema the binary already carries:
no network, no state, no staleness. Validating field and topic references
requires live model metadata — an authenticated call, a cache, and an
invalidation policy — so
--dry-runwould mean something different onquery runthan everywhere else.It's also where a wrong local rejection is most likely. Model metadata goes stale
as soon as anyone edits a branch, and falsely blocking a valid query is the
failure mode this design is most concerned with avoiding.
Worth its own issue. It needs its own answers on caching, refresh, and what to do
when metadata can't be fetched.
Family 7 (server state and permissions) is not locally decidable at all.
Ordering
1 → 2 → 3, with Layer 3 gated on the schema distribution decision above. Layers 1
and 2 have no external dependency and can start immediately.
Layer 1 is the best ratio of value to work: it reuses machinery that exists, adds
no dependency, and covers four error families. Layer 2 is nearly free once 1
lands. Layer 3 is the biggest payload to vendor and helps a narrower audience
(model authors) — valuable, but an authoring-experience investment rather than an
error-rate fix.