Skip to content

Scalar shorthand forces anyOf on Constraint, degrading validation errors #1

Open

Description

@christosgkoros

Context

A design review raised the two implicit rules the README teaches first
(README §The two rules worth learning first):

  1. Sibling members AND together (SPEC §4.4)
  2. A bare scalar means equality (SPEC §5.1)

The objection: in a language that otherwise names every operator explicitly, these two derive
meaning from position and type instead, which makes filters harder to parse and generate. Both
predate the full operator set — v0.1.0 described a grammar it did not enforce, and v0.2.0
completed the vocabulary around these rules without re-justifying them.

This issue records the assessment and proposes one concrete change.

Implicit AND: no change proposed

It is the composition rule, not an exception. JSON object members are unordered, so the only
combinator assignable to siblings is a commutative, associative one — AND is the sole candidate.
The alternative is not a different rule but "objects may have exactly one member," which forces
$and nesting into every non-trivial filter.

It also costs nothing to implement. A parser needs key[0] === '$' ? operator : fieldPath either
way; dropping the rule adds an arity check. In the schema it is properties +
patternProperties + additionalProperties: false, which localises a failure to the offending
member rather than reporting maxProperties against the whole object.

The genuine sharp edge here is the consequence of the rule, not the rule: a field may carry at
most one constraint per object, so a client adding a second constraint must desugar any shorthand
already present, check for operator-key collision, and fall back to an explicit $and. That is
currently a closing paragraph of §4.4 and deserves more prominence.

Scalar shorthand: real cost, concentrated in the validator

For a parser the shorthand is ~3 lines (typeof c !== "object" || c === null ? {$eq: c} : c)
applied at each recursion point. The hazard is downstream of the parser — an optimiser, a cache
key, or a check such as "does this filter pin tenantId to exactly X" must normalise first or
handle both shapes.

The validator cost is larger. The shorthand forces:

"Constraint": {
  "anyOf": [
    { "$ref": "#/$defs/ScalarOrNull" },
    { "$ref": "#/$defs/ConstraintObject" }
  ]
}

anyOf is the worst construct in JSON Schema for error reporting: AJV emits failures from every
branch plus an uninformative "must match a schema in anyOf". The repo's own fixtures show the
result — two adjacent user errors of the same class produce two unrelated keywords, and neither
message says "arrays and objects are excluded from the shorthand":

Fixture Filter expectKeyword
tests/fixtures/invalid/12-object-shorthand.json {"address": {"city": "Athens"}} additionalProperties
tests/fixtures/invalid/13-array-shorthand.json {"tags": ["a", "b"]} type

Fixture 12 fell into the ConstraintObject branch and complained that city is not an operator.

This matters most for the use case v0.3.0 was written for: an agent reading a validation error and
retrying (README §Exposing search to an agent).

Proposal

1. Replace anyOf with if/then/else on Constraint

"Constraint": {
  "if":   { "type": "object" },
  "then": { "$ref": "#/$defs/ConstraintObject" },
  "else": { "$ref": "#/$defs/ScalarOrNull" }
}

An object routes to ConstraintObject and yields a precise additionalProperties error naming the
bad operator. Anything else routes to ScalarOrNull and yields a clean type error listing the
four permitted types. No branch noise.

The accepted set of filters is identical, so this is not a grammar change — but it is a schema
edit, so the Unreleased note that query-language-schema.json is "byte-identical to v0.3.0" needs
rewording to "accepts an identical set of filters."

Not yet verified. The reasoning is from how AJV reports anyOf versus if/then/else, not
from a measurement. Confirming this means running the change against the fixture suite and checking
whether expectKeyword for fixtures 12 and 13 improves. If AJV's output does not improve, this
proposal should be dropped rather than merged on principle.

Same question applies to tools/generate-filter-schema.mjs, which emits per-field constraint
subschemas from the same shape.

2. Demote the shorthand in the spec

Keep it — {"status": "open"} is the most persuasive line in the README, and removing it would
break v0.1.0 compatibility that tests/fixtures/valid/29-* and 30-* currently pin. But:

  • Move it out of §5.1 (operator semantics) into §4 as a normalisation step applied before
    evaluation, so §5 defines semantics over one form rather than two.
  • Add a short canonical form section stating that the explicit operator form is normative, and
    that generators — LLM-driven ones in particular — SHOULD emit it. Hand-written filters keep the
    sugar; machine-written filters get one target.
  • Promote the one-constraint-per-field rule out of the tail of §4.4.

Note that (2) alone does not reduce the validator cost; only (1) does. They are independent.

Open question

Whether to go further and drop the shorthand from the published grammar entirely. Arguments
against: it is the language's most quotable feature, Mongo-shaped familiarity is a real adoption
lever, and it would be a breaking change for a pre-1.0 spec that has so far kept v0.1.0 filters
parsing. Recorded here rather than proposed.

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

Metadata

Metadata

Labels

documentationImprovements or additions to documentationenhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions