Skip to content

Client-side validation: catch bad requests before they leave the machine #94

Description

@ernestoongaro

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.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions