# OpenPredicate — full text > One JSON Schema for the filter half of a search API — $ref it from an OpenAPI document, or inline it as an MCP tool's inputSchema. Grammar v0.4.0 · release v0.6.2 · MIT · https://openpredicate.tech Schema: https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json This file concatenates the normative specification, the design questions it answers, and the release history, so an agent can read the whole thing in one request. The specification text below is the same file the repository ships; where this file and the repository disagree, the repository is correct. --- # OpenPredicate — Specification **Version 0.4.0** · Dialect: JSON Schema draft 2020-12 · Schema: [`open-predicate-schema.json`](./open-predicate-schema.json) **OpenPredicate** is stewarded by the [OpenPredicate](https://openpredicate.tech) organisation, which publishes it as an open standard under [MIT](./LICENSE). All identifiers this document defines — schema `$id`s and problem-type URIs — are rooted at `https://openpredicate.tech/`. This document defines the semantics of the language. The schema defines only its *shape* — a validator can tell you that `{"age": {"$gt": 18}}` is well-formed, but not what it means when `age` is `null`, absent, or a string. Everything a server and a client must agree on beyond well-formedness is specified here. For a guided introduction, see [README.md](./README.md). --- ## 1. Scope The language expresses a **predicate over a record**: a function that, given one record, answers whether it matches. The answer is three-valued — TRUE, FALSE or UNKNOWN — and only TRUE matches (§4.1). It is deliberately *not* a full query language. It has no projection, ordering, pagination, grouping or joins — those belong to the enclosing request body, where each API is free to define them. Confining this schema to the predicate is what makes it reusable across endpoints whose result shapes have nothing else in common. A conforming request body embeds a filter as a member, conventionally named `filter`: ```json { "filter": { "status": "open" }, "limit": 50 } ``` The schema validates the value of `filter`, not the envelope. ## 2. Conformance The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). ### 2.1 Profiles Operators are grouped into profiles so that a server can implement a subset honestly rather than silently mistranslating. The grouping is published in the schema itself, under the `x-profiles` extension keyword. | Profile | Operators | | --- | --- | | `core` | `$and` `$or` `$nor` `$not` `$eq` `$ne` `$in` `$nin` `$gt` `$gte` `$lt` `$lte` `$exists` `$isNull` `$unknownAs` | | `strings` | `$like` `$nlike` `$ilike` `$nilike` `$startsWith` `$endsWith` `$contains` | | `regex` | `$regex` `$flags` | | `ranges` | `$between` `$nbetween` | | `types` | `$type` | | `collections` | `$some` `$every` `$hasAll` `$size` | | `refs` | `$field` `$literal` | | `text` | `$search` | A conforming implementation MUST implement `core` in full. Every other profile is OPTIONAL, and MUST be implemented in full or not at all — partial profiles defeat the purpose of advertising them. An implementation that accepts only part of a profile is not prohibited from existing; it is prohibited from *advertising* that profile. It states what it accepts per path instead, through the `operators` member of §2.2, and omits the incomplete profile from `profiles`. An implementation MUST reject an operator it does not support with an `unsupported-operator` error (§8). It MUST NOT silently ignore the clause: dropping a predicate from a filter widens the result set, which is the most dangerous possible failure mode for an authorization-adjacent filter. ### 2.2 Capability discovery An implementation SHOULD publish which profiles and fields it accepts. This specification does not mandate a location; a `capabilities` member on the collection resource, or a separate endpoint linked by a `describedby` relation, both work. The RECOMMENDED shape: ```json { "queryLanguage": "https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json", "profiles": ["core", "strings", "ranges"], "fields": { "status": { "operators": ["$eq", "$ne", "$in"], "type": "string", "values": ["available", "pending", "sold"], "description": "Listing state." }, "createdAt": { "operators": ["$gt", "$gte", "$lt", "$lte", "$between"], "type": "string", "format": "date-time" }, "title": { "operators": ["$eq", "$like", "$ilike"], "type": "string" } }, "limits": { "maxDepth": 10, "maxClauses": 100, "maxSetLength": 1000 } } ``` The top-level members: | Member | Required | Meaning | | --- | --- | --- | | `queryLanguage` | SHOULD | The `$id` of the grammar version this endpoint implements. | | `profiles` | yes | The profiles implemented **in full**, per §2.1. An endpoint that accepts only part of a profile MUST NOT list it here; the per-path `operators` are where the accepted subset is stated. A client MUST therefore read `operators` rather than deriving a path's operators from `profiles`. | | `fields` | yes | One member per queryable path, described below. | | `limits` | SHOULD | The bounds of §7 as this endpoint enforces them. Absent means the §7 defaults apply. | | `filterSchema` | — | The URL of a per-resource filter schema (§6) carrying the same operators and domains, where one is published. | Each member of `fields` describes one queryable path: | Member | Required | Meaning | | --- | --- | --- | | `operators` | yes | The operators accepted on this path. A subset of those implied by `profiles`, and the authoritative list where a profile is only partly implemented. | | `type` | SHOULD | The JSON type of the field's value, drawn from the `$type` vocabulary of §5.10. | | `format` | — | A format name constraining a `type: "string"` value: `date`, `date-time`, `uuid`, and so on. | | `values` | SHOULD, where the domain is closed | The complete set of accepted values. | | `itemValues` | SHOULD, where an array's element domain is closed | The complete set of accepted element values. | | `nullable` | — | Present and `true` where the field's value may be `null`, which is what makes `$isNull` and `$unknownAs` meaningful on it (§4.6). | | `description` | — | Prose stating what the field holds. | `operators` tells a client what it may write; `type`, `format` and `values` tell it *what to write*. The grammar cannot carry that second half: field names are constrained through `propertyNames`, but every path shares one `Constraint` definition, so per-field operand domains are not expressible in the schema (§6). A filter naming a real field with a value outside that field's domain is therefore well-formed, and matches nothing — the failure surfaces as an empty result set rather than an error (§8). The capability document is the only place the domain can be stated, which is why `values` is RECOMMENDED wherever the domain is closed. ## 3. Data model and field paths ### 3.1 Records A **record** is any JSON value, in practice an object. Field paths address positions within it. The language makes no assumption that records share a shape. ### 3.2 Path grammar ```abnf field-path = head-key *( "." key ) head-key = ( head-char / "$$" ) *key-char *index key = 1*key-char *index head-char = key-char ; excluding "$" key-char = unescaped / escape-seq unescaped = %x20-2D / %x2F-5A / %x5E-10FFFF ; any character except "." "[" "\" "]" escape-seq = "\" ( "." / "[" / "]" / "\" ) index = "[" 1*DIGIT "]" ``` - `address.city` — a member of a nested object. - `items[0].sku` — the first element of an array. - `a\.b` — a single key whose literal name contains a dot. - `$$price` — a single key whose literal name is `$price`. A path addresses **one** position. To say something about the elements of an array without naming an index, quantify explicitly with `$some` or `$every` (§5.8). There is deliberately no wildcard segment: a path shape is the wrong place to carry a quantifier, because it cannot be declined by profile (§2.1) and it leaves the quantifier's scope implicit. ### 3.3 The `$` prefix is reserved Within a `Filter` object, a member name beginning with `$` is an operator. A record field whose real name begins with `$` MUST be escaped by doubling the prefix: `$$price` addresses the field `$price`. A name beginning with a single `$` that is not a defined operator MUST be rejected as `malformed-query` (§8) rather than treated as a field. This is what turns a typo like `$eqq` into an error instead of a filter that matches everything. ### 3.4 Resolution Resolving a path against a record yields **zero values or exactly one**: - Zero, when the path does not exist — including when traversing *into* a non-object or non-array. - One, otherwise. That one value may itself be `null`, an array or an object. The distinction between "yields zero values" and "yields one value that is `null`" is load-bearing; see §4.2. Inside `$some` and `$every` (§5.8) the paths of the nested condition resolve against the **element** rather than against the record, by the same rule. ### 3.5 Which paths are queryable The schema's default path rule is permissive by design: the set of queryable fields is a property of the resource, not of the language. An implementation MUST reject a path it does not expose with an `unknown-field` error (§8), and SHOULD publish the accepted set through §2.2. Endpoints that want the field set enforced by schema validation can narrow `$defs/FieldPath` in a bundled copy — see README §*Restricting the queryable field set*. An index suffix addresses the elements of a path rather than a member beneath it, so `items[0]` is the field `items` for this purpose: exposing `items` exposes `items[0]`. A **named** member beneath it (`items[0].sku`) is a separate path and MUST be exposed on its own. ## 4. Evaluation ### 4.1 Three-valued logic Every clause evaluates to **TRUE**, **FALSE** or **UNKNOWN**. UNKNOWN arises when a comparison is not meaningful — the path resolved to nothing, or to `null`, or to a value of a type the operator cannot order. | `a` | `b` | `a AND b` | `a OR b` | `a NOR b` | | --- | --- | --- | --- | --- | | T | T | T | T | F | | T | F | F | T | F | | T | U | U | T | F | | F | F | F | F | T | | F | U | F | U | U | | U | U | U | U | U | All three are commutative, so the six rows cover all nine combinations. | `a` | `NOT a` | | --- | --- | | T | F | | F | T | | U | **U** | `$nor [a, b, …]` is `NOT (a OR b OR …)`, which is TRUE only when **every** member is FALSE. One UNKNOWN member makes the whole thing UNKNOWN, so a `$nor` over a nullable field excludes the records whose field is null or absent — the same surprise as `$not`, one level up. **A record is included in the result if and only if the filter evaluates to TRUE.** UNKNOWN excludes, exactly as SQL's `WHERE` does. The consequence that surprises people: `{"$not": {"status": {"$eq": "archived"}}}` does **not** match records whose `status` is `null` or absent, because `$eq` returned UNKNOWN and `NOT UNKNOWN` is UNKNOWN. To include them, say so with `$unknownAs` (§4.6): ```json { "status": { "$ne": "archived", "$unknownAs": true } } ``` which is equivalent to the longhand this specification prescribed before v0.4.0: ```json { "$or": [ { "status": { "$ne": "archived" } }, { "status": { "$isNull": true } } ] } ``` ### 4.2 Missing versus null These are different states and the language keeps them apart: | Record | `{"$exists": true}` | `{"$exists": false}` | `{"$isNull": true}` | `{"$eq": "x"}` | | --- | --- | --- | --- | --- | | `{"a": "x"}` | TRUE | FALSE | FALSE | TRUE | | `{"a": null}` | TRUE | FALSE | TRUE | UNKNOWN | | `{}` | FALSE | TRUE | UNKNOWN | UNKNOWN | `$exists` and `$isNull` are the only operators that are never UNKNOWN for the reason of absence — `$exists` is total, and `$isNull` is UNKNOWN only when the path resolves to nothing. Implementations backed by a store that cannot distinguish the two (many document stores, most SQL columns) MUST document which state they report and SHOULD reject `$exists` rather than approximate it. ### 4.3 Types and coercion There is **no implicit coercion**. Comparing values of different JSON types is never an error and never a coerced comparison. `{"age": {"$gt": "18"}}` against `{"age": 21}` is UNKNOWN, not TRUE. A type mismatch resolves differently for equality than for ordering, and the difference is observable under negation: | Operator family | Type mismatch | Why | | --- | --- | --- | | `$eq`, `$ne`, `$in`, `$nin`, `$hasAll` | **FALSE** (unequal) | Equality is structural (§5.1). A string and a number are not equal; nothing is unknown about it. So `{"notes": {"$ne": 3}}` **does** match a record whose `notes` is `"hello"`. | | `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$nbetween` | **UNKNOWN** | No ordering is defined across types (§5.2). | | `$like` and friends, `$regex`, `$search`, `$startsWith`, `$endsWith`, `$contains` | **UNKNOWN** | The operator is defined on strings only (§5.5, §5.6). | | `$some`, `$every`, `$hasAll`, `$size` | **UNKNOWN** | The operator is defined on arrays only (§5.8). | `$exists`, `$isNull` and `$type` are total over types by construction and never UNKNOWN for this reason. This is a deliberate departure from SQL, where `'18' > 17` may or may not succeed depending on the engine. Servers that need coercion (a date column queried with a string, for instance) SHOULD perform it at the *boundary* — mapping the operand into the field's declared type once, before evaluation — and MUST reject an operand that cannot be mapped with an `invalid-operand` error (§8) rather than evaluating it as UNKNOWN. ### 4.4 Implicit AND Sibling members of a `Filter` object are combined with AND: ```json { "department": "sales", "age": { "$gte": 18 } } ``` is identical to ```json { "$and": [ { "department": "sales" }, { "age": { "$gte": 18 } } ] } ``` The same rule applies to sibling operators within one constraint object: `{"age": {"$gt": 18, "$ne": 30}}` is `age > 18 AND age <> 30`. Logical operators and field constraints MAY be siblings; all of them AND together. Because JSON object members are unordered and duplicate names are not interoperable, a field can appear at most once per object. Two constraints on the same field that cannot be merged into one object go in an explicit `$and`. ### 4.5 Evaluation order Evaluation order is unobservable: operators are side-effect free and no operator's well-formedness depends on another's result. Implementations are free to reorder, short-circuit and push down clauses however their storage engine prefers. ### 4.6 Resolving UNKNOWN — `$unknownAs` `$unknownAs` is a modifier on a constraint object, not a predicate. It resolves that constraint's UNKNOWN to the value given: ```json { "status": { "$ne": "archived", "$unknownAs": true } } ``` "`status` is not `archived`, and count the records where `status` is null or absent." It is the one-clause form of the §4.1 longhand, and it is the answer to the language's most common mistake: a negative predicate that silently drops the rows a client meant to include. It MUST be accompanied by at least one operator; the schema enforces this. Two rules fix its meaning. **It applies last.** The modifier resolves the result of the whole constraint object — after every sibling operator has been evaluated and ANDed together, and after a field-level `$not` (§5.13). For the conjunction this is unambiguous either way, because resolving UNKNOWN distributes over three-valued AND: writing `c` for the resolution, `c(a AND b)` equals `c(a) AND c(b)` in all nine cases. An implementation MAY therefore resolve per operator or once over the conjunction. It does **not** distribute over negation, which is why the ordering is normative rather than left to the implementer: ```json { "age": { "$not": { "$gt": 5 }, "$unknownAs": true } } { "age": { "$not": { "$gt": 5, "$unknownAs": true } } } ``` For a record with no `age`, the first is TRUE — the negation yields UNKNOWN, which is then resolved to TRUE — and the second is FALSE, because the inner UNKNOWN is resolved to TRUE first and then negated. Both are legal; the nesting says which is meant. **It is scoped to its own constraint object.** It does not reach into a nested `$some` or `$every` condition, and it does not affect sibling fields. Inside a quantifier it applies per element: ```json { "tags": { "$some": { "$gt": 5, "$unknownAs": true } } } ``` `$unknownAs` is a no-op wherever UNKNOWN is unreachable — on `$exists`, which is total (§4.2). Implementations MUST accept it there rather than rejecting it, so that a generated schema need not special-case the field; it simply changes nothing. A generator MAY omit it from a field that can be neither absent nor null, for the same reason it omits `$exists` and `$isNull` there. ## 5. Operator semantics Throughout, *the value* means the value the field path resolved to (§3.4). Unless stated otherwise, an operator applied to a path that resolved to nothing yields UNKNOWN. ### 5.1 Equality — `$eq`, `$ne` Structural equality over JSON values. Objects compare irrespective of member order; arrays compare element-wise and are order-**sensitive**. Numbers compare by mathematical value, so `1`, `1.0` and `1e0` are equal. A value of a **different JSON type is unequal, not unknown**: `$eq` is FALSE and `$ne` is TRUE (§4.3). Only absence and `null` produce UNKNOWN here. `$eq: null` is TRUE when the value is `null` — it is the one comparison for which `null` is an operand rather than a cause of UNKNOWN. `$ne` is the negation of `$eq` under three-valued logic, so `{"a": {"$ne": 1}}` is UNKNOWN when `a` is absent. Add `$unknownAs: true` (§4.6) to match those records. The scalar shorthand `{"status": "open"}` is exactly `{"status": {"$eq": "open"}}`. It is available for strings, numbers, booleans and `null`. Arrays and objects are excluded so that `{"tags": ["a", "b"]}` cannot be read as either `$eq` or `$in`; write the operator you mean. ### 5.2 Ordering — `$gt`, `$gte`, `$lt`, `$lte` Defined for two numbers or two strings. Numbers compare numerically. Strings compare by Unicode code point, which for RFC 3339 `date`, `date-time` and `time` strings coincides with chronological order — provided the operands use the same offset. Comparing a `Z`-suffixed timestamp against a `+02:00` one lexicographically gives the wrong answer; servers that store timestamps as strings SHOULD normalise to UTC before comparing, and servers that store them as instants SHOULD parse the operand. Any other operand type, or a type mismatch between operand and value, yields UNKNOWN. ### 5.3 Ranges — `$between`, `$nbetween` `{"$between": [lo, hi]}` is `value >= lo AND value <= hi` — **inclusive at both ends**. `$nbetween` is its negation under three-valued logic. Both bounds SHOULD be of the same type; if they are not, the result is UNKNOWN. If `lo > hi` the range is empty and the result is FALSE, not an error. ### 5.4 Sets — `$in`, `$nin` `$in` is TRUE when the value is `$eq` to at least one member of the list. `$nin` is its negation. **`$in` compares the value as a whole.** If the field is array-valued, `{"tags": {"$in": ["a"]}}` asks whether the array *equals* `"a"` — which it does not. Element membership is a quantifier over the elements: ```json { "tags": { "$some": { "$in": ["a"] } } } ``` This differs from MongoDB, which overloads `$in`; the split is intentional, because overloading makes the meaning depend on data the validator cannot see. What v0.4.0 changed is that the element form now names its quantifier, so the two spellings can no longer be mistaken for variants of one operator. The list is a set: duplicate members are rejected by the schema, and order is not significant. Members are drawn from the same operand grammar as every other operator, so a `$field` reference or a `$literal` may appear among them. ### 5.5 Pattern matching — `$like`, `$nlike`, `$ilike`, `$nilike` A `$like` pattern is matched against the **whole** value, not a substring of it. | Sequence | Meaning | | --- | --- | | `%` | zero or more characters | | `_` | exactly one character | | `\%` | a literal `%` | | `\_` | a literal `_` | | `\\` | a literal `\` | A `\` followed by anything else, or a trailing `\`, is a malformed pattern and MUST be rejected as `invalid-operand` (§8). `$ilike` is `$like` under case-insensitive comparison. Collation is server-defined; Unicode simple case folding is RECOMMENDED. Servers SHOULD state their collation in their capability document, because case folding for non-ASCII text differs materially between engines. A non-string value yields UNKNOWN. ### 5.6 Substrings — `$startsWith`, `$endsWith`, `$contains` Literal, case-sensitive substring tests. `%` and `_` carry **no** special meaning here — `{"body": {"$contains": "100%"}}` looks for the three characters `100%`. An empty operand is TRUE for any string value. `$contains` is string-only. For arrays, quantify over the elements: `{"tags": {"$some": {"$contains": "x"}}}` (§5.8). ### 5.7 Regular expressions — `$regex`, `$flags` The operand is an ECMA-262 regular expression, matched **unanchored**: the clause is TRUE if the pattern matches anywhere in the value. Anchor with `^` and `$` for a whole-value match. `$flags` accepts `i` (case-insensitive), `m` (`^`/`$` match at line breaks) and `s` (`.` matches line terminators), in any order. It is only valid alongside `$regex`; the schema enforces this. Flags that affect capture or iteration (`g`, `y`, `u`, `d`) are excluded because the language only asks whether a match exists. A pattern that does not compile MUST be rejected as `invalid-operand`. See §7 on execution limits — this operator is the language's largest denial-of-service surface. ### 5.8 Collections — `$some`, `$every`, `$hasAll`, `$size` All four require the value to be an array; any other type — including `null`, and including a path that resolved to nothing — yields UNKNOWN. **`$some` and `$every` quantify over the field's elements.** Each takes a `Filter` when the elements are objects, in which case the paths inside resolve against the element (§3.4), or a constraint object when the elements are scalars: ```json { "items": { "$some": { "qty": { "$gt": 2 } } } } { "tags": { "$every": { "$startsWith": "adopt-" } } } ``` - `$some` — TRUE when at least one element satisfies the condition. - `$every` — TRUE when every element satisfies it. **The quantifiers are two-valued over elements.** An element for which the condition is UNKNOWN does not satisfy it, and does not make the quantifier UNKNOWN. So over any array both operators return TRUE or FALSE, and UNKNOWN can only come from the field itself: | Value of the field | `$some` | `$every` | | --- | --- | --- | | Array, at least one element satisfies | TRUE | FALSE unless all do | | Array, every element satisfies | TRUE | TRUE | | Array, no element satisfies | FALSE | FALSE | | Array, every element UNKNOWN | FALSE | FALSE | | Empty array `[]` | **FALSE** | **TRUE** (vacuously) | | `null` | UNKNOWN | UNKNOWN | | Path resolved to nothing | UNKNOWN | UNKNOWN | | Present but not an array | UNKNOWN | UNKNOWN | The two vacuous-case answers are the ones worth reading twice. `$every` over an empty array is TRUE because there is no element that fails; `$every` over a **missing** array is UNKNOWN, not vacuously TRUE, because there is no array at all. Note that `$every` is not expressible as a negation. `{"$not": {"items": {"$some": c}}}` is "no element satisfies `c`", and `$some` over the negation of `c` admits elements for which `c` is UNKNOWN. Neither is `$every`. **Operand shape.** `$some` and `$every` accept either a `Filter` or a constraint object, and the two overlap on a leading `$not`. An implementation MUST disambiguate by scanning for the first member that can only be one of the two — a field path, `$and`, `$or` or `$nor` makes it a `Filter`; any other operator makes it a constraint object — recursing through the bodies of `$not`, `$and`, `$or` and `$nor` when the outer member is itself ambiguous. If no such member exists, it is a constraint object. **`$hasAll` quantifies over the operand, not over the elements.** TRUE when every member of the list is `$eq` to some element — possibly different elements. Set semantics: multiplicity is ignored, so `["a"]` satisfies `$hasAll: ["a"]` and `["a","a"]` does not additionally satisfy anything. It is kept as its own operator because that doubled quantifier is not a `$some` or an `$every` over any single condition; the `$some`-per-member rewrite would grow with the operand and run into the §7 clause limit. **`$size` compares the array's length.** `{"$size": 3}` is exact; `{"$size": {"$gte": 1}}` compares. An empty array has size `0`. ### 5.9 Quantifier scope A quantifier scopes every condition inside it to **one** element. That is the difference between these two filters, and it is the whole reason `$some` takes a nested condition rather than being spelled across sibling clauses: ```json { "items": { "$some": { "qty": { "$gt": 2 }, "sku": { "$startsWith": "A" } } } } ``` is TRUE only when a **single** item has both `qty > 2` and an `A` SKU. ```json { "$and": [ { "items": { "$some": { "qty": { "$gt": 2 } } } }, { "items": { "$some": { "sku": { "$startsWith": "A" } } } } ] } ``` is TRUE when *some* item has `qty > 2` and *some* item has an `A` SKU — **possibly different items**, because each `$some` chooses its own element. Both readings are expressible and the nesting says which is meant. Versions before v0.4.0 offered a second mechanism for the second reading — a `[*]` wildcard path segment — which expressed nothing the two-clause form does not, could not be declined by a server through its profiles, and left the quantifier's scope to be inferred from a path shape. It was removed; see [`decisions/0001`](https://github.com/OpenPredicate/open-predicate/blob/main/decisions/0001-array-quantifiers-and-unknown-handling.md). ### 5.10 Presence and type — `$exists`, `$isNull`, `$type` `$exists` and `$isNull` are specified by the table in §4.2. `$exists` is total: it is TRUE or FALSE for every record and every path, with no exceptions anywhere in this specification. `$type` tests the value's JSON type against one of `string`, `number`, `integer`, `boolean`, `object`, `array`, `null`. `integer` matches a number with no fractional part, so `3` and `3.0` are both integers and `3.5` is not; `number` matches any number including integers. `$type: "null"` is TRUE for a present `null` and UNKNOWN for an absent field — use `$exists` to test absence. ### 5.11 Field references — `$field`, `$literal` `{"$field": "path"}` in operand position resolves `path` against the **same record** and compares the two values, giving the equivalent of SQL's `WHERE price > cost`: ```json { "price": { "$gt": { "$field": "cost" } } } ``` If the referenced path resolves to nothing, the comparison is UNKNOWN. A `$field` path is subject to the same queryability rules as any other path (§3.5) — a reference is a read, and MUST be authorized as one. Inside `$some` or `$every`, a `$field` reference resolves against the **element**, consistently with every other path in that nested condition (§3.4). To compare an element against a member of the enclosing record, hoist the comparison out of the quantifier. `{"$literal": v}` forces `v` to be treated as data. It is needed only when an object operand would otherwise be read as a reference: ```json { "payload": { "$eq": { "$literal": { "$field": "this is data" } } } } ``` ### 5.12 Free text — `$search` `{"title": {"$search": "kubernetes ingress"}}` requests a free-text match. Tokenisation, stemming, stop-words, phrase handling and relevance are entirely server-defined; this specification only reserves the operator name so that implementations do not each invent one. Because relevance ordering is not part of a predicate language, a server that returns ranked results MUST expose the ranking through its own response envelope. ### 5.13 Field-level `$not` `{"age": {"$not": {"$gt": 5}}}` negates the constraint on that one field. It follows §4.1: UNKNOWN in, UNKNOWN out. It is a convenience — the same thing can always be written with the top-level `$not`. A sibling `$unknownAs` applies *after* the negation; to resolve UNKNOWN before it, put the modifier inside. See §4.6. ## 6. Extensibility The `$` namespace is reserved for this specification. An implementation that adds an operator MUST prefix it distinctly (`$x_`, or a vendor tag such as `$acme_geoWithin`) and MUST document it, because a bare `$geoWithin` may be standardised later with different semantics. Adding an operator means the endpoint no longer validates against the published schema. Such an endpoint SHOULD publish an extended schema that `allOf`-composes or bundles this one, and MUST NOT advertise the unmodified `$id`. ## 7. Safety limits A filter is user-supplied input that becomes a query plan. Every implementation MUST bound it. RECOMMENDED defaults, to be published through the capability document (§2.2): | Limit | Default | Why | | --- | --- | --- | | Nesting depth | 10 | Recursive descent over an attacker-supplied tree. | | Total clauses | 100 | Query planners degrade non-linearly. | | Set length (`$in`, `$nin`, `$hasAll`) | 1000 | Each member is a comparison. | | Request body size | 64 KiB | The cheapest limit to enforce, and the one that subsumes the others. | | `$regex` execution | 100 ms per record, or a non-backtracking engine | Catastrophic backtracking (ReDoS). | An implementation MUST reject a filter that exceeds a limit with `query-too-complex` (§8). It MUST NOT truncate the filter to fit — a truncated predicate silently returns records the client did not ask for. For `$regex`, a linear-time engine (RE2, Rust `regex`, Go `regexp`) is strongly RECOMMENDED over a backtracking one. Where that is not available, `$regex` SHOULD be left out of the advertised profiles entirely, and clients directed to `$like`, whose worst case is bounded. Fields backed by unindexed storage are their own denial-of-service surface. An implementation SHOULD restrict expensive operators to indexed fields through its capability document rather than accepting them and timing out. `$some` and `$every` are the expensive operators on most backends: each one is a traversal of an array, and a nested quantifier is a traversal per element. They count toward the nesting-depth and clause limits like any other clause, and — unlike the `[*]` path segment they replaced — a server that cannot afford them can decline the whole `collections` profile (§2.1) instead of having to accept them as part of the path grammar. An implementation SHOULD publish a lower `maxDepth` for filters containing quantifiers if its storage makes them disproportionately costly. ## 8. Errors A rejected filter MUST be answered with status `400 Bad Request`, and the response MUST tell the client which of these conditions applies. Which one it is decides what the client does next, so an error that says only "bad request" is not conforming. | Condition | Meaning | | --- | --- | | `malformed-query` | The body does not conform to the schema: unknown operator, wrong operand type, structural error. | | `unknown-field` | The path is well-formed but this endpoint does not expose it. | | `unsupported-operator` | The operator is part of the language but not of this endpoint's profiles. | | `invalid-operand` | The operator is supported but the operand is not usable: an uncompilable `$regex`, a malformed `$like` escape, a value outside the field's domain. | | `query-too-complex` | A limit from §7 was exceeded. | **The wire format is the API's own.** This specification mandates the conditions, not an envelope: an API that already has an error format SHOULD express them in it rather than carry a second format for one endpoint. Where there is no established format, [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details with media type `application/problem+json` is RECOMMENDED, taking each condition as a `type` URI relative to `https://openpredicate.tech/problems/`. The examples below use it. However it is encoded, the error SHOULD locate the offending clause with a **`pointer`**: an [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) JSON Pointer into the request body. Without it a client faced with a deeply nested filter has no way to know which clause to fix. ```http HTTP/1.1 400 Bad Request Content-Type: application/problem+json ``` ```json { "type": "https://openpredicate.tech/problems/unsupported-operator", "title": "Unsupported operator", "status": 400, "detail": "$regex is not in this endpoint's advertised profiles (core, strings).", "pointer": "/filter/$and/1/name/$regex" } ``` An error SHOULD carry whatever the client needs to build a correct filter on its next attempt, not only a statement of what was wrong. The member names below are the RECOMMENDED ones; an API expressing these in its own envelope keeps the information and adapts the naming: - `unknown-field` SHOULD carry a **`queryableFields`** member listing the paths this endpoint does expose. Without it, a client that guessed one field name wrong has no way to converge except by guessing again. - `unsupported-operator` SHOULD name the endpoint's advertised profiles, as above. - `invalid-operand` SHOULD carry an **`accepted`** member describing the field's domain — the `values`, `type` or `format` of §2.2 — whenever the operand was rejected for falling outside it. These members restate part of the capability document (§2.2) deliberately: a client that never fetched it can still recover in one round trip. ```json { "type": "https://openpredicate.tech/problems/unknown-field", "title": "Unknown field", "status": 400, "detail": "'birthDate' is not a queryable path on this collection.", "pointer": "/filter/$and/0/birthDate", "queryableFields": ["id", "name", "species", "status", "born", "tags"] } ``` A server MUST report the first error it finds rather than partially evaluating, and SHOULD report all of them when it can — a client fixing one clause at a time across round trips is a poor experience for a filter with a dozen clauses. ## 9. Versioning The schema's `$id` carries the version: `https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json`. Each release is published at its own URL under that namespace and, once published, is immutable. Consumers pin by `$id`. - **Patch** — documentation and description text only. - **Minor** — new optional operators or profiles. A filter valid under `v0.N` stays valid under `v0.N+1`. - **Major** — anything that can invalidate an existing filter. Before `1.0.0` a minor release MAY break compatibility; each such break is recorded in [CHANGELOG.md](./CHANGELOG.md) with a migration note. ## 10. References - [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) / [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) — requirement keywords - [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) — date and time on the internet - [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) — JSON Pointer - [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) — Problem Details for HTTP APIs - [JSON Schema draft 2020-12](https://json-schema.org/draft/2020-12/release-notes) - [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008) — the HTTP QUERY method - [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) · [OpenAPI 3.2](https://spec.openapis.org/oas/v3.2.0) - [ECMA-262 §22.2](https://tc39.es/ecma262/#sec-regexp-regular-expression-objects) — regular expressions --- ## Designing a search API — common questions ### Should search filters go in the query string or the request body? In the body, once the filter can nest. A query string has no natural way to express (a AND b) OR c, so APIs that start there end up inventing a mini-language inside a string — ?q=status:open AND born>2020 — which no schema can validate and no client can build safely. A JSON body nests for free. The cost is that POST is not obviously cacheable or idempotent, which is exactly the gap RFC 10008's QUERY method closes: a method with a body that is safe and cacheable. Use QUERY where you can, POST /…/search where you cannot. ### Should a search endpoint be GET or POST? GET for the simple, flat cases — a handful of equality filters that fit in a query string and benefit from HTTP caching. POST /…/search once filters nest or grow past URL length limits. The honest answer is that neither is right, because GET has no body and POST is not safe; QUERY exists because the working group agreed. If you support two, make the filter grammar identical across both so a client does not have to learn twice. ### How do I describe a search filter in an OpenAPI document? Point the filter property at a JSON Schema with $ref. If the grammar is one self-contained schema, that is a single line, it validates in CI, and your generated clients get types for it. What you should not do is type the filter as object with no further constraint, or as a string — both mean your document describes the endpoint's shape while saying nothing about the part clients get wrong most. ### What should an MCP tool's inputSchema be for a search tool? A JSON Schema that spells out the filter grammar, rather than a free-text query string. A model writing into a typed schema can be told exactly which operators exist and what each one means, and the result can be validated before it reaches your database. A free-text field pushes the parsing problem onto you and gives the model nothing to aim at. Every operator in OpenPredicate carries a description, so those descriptions become the tool's instructions. ### How should a search API reject a filter it cannot serve? With 400, a machine-readable reason, and a pointer to the clause at fault. "Bad request" with no reason forces the client to guess whether the filter was malformed, referenced an unknown field, or used an operator this endpoint does not serve — three problems with three different fixes. OpenPredicate defines five conditions and recommends RFC 9457 Problem Details with an RFC 6901 JSON Pointer. ### How does a client know which fields and operators a search endpoint supports? It has to be told, because no endpoint serves the whole grammar over every field. Publish a capability document — profiles served, and per field the operators and domain allowed. A client can then narrow a filter before sending it, and a model can be handed the same document as context. OpenPredicate makes profiles the unit a server advertises, and accepting only part of a profile is allowed while advertising it in that case is not. ### Why does my filter match fewer rows than expected when a field is null? Because comparison against a missing or null field is neither true nor false. OpenPredicate is explicit about this: evaluation is three-valued — TRUE, FALSE, UNKNOWN — and only TRUE matches. So {"status": {"$ne": "archived"}} does not match a record with no status at all, which surprises almost everyone the first time. That is SQL's behaviour too; the difference is that it is written down, and $unknownAs lets a clause opt out deliberately. ### Should I write my own filter grammar or adopt one? Writing one is a week; maintaining it is the rest of the project. The parts that look small up front are where the cost is — precedence, null handling, type coercion, a safety limit on nesting depth, an error model precise enough to act on, and a description a client generator can read. If you adopt one, the test is whether it is a plain JSON Schema you can $ref, whether it pins by version, and whether it says what happens when a field is missing. --- # Changelog All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the pre-1.0 caveat that a minor release may break compatibility, in which case the break is spelled out below. ## [Unreleased] ## [0.6.2] — 2026-09-14 **A packaging release, again.** The schema is still byte-identical and the `$id` still names `v0.4.0`. 0.6.1 fixed the defects an audit of the package found; this one fixes the ones that audit had not looked for, and closes the coverage gaps that let the first set through. ### Fixed - **35 links in the shipped documents pointed at files that are not shipped.** The package is a subset of the repository, so a relative link into `examples/` reads fine on GitHub and is dead for anyone reading the same file in `node_modules`. npmjs.com hides this for the README — it rewrites relative links against the repository — but only for the README, and only on that page. Fixed from both ends. `files` now ships the documents a consumer or a would-be contributor plausibly wants offline: `COMPARISON.md`, `CONTRIBUTING.md`, `GOVERNANCE.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` and `SUPPORT.md`. Links to things a schema package has no business shipping — `examples/`, `assets/`, `.github/`, `tests/`, `experiments/`, the 39 KB decision record and the maintainer-only `RELEASING.md` — are now absolute. The tarball grew from 61 KB to 77 KB, which is the price of the docs being readable where they are installed. - **`npx open-predicate` printed npm's `could not determine executable to run`.** The unscoped name is a deprecated placeholder with no code, so npm had no bin to run and said so in its own terms, which tells someone following a stale instruction nothing. It now carries a single executable whose only job is to name the scoped package and exit non-zero. The recipe in [`RELEASING.md`](https://github.com/OpenPredicate/open-predicate/blob/main/RELEASING.md) is updated to reproduce it, and records that `npm deprecate` marks a version rather than a package, so the deprecation has to be re-applied after each publish. ### Added - **The packed artifact is now tested under every package manager, on every platform that links a bin differently.** The 0.6.0 no-op was a symlink bug, and package managers disagree about symlinks: npm and yarn classic symlink, pnpm writes a shell shim, Yarn Berry's PnP has no `node_modules` at all, and Windows gets `.cmd` wrappers that pass the real path — which is why the bug never existed there. The release gate added in 0.6.1 only ever proved the npm-on-Linux case. [`.github/scripts/smoke-packed-artifact.sh`](https://github.com/OpenPredicate/open-predicate/blob/main/.github/scripts/smoke-packed-artifact.sh) installs the tarball and drives the bin, `require()`, the documented ESM import attribute and `./generate` under a named package manager, and CI runs it across npm, pnpm, yarn, yarn-pnp and bun on Linux plus npm on Windows and macOS. The release workflow calls the same script rather than keeping a second copy of the logic. - **`tests/packaging.test.mjs` asserts the claims a tarball can break.** Every relative link in a shipped document resolves to a shipped file; no shipped document links outside the package; the bin is shipped, non-empty and still has its shebang; and the declared Node floor is the one the code actually needs. It reads the file list from `npm pack --dry-run --json` rather than extracting a tarball, so it needs no `tar` and runs on Windows unchanged. - **CI declares `permissions: contents: read`.** It had no `permissions` block at all, so it inherited the repository default while `release.yml` had been careful to take none. ## [0.6.1] — 2026-09-14 **A packaging release.** No change to the grammar, the schema or the semantics of evaluation: `open-predicate-schema.json` is byte-identical to 0.6.0, and the `$id` still names `v0.4.0`. What changed is that the package now works the way 0.6.0 said it did. ### Fixed - **The CLI did nothing when invoked as a CLI.** `tools/generate-filter-schema.mjs` guarded its entry point with `fileURLToPath(import.meta.url) === process.argv[1]`, and npm installs a `bin` as a **symlink** — so `argv[1]` was `node_modules/.bin/open-predicate-generate` while `import.meta.url` was the file it pointed at. The comparison was false, `main()` never ran, and the process exited **0 having printed nothing**. Every `npx @open-predicate/open-predicate` and every global install was affected in `0.6.0`, the first release to ship a `bin` at all. It went unnoticed because the two ways it is exercised here both avoid the symlink: `npm run generate:example` and the tests call the file by path. The same comparison also failed for a plain path invocation anywhere under a symlinked directory — including `/tmp` on macOS, which is a symlink to `/private/tmp`. The guard now compares through `realpathSync` on both sides, and `tests/generator.test.mjs` runs the real generator through a real symlink and requires output, so the regression cannot return. It still must not run on import, and that is asserted in the same test. - **`engines` was missing, so an unsupported Node failed obscurely.** The package now declares `"node": ">=20.10.0"`. That is the real floor, not a guess: the generator uses `parseArgs` from `node:util`, and the consumption path the README documents — `import schema from '@open-predicate/open-predicate' with { type: 'json' }` — needs import attributes, which is 20.10. Without the field, npm had nothing to warn against and a user on an older line got a stack trace instead of a version complaint. - **The release pipeline never exercised the artifact it publishes.** `npm test` and `npm run generate:example` both invoke the generator by path, which is the one way that never crosses the symlink npm installs a `bin` as — so the no-op above passed every check and shipped. [`.github/workflows/release.yml`](https://github.com/OpenPredicate/open-predicate/blob/main/.github/workflows/release.yml) now packs the tarball, installs it into a scratch project the way a consumer would, and drives every entry point the README documents: the bin through its symlink, `require()`, the ESM import attribute, and `./generate`. It also asserts the generator stays silent when merely imported. A release cannot now ship an artifact whose documented entry points do not work. ### Changed - **The npm package is scoped: `@open-predicate/open-predicate`.** 0.6.0 named it `open-predicate`, unscoped; it now sits under the `open-predicate` organisation on npmjs.com, which is where the project's packages will live. Nothing was ever published under the unscoped name, so there is no version to migrate from and no redirect to leave behind. GitHub Packages is unaffected — it still carries `@openpredicate/open-predicate`, because that scope has to match the repository owner. One consequence worth knowing: `npx` resolves a *package* name, so the generator is now `npx @open-predicate/open-predicate` rather than `npx open-predicate-generate`. The `bin` is still named `open-predicate-generate` once the package is installed. The unscoped `open-predicate` is claimed anyway, as a deprecated placeholder holding two files and no code, so the name cannot end up on something unrelated to the project. `npm install open-predicate` prints a redirect to the scoped package. It is not versioned alongside releases and the release pipeline never touches it — see [`RELEASING.md`](https://github.com/OpenPredicate/open-predicate/blob/main/RELEASING.md#the-reserved-unscoped-name). ### Added - **GitHub Packages has its first copy.** `@openpredicate/open-predicate@0.6.1` is the first version to reach it; npmjs.com had been the only registry carrying anything. Installing from it still needs an `.npmrc` and a token even though it is public, so npmjs.com remains the easier path. - **0.6.1 is the first release with provenance.** Published over OIDC from the workflow rather than from a laptop, so the tarball carries a SLSA v1 attestation linking it to the run and commit that built it. Verify with `npm view @open-predicate/open-predicate@0.6.1 dist.attestations`. - **The project has a written governance and contribution process.** [`GOVERNANCE.md`](./GOVERNANCE.md) states how a decision is made and what it costs — editorial, substantive-compatible, or normative, where normative requires a record under [`decisions/`](https://github.com/OpenPredicate/open-predicate/tree/main/decisions), a migration note and a `$id` bump. It sets out how a disputed design call is resolved (answered in writing, then a decision record quoting the objection in the objector's words, then 14-day lazy consensus, then the editor decides **and the dissent is recorded in the record**), what earns commit rights, and an explicit royalty-free patent posture — MIT settles copyright and says nothing about patents, which is the first thing an adopter's lawyer looks for. It also states that there is currently one maintainer and calls that a defect rather than a design. [`CONTRIBUTING.md`](./CONTRIBUTING.md) leads with the thing the README already says is most useful — *disagreement* — and makes the three entry points concrete: file a design objection, build an implementation, or claim conformance for a library that already exists. It is candid that `tests/fixtures/` checks schema well-formedness only, has no records or expected results, uses an ajv-flavoured `expectKeyword`, and is excluded from the published package; and it names promoting `experiments/filter-to-sql/cases.mjs` (73 cases over 10 records, already shaped `{group, id, title, filter, expect}`) into a portable conformance suite as the highest-value contribution currently available. Also added: [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md), which makes explicit that blunt technical disagreement is welcome and is not incivility, and discloses that a report about the sole maintainer has nowhere independent to go inside the project; [`SECURITY.md`](./SECURITY.md), which defines what a vulnerability even means for a specification — a rule that makes conforming implementations unsafe, a filter that stays inside the §7 limits and is still superlinear, or any path where a predicate ends up dropped, widened or truncated, since that is an authorization bypass wherever filters carry tenancy; [`SUPPORT.md`](./SUPPORT.md); three issue forms; and a pull-request template whose checklist is tied to the invariants the tests already enforce. Discussions and private vulnerability reporting are enabled on the repository. - **The schema is served from its `$id`.** `https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json` now returns the file it identifies, as `application/schema+json`, with `Access-Control-Allow-Origin: *` so browser-based tooling can fetch it and `Cache-Control: public,max-age=31536000,immutable` because a versioned `$id` never changes (§9). The served bytes are identical to [`open-predicate-schema.json`](./open-predicate-schema.json) in this repository. This closes what the README called the one piece of remaining work. The `$ref`-by-URL workflow that [Using it from OpenAPI](./README.md#using-it-from-openapi) and the capability document examples are written around now describes today rather than an intended end state, and the five RFC 9457 problem types under `https://openpredicate.tech/problems/` dereference as well. There is deliberately **no** unversioned or `latest` schema URL; both 404. Serving it is a second repository's job, so a release is not finished when the tag is pushed — [`OpenPredicate/openpredicate.tech`](https://github.com/OpenPredicate/openpredicate.tech) vendors the artefacts and has to be synced at the tag. That step is now written down in [`RELEASING.md`](https://github.com/OpenPredicate/open-predicate/blob/main/RELEASING.md#serving-the-schema-from-its-id). - **Publishing is back on, and npmjs.com authenticates by OIDC.** [`.github/workflows/release.yml`](https://github.com/OpenPredicate/open-predicate/blob/main/.github/workflows/release.yml) publishes to both registries when a GitHub Release is published. The npmjs job uses npm's [trusted publishing](https://docs.npmjs.com/trusted-publishers): it requests `id-token: write` and npm exchanges that for a short-lived credential, so there is **no `NPM_TOKEN` secret** in this repository and nothing to rotate. Provenance is attached automatically, linking each tarball to the workflow run and commit that built it — though only for tarballs the workflow publishes, so 0.6.0 has none: its bootstrap publish came from a laptop, which is the one publish OIDC cannot do. 0.6.1 is the first over OIDC. GitHub Packages stays token-authenticated — it has no OIDC equivalent — but `GITHUB_TOKEN` is minted per run and expires with it. `.github/scripts/version-published.sh` is restored alongside, so each job skips a version it has already published and a partially-failed release can be re-run safely. **The trust is pinned to the repository and to the workflow filename.** Renaming `release.yml` breaks publishing until the trusted publisher is updated to match. **npm cannot mint a package's first version over OIDC**, because a trusted publisher can only be attached to a package that already exists. Claiming `@open-predicate/open-predicate` was therefore a one-time manual publish, documented along with everything it cost to get right in [`RELEASING.md`](https://github.com/OpenPredicate/open-predicate/blob/main/RELEASING.md#trusted-publishing-and-the-one-time-bootstrap) — including that *configuring* the trusted publisher needs npm >= 12, which fails with an unexplained `E400` on npm 11 because the older client omits the `permissions` field the registry now requires. - **`@open-predicate/open-predicate` is on npmjs.com**, public and installable, from `0.6.0` on. The package is the schema: `require()` it, or `import` it with `{ type: 'json' }`. The trusted publisher is configured, so every release from here is published by the workflow rather than by hand. GitHub Packages got its first copy with 0.6.1. ## [0.6.0] — 2026-09-13 **A naming release.** No change to the grammar or to the semantics of evaluation: `open-predicate-schema.json` is byte-identical to 0.5.0 apart from three lines — its `$id`, its `title` and its `$comment` — and the `$id` still names `v0.4.0`, because the `$id` version tracks the grammar and the grammar did not move. Consumers pinning that `$id` have only the new namespace to re-point at. ### Changed - **The name is settled, and the project has a dedicated organisation.** **OpenPredicate** is stewarded by the [OpenPredicate](https://openpredicate.tech) organisation, whose purpose is to carry the grammar to an open standard and push for its adoption. This is the single pass the README promised: the repository, both package names, the schema `$id`, the problem-type URIs, the CLI and the vendor keyword all derive from the one namespace, so nothing is left half-named. | | Name | | --- | --- | | Repository | [`OpenPredicate/open-predicate`](https://github.com/OpenPredicate/open-predicate) | | Schema file | `open-predicate-schema.json` | | Schema `$id` | `https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json` | | Problem types | `https://openpredicate.tech/problems/…` | | npm package | `open-predicate` | | GitHub Packages | `@openpredicate/open-predicate` | | CLI | `open-predicate-generate` | | Vendor keyword | `x-open-predicate` | | Generator config | `open-predicate.config.json` | **Migration.** Mechanical, and only for identifiers — no filter valid before this release becomes invalid, because the grammar and the evaluation semantics did not move. In a resource schema, the vendor keyword the generator reads is `x-open-predicate`; any other spelling is silently ignored, so a field you meant to exclude would become queryable. Point any `$ref` or pinned `$id` at the `$id` above, and any RFC 9457 `type` matching at the problem base above. A generator config file is `open-predicate.config.json`, or pass it explicitly with `--config`. The `$id` still names `v0.4.0` — the version tracks the grammar, which is unchanged; only the namespace it sits under is new. Nothing was ever served or published under any other namespace or package name, so no working deployment can be pinned elsewhere. **On the entries below.** Past releases are written up in these names, so the whole document reads in one vocabulary. Released artefacts are unaffected — this names the project, not history. ### Added - **Brand assets, in [`assets/`](https://github.com/OpenPredicate/open-predicate/tree/main/assets/).** The `{ > }` mark — JSON braces around a comparison — as SVG and as raster at three sizes, plus a wordmark for light and dark backgrounds. [`assets/README.md`](https://github.com/OpenPredicate/open-predicate/blob/main/assets/README.md) states the palette and the usage rules. The README now opens with the mark. MIT-licensed with the rest of the repository. ## [0.5.0] — 2026-09-13 **A tooling release.** No change to the grammar or to the semantics of evaluation: `open-predicate-schema.json` is byte-identical to 0.4.0 apart from its root `description`, and its `$id` still names `v0.4.0`, because the `$id` version tracks the grammar and the grammar did not move. Consumers pinning that `$id` have nothing to do. What did move is the generator, which is now the thing an API provider uses when implementing search: point it at the resource schema, state the slice of the language you can actually serve, and get back a filter schema that permits exactly that slice plus a capability document that describes it honestly. It also travels with the package for the first time, as a `bin` named `open-predicate-generate`, rather than being a file inside a repository nobody installs. [SPEC.md](./SPEC.md) gains two clarifications in service of that, both about what an implementation may *claim* rather than about what a filter means: [§2.1](./SPEC.md#21-profiles) says out loud that accepting part of a profile is permitted and advertising it is not, and [§2.2](./SPEC.md#22-capability-discovery) documents the capability document's top-level members. No filter valid under 0.4.0 becomes invalid. ### Added - **The generator selects capabilities, not just profiles** ([#11](https://github.com/OpenPredicate/open-predicate/issues/11)). Profiles are the unit a server advertises, but three shapes do not fit inside one: a backend with `LIKE` and no `POSITION` supports `$like` and not `$contains`; a key-value store cannot implement `$exists` at all; a provider compiling to a flat conjunctive index wants one AND level and no shorthand. Six new knobs, each available as a flag and as a JS API option — `--operators`, `--drop-operators`, `--no-shorthand`, `--max-filter-depth`, `--limits`, and `--config` to hold the combination. What is declined is absent from the generated schema, so a client learns it from validation rather than from an `unsupported-operator` at runtime. Defaults are unchanged: with none of them given the output is byte-identical to before. - **`--config `, and `examples/pet.open-predicate.config.json`.** The capability selection is a decision about the endpoint, not a shell invocation, so it goes in a JSON file checked in beside the resource schema and regenerated from. Its keys are the JS API's option names plus `resource`, `out` and `capabilities`; relative paths in it resolve against its own directory, an explicit flag beats it, and an unrecognised key is refused rather than ignored — a misspelled key is a capability that silently did not apply. `npm run generate:example` now runs through one. - **`--max-filter-depth `** caps how deep `$and`/`$or`/`$nor`/`$not` may nest: `1` is a flat filter offering no logical operators at all, `2` permits one level of them. JSON Schema cannot count how deep an instance already is, so the filter is emitted as a chain of levels: level *i* offers the logical operators over level *i+1* and the last level does not offer them at all. Every level shares the operand `$defs`, so the cost is *n* copies of a map of `$ref`s. Field-level `$not` is bounded to a single application by the same flag — under Kleene logic `¬¬X ≡ X` even for UNKNOWN, so a negated negation says nothing the plain constraint does not. - **`--limits `** puts the SPEC §7 numbers a provider actually enforces into the capability document. They were emitted unconditionally, so every document generated from the CLI claimed `maxDepth: 10, maxClauses: 100, maxSetLength: 1000` whether or not that was true. Where `--max-filter-depth` is given and `maxDepth` is not, the enforced bound is published. - **The generator is part of the package.** `tools/` was not in `package.json` `files` and there was no `bin` entry, so the tool the README points readers at could not travel with the package at all. It is now a `bin` named `open-predicate-generate`, with `@open-predicate/open-predicate/generate` exporting `generateFilterSchema` for programmatic use. At the time of this release the repository still published no artifacts, so the command was reachable from a clone or a git install and not from npmjs; what changed here is that it was ready to be, and `npm pack` contained it. Publishing arrived in 0.6.0. - **[SPEC.md §2.2](./SPEC.md#22-capability-discovery) documents the capability document's top-level members** — `queryLanguage`, `profiles`, `fields`, `limits` and `filterSchema` — in a table beside the existing per-field one. `limits` appeared in the example and in no table, and `filterSchema`, `itemValues` and `nullable` were emitted by the generator and described nowhere. No normative change to what the members mean. - **[SPEC.md §2.1](./SPEC.md#21-profiles) says what a partial profile may and may not do.** The rule was already there — a profile other than `core` is implemented in full or not at all — but it read as a prohibition on the implementation rather than on the advertisement. An endpoint accepting part of a profile is not prohibited from existing; it states what it accepts per path and omits the incomplete profile from `profiles`. - **`examples/mcp-server/`** — a runnable MCP server whose one tool, `search_pets`, takes a filter as its `filter` argument and nothing else. The tool's `inputSchema` is `examples/pet.filter.json` inlined verbatim; validation is ajv against that same file, and execution is the SQL compiler from `experiments/filter-to-sql` over an in-memory SQLite table, so the queries are real. `node examples/mcp-server/demo.mjs` drives it over stdio and prints a transcript: two filters that answer, one that shows the `$unknownAs` difference (4 matches against 7), and the three valid-but-wrong filters from README §*Exposing search to an agent* being rejected with a pointer at the clause. `npm run example:mcp` and `npm run example:mcp:demo` are the entry points. - The example is also the first place the `$id`-when-inlining hazard is written down: nested under `properties.filter`, a bundled schema's self-references resolve against its own `$id`, so removing the `$id` breaks it — ajv fails to compile it at all. ### Changed - **The capability document's `profiles` reports coverage rather than the request.** It echoed whatever `--profiles` was given; it now lists only the profiles the final operator set covers in full, because [SPEC.md §2.1](./SPEC.md#21-profiles) makes a partial profile one an implementation may not advertise. `--drop-operators '$contains'` therefore costs the `strings` claim, and the per-field `operators` lists carry what is on offer instead — with a warning on stderr naming the operator responsible. Declining a `core` operator drops `core` too, and warns that the result is not a conforming implementation. Nothing changes for a selection that is whole profiles, which is every invocation before this release. - **Positioned as one JSON-Schema-described query language with two integration points**, rather than as an agent interface. An earlier revision in this same unreleased window led with the MCP tool definition and moved §*Exposing search to an agent* ahead of the OpenAPI and generator sections; that ordering is reverted and the "search interface for agents" framing is gone from the README, the `package.json` description and the repository description. The agent use case keeps its section and its runnable server — it is one of the two things the schema is for, not the thing the document opens with. - **The schema's root `description`** likewise leads with the shared-grammar framing again, and mentions inlining as a tool's input schema second. Non-normative prose; no validator behaviour changes. - **The error format is no longer mandated.** [SPEC.md §8](./SPEC.md#8-errors) required [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details with media type `application/problem+json`. It now requires only that a rejected filter be answered with `400 Bad Request` and that the response say **which** of the five conditions applies — `malformed-query`, `unknown-field`, `unsupported-operator`, `invalid-operand`, `query-too-complex` — because that is what a client branches on. The envelope is the API's own: an API with an established error format should express these conditions in it rather than carry a second format for one endpoint. RFC 9457 remains the RECOMMENDED default where there is none, and the `type` URIs, the `pointer` member and the recovery members (`queryableFields`, `accepted`) are unchanged as its encoding. This relaxes a requirement, so nothing that conformed before stops conforming. ### Fixed - **`--max-depth` accepted a value that was not a number.** It was coerced with `Number()` and never checked, so `--max-depth deep` became `NaN` and silently stopped the walk at the first nested object. It and `--max-filter-depth` are both validated now. - **An operator whose dependency was dropped is dropped with it.** `$flags` carries `dependentRequired: ["$regex"]` out of the grammar, so `--drop-operators '$regex'` would have left `$flags` in `properties` with a rule naming a member `additionalProperties: false` forbids — present in the schema and impossible to use. The closure is read off `$defs/ConstraintObject`, so a dependency added later is handled by construction. - **Generated filter schemas were not a narrowing** ([#8](https://github.com/OpenPredicate/open-predicate/issues/8)). `tools/generate-filter-schema.mjs` carried the published constraint object's `dependentRequired` rule but not its `dependentSchemas` one, so `{"microchip": {"$unknownAs": false}}` — a modifier with nothing to modify — passed a generated schema while `open-predicate-schema.json` rejected it. A server following the documented path (generated schema as the tool's `inputSchema`, published semantics behind it) then had to evaluate a filter with no predicate in it; the SQL compiler in `experiments/filter-to-sql` emitted `coalesce((), FALSE)` and the database answered with a syntax error. The generator now reads both dependency keywords off `$defs/ConstraintObject` instead of restating either, so a rule added there reaches generated schemas with the version that introduces it, and `examples/pet.filter.json` is regenerated: 12 of its 18 constraint objects gain the rule — the ones offering `$unknownAs`, which is every field that can be absent or null. The `$comment` justifying the rule is deliberately not copied along with it: a validator never reads it, and one copy per field is charged by the token to whoever inlines the schema in a tool definition. No change to the grammar — this is the generator agreeing with it. - **The narrowing property is now tested as a property.** `tests/generator.test.mjs` asserted it over a hand-written list of fifteen filters, which can only re-check the leaks someone already thought of — the keyword above was dropped for as long as the list existed. It now samples filters out of each generated schema's own vocabulary (`tests/fuzz.mjs`, seeded, deterministic) and asserts that every one the generated schema accepts is valid OpenPredicate, over three generated schemas; the run is checked for not being vacuous, in that it must accept a fraction of its samples and must reach every operator the schema offers. A second test pins what the generator does with each instance-constraining keyword of `$defs/ConstraintObject`, so adding one there fails the suite until it is handled. - **`experiments/filter-to-sql` rejects a constraint object with no predicate in it** rather than emitting an empty expression — `malformed-query`, at the pointer of the offending clause. Both schemas already reject these, so this only matters for a compiler reached another way, but the failure it replaces was a `500` from the database. - The prose in [README §Errors](./README.md#errors), [COMPARISON.md §4](./COMPARISON.md), the OpenAPI examples and `experiments/filter-to-sql` follows: they now describe Problem Details as the recommended shape rather than the required one, and name the failing *condition* where they previously said "problem". The examples still model RFC 9457, since it is still the default a greenfield API should pick. ## [0.4.0] — 2026-09-07 **Breaking.** The `$id` is now `…/v0.4.0/open-predicate-schema.json`. This release resolves the three operator overlaps that an external review and this repository's own `experiments/filter-to-sql` flagged independently; the design and the evidence are in [`decisions/0001-array-quantifiers-and-unknown-handling.md`](https://github.com/OpenPredicate/open-predicate/blob/main/decisions/0001-array-quantifiers-and-unknown-handling.md). The headline is that the language had **two** unrelated mechanisms for looking inside an array — `$elemMatch` and the `[*]` path segment — and one mechanism now does both jobs while naming its quantifier. Operator count is unchanged at 34. ### Added - **`$some` and `$every`** (profile `collections`), the element quantifiers. Each takes a `Filter` when the elements are objects — paths inside resolve against the element — or a constraint object when they are scalars. `$some` is `$elemMatch` renamed; `$every` is new, because universal quantification over elements was **not previously expressible**: `$not` over `$some` is "no element matches", which is a different predicate. - **`$unknownAs`** (profile `core`), a boolean modifier on a constraint object that resolves that constraint's UNKNOWN. `{"status": {"$ne": "archived", "$unknownAs": true}}` is the one-clause form of the `$or`/`$isNull` longhand this specification prescribed before. It applies last — after every sibling operator, including a field-level `$not` — and [SPEC.md §4.6](./SPEC.md#46-resolving-unknown--unknownas) gives the scope rules and the nine-case proof that resolution distributes over three-valued AND. It requires at least one operator beside it. - **A truth-table column for `$nor`** in §4.1, and a note that all three connectives are commutative so the six rows cover all nine combinations. `$nor`'s three-valued result previously had to be derived, and the derivation was the trap. - **`$every` on generated schemas**, and `$unknownAs` on exactly the fields where UNKNOWN is reachable — the same rule the generator already applied to `$exists` and `$isNull`. On a property that is required all the way up and cannot hold null, the modifier would be a constant, so it is omitted and the trap disappears from the tool definition entirely. ### Removed — breaking - **`$elemMatch`.** Renamed to `$some`. Mechanical: `{"items": {"$elemMatch": {…}}}` → `{"items": {"$some": {…}}}`. - **`$hasAny` and `$hasNone`.** Both were compositions of a quantifier and `$in`, and their presence beside whole-value `$in` was the whole `$in`-versus-membership confusion. `{"tags": {"$hasAny": ["a"]}}` → `{"tags": {"$some": {"$in": ["a"]}}}`; `{"tags": {"$hasNone": ["a"]}}` → `{"tags": {"$not": {"$some": {"$in": ["a"]}}}}`. - **The `[*]` wildcard path segment**, from the §3.2 grammar. It expressed nothing the equivalent `$some` clauses do not: per-constraint existential scope is exactly what an `$and` of *separate* `$some` clauses means. `{"items[*].qty": {"$gt": 2}}` → `{"items": {"$some": {"qty": {"$gt": 2}}}}`. Three further reasons it went: living in the path grammar made it the only construct present in **every** profile including `core`, so no server could decline it; it contradicted §4.2 by revoking `$exists`'s totality; and it cost 1.74× the SQL of the equivalent `$elemMatch` plus a table-valued join per clause. The schema now rejects a `[*]` path outright, including in `$field` position, so a stale filter is a validation error rather than a path read as a literal key name. - **`$defs/ScalarSet`.** `$in` and `$nin` now take `$defs/OperandSet`, the same set definition the collection operators use. The two definitions had silently diverged — `$hasAny` accepted `$field` references and object members while `$in` accepted only scalars — with nothing in the specification acknowledging it. The unification is toward the permissive side, so no filter that was valid becomes invalid. ### Changed — breaking - **A type-mismatched equality is FALSE, not UNKNOWN.** §4.3 said comparing different JSON types yields UNKNOWN; §5.1 defined `$eq` as structural equality, under which a string and a number are simply unequal. The two readings are indistinguishable under `$eq` and differ under `$ne`, and the specification asserted both. It is now settled as **FALSE for the equality family** (`$eq`, `$ne`, `$in`, `$nin`, `$hasAll`) and UNKNOWN for ordering, string and array operators, with a table in §4.3. **This changes result sets without changing any filter's shape**, so a mechanical rewrite will not surface it: `{"notes": {"$ne": 3}}` now matches a record whose `notes` is `"hello"`. - **An empty array under a former wildcard clause.** `[*]` on `[]` was UNKNOWN, because the path resolved to nothing; `$some` on `[]` is FALSE, because an empty array is a resolved value and nothing in it satisfies the condition. `$every` on `[]` is TRUE, vacuously. Observable under negation only, and it is the one migration step a codemod cannot claim to preserve. - **§3.4 resolution is single-valued.** With no wildcard segment, a path yields zero values or exactly one. The sequence model is gone. - **Six operator descriptions that contradicted §4.1.** These strings are vendored verbatim into generated schemas and MCP tool definitions, so they were a first-order cause of the confusion rather than a cosmetic issue. `$nor`'s was outright wrong — "None of the listed filters may evaluate TRUE" is the two-valued reading — and `$ne`'s said only "Field does not equal the operand". `$nin`, `$nbetween`, `$nlike` and `$nilike` all read as total predicates. Every negative operator now states what it does with UNKNOWN. - **§1 no longer calls the filter "a boolean function"** while §4.1 makes it three-valued. - **`$exists` is documented as unconditionally total.** It always was, except under a wildcard path; with those gone the exception is gone. - **§3.5 settles whether an index suffix is a separate path.** It is not: `items[0]` is the field `items` for queryability, while a named member beneath it (`items[0].sku`) is its own path. - **§7 addresses quantifier cost.** `$some` and `$every` are the expensive operators on most backends, and a server that cannot afford them can decline the `collections` profile — which is precisely what the `[*]` segment made impossible. ### Fixed - **`$some`/`$every`'s operand shape is no longer ambiguous.** `anyOf: [Filter, ConstraintObject]` overlaps on a leading `$not`, and nothing said which was meant. §5.8 now gives a decidable rule: scan for the first member that can only be one of the two, recursing through `$not`/`$and`/`$or`/ `$nor` bodies when the outer member is itself ambiguous. - **`$field` inside a quantifier resolves against the element**, stated in §5.8 and §5.11. §5.11 said "the same record" while §5.8 said paths were element-relative; both readings were defensible. ## [0.3.1] — 2026-09-04 No change to the schema, the grammar or the semantics. `open-predicate-schema.json` is byte-identical to 0.3.0 and its `$id` still names `v0.3.0`, because the `$id` version tracks the grammar and the grammar did not move. Consumers pinning that `$id` have nothing to do. ### Removed - **Publishing.** The release workflow no longer ships to npmjs.com or GitHub Packages. Neither registry ever received a copy, and neither should before the name is final: publishing claims a name, and npm blocks a name from reuse permanently once it has been published and unpublished. `.github/workflows/release.yml` now only verifies a release — the test suite, and the tag-against-`package.json` check — and uploads nothing. The `NPM_TOKEN` secret and `.github/scripts/version-published.sh` are deleted with it. [RELEASING.md](https://github.com/OpenPredicate/open-predicate/blob/main/RELEASING.md#turning-publishing-back-on) keeps what the jobs needed, so they can be restored from git history rather than rewritten. ### Changed - **README no longer offers an install that does not exist.** The Quickstart opened with `npm install --save-dev open-predicate`, which the README's own *Status* table already contradicted two screens further down. It now vendors the file by `curl`, which is the only way to obtain the schema and always was. - **[RELEASING.md](https://github.com/OpenPredicate/open-predicate/blob/main/RELEASING.md) documents the process that exists** — a tag and a GitHub Release, carrying notes and a source snapshot and nothing else. ## [0.3.0] — 2026-09-04 Guidance for adopters exposing a search endpoint to an LLM agent, the tooling that acts on it, and an honest statement of how finished this is. No grammar change: every filter valid under v0.2.0 remains valid, and the only edits to `open-predicate-schema.json` are two `description` annotations and its version strings. ### Added - **Per-field domains in the capability document.** [SPEC.md §2.2](./SPEC.md#22-capability-discovery)'s RECOMMENDED shape now carries `type`, `format`, `values` and `description` alongside `operators`, with a table defining each. The grammar cannot express per-field operand domains — every path shares one `Constraint` — so a filter naming a real field with an out-of-domain value is well-formed and matches nothing. The capability document is the only place that domain can be stated. - **Recovery members on problem details.** [SPEC.md §8](./SPEC.md#8-errors) now RECOMMENDS that `unknown-field` carry `queryableFields` and that `invalid-operand` carry `accepted`, so a client that never fetched the capability document can still converge in one round trip instead of guessing field names one at a time. - **README §*Exposing search to an agent*** — what reaches a tool definition, the three valid-but-wrong filters that fail as an empty result set, and the five steps that prevent them (bundle rather than remote-`$ref`, narrow `FieldPath`, publish value domains, trim to advertised profiles, state the null and `$in` semantics in the tool description). - **`tools/generate-filter-schema.mjs`** — derives a per-resource filter schema from the resource's own JSON Schema. The published grammar shares one `Constraint` across every field, so it can say `{"status": "Available"}` is well-formed but not that `"Available"` is outside `status`'s domain; that is why [SPEC.md §2.2](./SPEC.md#22-capability-discovery) exists. A generated schema gives each queryable path its own constraint subschema, carrying only the operators that apply to its type and only the operands its domain admits — so the three valid-but-wrong filters catalogued in README §*Exposing search to an agent* become validation failures instead of empty result sets. The generator emits the §2.2 capability document from the same source, and copies operator prose out of the published grammar rather than restating it. Generation is narrowing only: every filter a generated schema accepts is valid against the published grammar, which `tests/generator.test.mjs` asserts. - **`COMPARISON.md`** — how this specification relates to GraphQL, and what a JSON-Schema-native alternative to GraphQL would still need. The short version: GraphQL never standardised filtering, so the two overlap far less than the question assumes. Also covers OData, JSON:API, OGC CQL2 and JSON Hyper-Schema as prior art. - **`examples/pet.schema.json`** with its generated `pet.filter.json` and `pet.capabilities.json` committed beside it, and `npm run generate:example` to refresh them. A test fails if they drift. - **README §*Generating a per-resource filter schema*** — what the generator decides and why, and the `x-open-predicate` property annotations that override it. ### Changed - **README framing.** The schema is presented as feeding two integration paths rather than one: `$ref`'d from an OpenAPI document, or bundled into an MCP tool's `inputSchema`. The *Referencing by URL or by copy* table gains an `MCP inputSchema` row recording that the absolute-URL form does not work there at all, since nothing on that path resolves remote refs. - `$in` and `$nin` descriptions now state that they compare the whole value and do not test array membership, naming `$hasAny`/`$hasNone` as the element operators. `$contains` already warned about the same crossover; these two did not, and they are the operators a client carrying MongoDB habits reaches for first. - **`$id` is now `…/v0.3.0/open-predicate-schema.json`.** Consumers pin by `$id`, so the version in the path moves with the release. `SPEC.md`, the OpenAPI examples and the generated capability document were all still naming v0.2.0; they now agree. - **README §*Status* states that this is a work in progress, name included.** The name is not final, and every identifier downstream of it — both package names, the `$id`, the URLs in the integration examples — is a placeholder, several of which do not resolve. Getting them right is deliberately deferred until the name is settled, because a rename moves all of them at once. A notice at the top of the README says the same thing before a reader reaches an install command that will not work. - **`QUERY` now cites [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)** rather than `draft-ietf-httpbis-safe-method-w-body`. The method reached Proposed Standard in June 2026. The advice to ship `POST /search` alongside it is unchanged, but the reason is now that deployed support trails a fresh RFC, not that the specification is unsettled. ## [0.2.0] — 2026-08-06 A structural rewrite. The v0.1.0 file described a grammar but did not enforce one; this release makes it a working schema, fixes the grammar's dead ends, and completes the operator set. Filters written against v0.1.0 still parse apart from the `$isnull` rename. ### Fixed - **The schema validated nothing.** The root used `"id"` (a draft-04 spelling) rather than `"$id"`, and wrapped its definitions in `components.schemas`, which is an OpenAPI container and not a JSON Schema keyword. Under draft 2020-12 both were unknown keywords, and the root carried no assertion keywords at all — so a validator pointed at the file accepted every instance. The root now `$ref`s `#/$defs/Filter`. - **`"regex"` is not a JSON Schema keyword** (it is `"pattern"`), and the value it carried — `"['\"%?.+%?['\"]"` — was a malformed character class that also expected quote characters inside the operand. `$like` and `$nlike` are now plain strings; the wildcard and escape grammar is specified in prose ([SPEC.md §5.5](./SPEC.md#55-pattern-matching--like-nlike-ilike-nilike)) where it belongs. - **`examples` was an object** throughout, in the OpenAPI Example-Object style, where JSON Schema requires an array of instance values; two schemas used the OpenAPI 3.0 singular `example`. Both spellings are now correct, and a test walks the whole document to keep them that way. - **Operators from different families could not be combined on one field.** The eight-way `oneOf` over leaf condition types meant `{"age": {"$gt": 18, "$ne": 30}}` matched no branch and was rejected. Sibling operators now AND together. - **Ambiguous and empty forms were accepted or accidentally rejected.** `{}` matched all eight leaf branches at once; `{"$and": […], "$or": […]}` was accepted with no defined semantics; `$and: []` and `$in: []` were accepted. Empty forms are now rejected, and implicit AND across siblings is specified. - **`$in`/`$nin` excluded booleans and `null`** while `$eq` allowed them. ### Changed — breaking | v0.1.0 | v0.2.0 | Note | | --- | --- | --- | | `"id": "…/v0.1.0"` | `"$id": "…/v0.2.0/open-predicate-schema.json"` | Correct keyword, versioned path | | `#/components/schemas/Query` | `#/$defs/Filter` | Or `$ref` the file itself | | `#/components/schemas/Condition` | *(removed)* | Folded into `#/$defs/Filter` | | `#/components/schemas/equalCondition`, `notEqualCondition`, `inArrayCondition`, `notInArrayCondition`, `likeCondition`, `notLikeCondition`, `rangeCondition`, `isNullCondition` | *(removed)* | Folded into `#/$defs/ConstraintObject` | | `$isnull` | `$isNull` | Renamed for consistency with `$startsWith` &c. | Any OpenAPI document referencing a `#/components/schemas/…` pointer must be repointed. Filter *documents* need no change other than `$isnull` → `$isNull`; the v0.1.0 examples are kept as test fixtures to prove it. ### Added - **Operators.** `$nor`; `$nbetween`; `$ilike`, `$nilike`, `$startsWith`, `$endsWith`, `$contains`; `$regex` with `$flags`; `$exists`; `$type`; `$hasAny`, `$hasAll`, `$hasNone`, `$size`, `$elemMatch`; `$search`; a field-level `$not`. - **Field-to-field comparison** via `{"$field": "path"}` in operand position — SQL's `WHERE price > cost` — with `{"$literal": …}` as the escape for object operands that would otherwise read as references. - **Scalar shorthand.** `{"status": "open"}` for `{"status": {"$eq": "open"}}`. Restricted to strings, numbers, booleans and `null`, so `{"tags": ["a"]}` can never be read ambiguously. - **`null`, arrays and objects as `$eq`/`$ne` operands.** - **A field path grammar** — dotted paths, array indices, `[*]` wildcards, `\.` dot escaping — and a rule for field names beginning with `$`: they are escaped by doubling (`$$price`). A single `$` prefix that is not a known operator is now rejected, so `$eqq` is an error rather than a field name. - **Conformance profiles**, published in the schema as `x-profiles` and specified in [SPEC.md §2.1](./SPEC.md#21-profiles), so a server can advertise the subset it implements. - **One override point for the queryable field set**, `#/$defs/FieldPath`, reached through `propertyNames` so that narrowing it in a bundled copy applies at every nesting level. - **[SPEC.md](./SPEC.md)** — three-valued logic, missing-versus-null, coercion rules, per-operator semantics, safety limits, and an RFC 9457 problem-type registry. - **[README.md](./README.md)** — operator reference and OpenAPI 3.1 / 3.2 integration guidance. - **Tests and CI** — ajv under `strict: true`, 30 valid and 22 invalid fixtures, and Redocly linting of both OpenAPI examples. ### Notes `$dynamicRef`/`$dynamicAnchor` was evaluated as a way to let consumers restrict the queryable field set without copying the file, and rejected: ajv 8.20 does not resolve it correctly even for the canonical recursive case, and OpenAPI tooling support is worse. The `$ref`-based override described in the README works in every validator. ## [0.1.0] — 2025-02-05 Initial research draft: `$and`, `$or`, `$not` over eight leaf condition types (`$eq`, `$ne`, `$in`, `$nin`, `$like`, `$nlike`, `$gt`/`$gte`/`$lt`/`$lte`/`$between`, `$isnull`), laid out as an OpenAPI `components.schemas` fragment. [0.6.2]: https://github.com/OpenPredicate/open-predicate/compare/v0.6.1...v0.6.2 [0.6.1]: https://github.com/OpenPredicate/open-predicate/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/OpenPredicate/open-predicate/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/OpenPredicate/open-predicate/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/OpenPredicate/open-predicate/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/OpenPredicate/open-predicate/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/OpenPredicate/open-predicate/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/OpenPredicate/open-predicate/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/OpenPredicate/open-predicate/releases/tag/v0.1.0