@open-predicate/open-predicate 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,483 @@
1
+ <img src="./assets/logo.svg" alt="" width="76" />
2
+
3
+ # OpenPredicate
4
+
5
+ A JSON-encoded, SQL-flavoured **predicate language**, described by a single JSON Schema — `$ref` it from an OpenAPI document, or inline it into an MCP tool's `inputSchema`.
6
+
7
+ Write the filter grammar once. Use it for every `POST /…/search` and `QUERY /…` operation in your API, and for every search tool you expose to an agent. Clients learn one language instead of one ad-hoc query syntax per endpoint — and so do models.
8
+
9
+ ```json
10
+ {
11
+ "$and": [
12
+ { "status": "available" },
13
+ { "$or": [
14
+ { "species": { "$in": ["cat", "dog"] } },
15
+ { "tags": { "$some": { "$in": ["rescue", "senior"] } } }
16
+ ]},
17
+ { "born": { "$gte": "2020-01-01" } }
18
+ ]
19
+ }
20
+ ```
21
+
22
+ One schema, two integration points, because JSON Schema is what both already speak: it is the interchange format of OpenAPI 3.1, and it is what an MCP `inputSchema` is.
23
+
24
+ - **Schema** — [`open-predicate-schema.json`](./open-predicate-schema.json) (JSON Schema draft 2020-12)
25
+ - **Semantics** — [`SPEC.md`](./SPEC.md) — nulls, paths, coercion, errors, limits
26
+ - **MCP server** — [`examples/mcp-server/`](./examples/mcp-server) — a search tool with the language as its `inputSchema`, runnable
27
+ - **OpenAPI documents** — [`examples/`](./examples) — working 3.1 (`POST /…/search`) and 3.2 (`QUERY`) integrations
28
+ - **Generator** — [`tools/generate-filter-schema.mjs`](./tools/generate-filter-schema.mjs) — turns a resource's JSON Schema, plus the slice of the language you can serve, into a per-field filter schema
29
+ - **Compared with GraphQL** — [`COMPARISON.md`](./COMPARISON.md) — what this overlaps with, what it does not, and what a JSON-Schema-native alternative would still need
30
+ - **Stewardship** — the [OpenPredicate](https://openpredicate.tech) organisation — see [About OpenPredicate](#about-openpredicate)
31
+ - **Version** — `0.5.0`. The schema's `$id` still names `v0.4.0`: the `$id` tracks the grammar, and 0.5.0 changed only the tooling and what it may claim. See [`CHANGELOG.md`](./CHANGELOG.md) for this release, and [`decisions/0001`](./decisions/0001-array-quantifiers-and-unknown-handling.md) for the v0.4.0 migration.
32
+
33
+ > **Work in progress — but no longer in name.** This is a design published for review, not a distribution you can depend on yet. The name is now settled: *OpenPredicate*, stewarded by the [OpenPredicate](https://openpredicate.tech) organisation, with every identifier derived from it — the repository, both package names, the schema `$id`, the problem-type URIs — fixed in the one pass this README used to promise. What remains provisional is *availability*, not naming: the schema is not yet served from `openpredicate.tech` and nothing is published to a registry. The grammar and its semantics are the part worth reviewing. See [Status](#status) before you try to install or `$ref` anything.
34
+
35
+ ---
36
+
37
+ ## Why
38
+
39
+ Search endpoints attract bespoke query syntaxes. Each one arrives as an opaque string parameter (`?q=status:open AND born>2020`) that no schema can validate, no generator can type, and no client can build safely. Structuring the query as JSON changes that: it can be described by a JSON Schema, and a JSON Schema is the interchange format both of today's API description layers already speak. From OpenAPI it validates in CI, appears in generated docs, and produces real types in generated clients. As an MCP tool's `inputSchema` it becomes the contract an agent writes filters against, with the operator `description`s carried along as the instructions.
40
+
41
+ Confining the schema to the *predicate* — no projection, ordering or pagination — is what makes it reusable. Those parts differ per API; the filter does not.
42
+
43
+ ## The two rules worth learning first
44
+
45
+ **Sibling members AND together.** At every level.
46
+
47
+ ```json
48
+ { "department": "sales", "age": { "$gte": 18 } }
49
+ ```
50
+ is `department = 'sales' AND age >= 18`. That holds for operators on one field too — `{"age": {"$gt": 18, "$ne": 30}}` is a single valid constraint.
51
+
52
+ **A bare scalar means equality.** `{"status": "open"}` is shorthand for `{"status": {"$eq": "open"}}`. The shorthand covers strings, numbers, booleans and `null` only; arrays and objects must use an explicit operator, so `{"tags": ["a"]}` can never be misread as either `$eq` or `$in`.
53
+
54
+ ## Operator reference
55
+
56
+ Every operator below has a matching fixture in [`tests/fixtures/valid/`](./tests/fixtures/valid) — the table and the test suite are the same list. Full semantics in [SPEC.md §5](./SPEC.md#5-operator-semantics).
57
+
58
+ ### Logical — profile `core`
59
+
60
+ | Operator | Example | Meaning |
61
+ | --- | --- | --- |
62
+ | `$and` | `{"$and": [{"a": 1}, {"b": 2}]}` | All must be TRUE |
63
+ | `$or` | `{"$or": [{"a": 1}, {"b": 2}]}` | At least one TRUE |
64
+ | `$nor` | `{"$nor": [{"a": 1}]}` | None TRUE |
65
+ | `$not` | `{"$not": {"a": 1}}` | Negation |
66
+
67
+ ### Comparison — profile `core`
68
+
69
+ | Operator | Example | Meaning |
70
+ | --- | --- | --- |
71
+ | `$eq` | `{"name": {"$eq": "Alice"}}` | Equal. Accepts any JSON value, including `null`, arrays and objects |
72
+ | `$ne` | `{"name": {"$ne": "Alice"}}` | Not equal |
73
+ | `$gt` `$gte` | `{"price": {"$gt": 50}}` | Greater than / or equal |
74
+ | `$lt` `$lte` | `{"born": {"$lte": "2023-12-31"}}` | Less than / or equal |
75
+ | `$in` | `{"color": {"$in": ["red", "green"]}}` | Value is one of |
76
+ | `$nin` | `{"color": {"$nin": ["red"]}}` | Value is none of |
77
+ | `$exists` | `{"archivedAt": {"$exists": false}}` | Key present on the record |
78
+ | `$isNull` | `{"middleName": {"$isNull": true}}` | Value is `null` |
79
+ | `$unknownAs` | `{"s": {"$ne": "x", "$unknownAs": true}}` | Resolve this constraint's UNKNOWN to `true`/`false` |
80
+
81
+ ### Ranges — profile `ranges`
82
+
83
+ | Operator | Example | Meaning |
84
+ | --- | --- | --- |
85
+ | `$between` | `{"score": {"$between": [10, 20]}}` | Within `[lo, hi]`, **inclusive** |
86
+ | `$nbetween` | `{"score": {"$nbetween": [10, 20]}}` | Outside `[lo, hi]` |
87
+
88
+ ### Strings — profile `strings`
89
+
90
+ | Operator | Example | Meaning |
91
+ | --- | --- | --- |
92
+ | `$like` | `{"description": {"$like": "%urgent%"}}` | SQL pattern: `%` any run, `_` one char, `\` escapes |
93
+ | `$nlike` | `{"code": {"$nlike": "TMP-%"}}` | Negated `$like` |
94
+ | `$ilike` `$nilike` | `{"title": {"$ilike": "%k8s%"}}` | Case-insensitive `$like` |
95
+ | `$startsWith` | `{"sku": {"$startsWith": "INV-"}}` | Literal prefix — wildcards not interpreted |
96
+ | `$endsWith` | `{"file": {"$endsWith": ".pdf"}}` | Literal suffix |
97
+ | `$contains` | `{"body": {"$contains": "100%"}}` | Literal substring. **String-only** — for arrays, quantify with `$some` |
98
+
99
+ ### Regular expressions — profile `regex`
100
+
101
+ | Operator | Example | Meaning |
102
+ | --- | --- | --- |
103
+ | `$regex` | `{"ref": {"$regex": "^inv-[0-9]{4}$"}}` | ECMA-262, matched unanchored |
104
+ | `$flags` | `{"ref": {"$regex": "^inv-", "$flags": "i"}}` | `i`, `m`, `s`. Only valid alongside `$regex` |
105
+
106
+ ### Types — profile `types`
107
+
108
+ | Operator | Example | Meaning |
109
+ | --- | --- | --- |
110
+ | `$type` | `{"quantity": {"$type": "integer"}}` | One of `string` `number` `integer` `boolean` `object` `array` `null` |
111
+
112
+ ### Collections — profile `collections`
113
+
114
+ | Operator | Example | Meaning |
115
+ | --- | --- | --- |
116
+ | `$some` | `{"items": {"$some": {"qty": {"$gt": 2}}}}` | At least one element satisfies the condition |
117
+ | `$every` | `{"tags": {"$every": {"$startsWith": "a-"}}}` | Every element satisfies it. TRUE for `[]` |
118
+ | `$hasAll` | `{"tags": {"$hasAll": ["a", "b"]}}` | Array contains every member of the list |
119
+ | `$size` | `{"tags": {"$size": {"$gte": 1}}}` | Array length — exact, or a comparison |
120
+
121
+
122
+ ### Field references — profile `refs`
123
+
124
+ | Operator | Example | Meaning |
125
+ | --- | --- | --- |
126
+ | `$field` | `{"price": {"$gt": {"$field": "cost"}}}` | Compare two fields — SQL's `WHERE price > cost` |
127
+ | `$literal` | `{"p": {"$eq": {"$literal": {"$field": "x"}}}}` | Force an object operand to be read as data |
128
+
129
+ ### Free text — profile `text`
130
+
131
+ | Operator | Example | Meaning |
132
+ | --- | --- | --- |
133
+ | `$search` | `{"title": {"$search": "kubernetes ingress"}}` | Server-defined text match |
134
+
135
+ ## Three things that will bite you
136
+
137
+ **`$not` does not include nulls.** Evaluation is three-valued, like SQL. `{"$not": {"status": {"$eq": "archived"}}}` excludes records whose `status` is `null`, because `NOT UNKNOWN` is UNKNOWN and only TRUE matches. Say which you meant:
138
+
139
+ ```json
140
+ { "status": { "$ne": "archived", "$unknownAs": true } }
141
+ ```
142
+
143
+ `$unknownAs` resolves that constraint's UNKNOWN, applied after every sibling operator including a field-level `$not`. [SPEC.md §4.6](./SPEC.md#46-resolving-unknown--unknownas).
144
+
145
+ **Missing is not null.** `{"a": null}` and `{}` are different records. `$exists` tests the key, `$isNull` tests the value. [SPEC.md §4.2](./SPEC.md#42-missing-versus-null) has the full table.
146
+
147
+ **`$in` does not search inside arrays.** It compares the value as a whole, so `{"tags": {"$in": ["a"]}}` asks whether `tags` *equals* `"a"`. Element membership names its quantifier: `{"tags": {"$some": {"$in": ["a"]}}}`. This differs from MongoDB on purpose — overloading `$in` makes the meaning depend on data a validator cannot see.
148
+
149
+ ## Field paths
150
+
151
+ A member name is a path into the record:
152
+
153
+ | Path | Addresses |
154
+ | --- | --- |
155
+ | `name` | a top-level field |
156
+ | `address.city` | a nested field |
157
+ | `items[0].sku` | an array element by index |
158
+ | `a\.b` | a single key whose literal name contains a dot |
159
+ | `$$price` | a single key whose literal name is `$price` |
160
+
161
+ `$` is reserved for operators, which is why a real `$price` field is escaped by doubling. A name starting with a single `$` that is not a known operator is rejected — that is what turns `$eqq` into an error rather than a filter that matches everything.
162
+
163
+ A path addresses one position. To say something about an array's elements, quantify with `$some` or `$every` — and note that the nesting carries the scope: one `$some` with two conditions needs a *single* element to satisfy both, while two `$some` clauses may be satisfied by different elements. [SPEC.md §5.9](./SPEC.md#59-quantifier-scope).
164
+
165
+ ## Using it from OpenAPI
166
+
167
+ One of the two integration paths this repo exists for; [Exposing search to an agent](#exposing-search-to-an-agent) covers the other. Complete, CI-linted documents live in [`examples/`](./examples).
168
+
169
+ ### OpenAPI 3.1 — `POST /…/search`
170
+
171
+ The 3.1 Path Item Object has a **fixed** set of method fields (`get`, `put`, `post`, `delete`, `options`, `head`, `patch`, `trace`). `QUERY` is not among them, so on 3.1 a search with a body is a `POST` to a sub-resource:
172
+
173
+ ```yaml
174
+ paths:
175
+ /pets/search:
176
+ post:
177
+ operationId: searchPets
178
+ requestBody:
179
+ required: true
180
+ content:
181
+ application/json:
182
+ schema:
183
+ $ref: '#/components/schemas/PetSearchRequest'
184
+ responses:
185
+ '200': { $ref: '#/components/responses/PetPage' }
186
+ '400': { $ref: '#/components/responses/InvalidQuery' }
187
+
188
+ components:
189
+ schemas:
190
+ Filter:
191
+ $ref: 'https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json'
192
+ PetSearchRequest:
193
+ type: object
194
+ required: [filter]
195
+ properties:
196
+ filter: { $ref: '#/components/schemas/Filter' }
197
+ ```
198
+
199
+ → [`examples/openapi-3.1-post-search.yaml`](./examples/openapi-3.1-post-search.yaml)
200
+
201
+ ### OpenAPI 3.2 — the `QUERY` method
202
+
203
+ 3.2 added `additionalOperations`, which is how methods outside the fixed set are described:
204
+
205
+ ```yaml
206
+ paths:
207
+ /pets:
208
+ additionalOperations:
209
+ QUERY:
210
+ operationId: queryPets
211
+ requestBody:
212
+ required: true
213
+ content:
214
+ application/json:
215
+ schema: { $ref: '#/components/schemas/PetSearchRequest' }
216
+ responses:
217
+ '200':
218
+ description: Matching pets
219
+ headers:
220
+ Content-Location:
221
+ schema: { type: string, format: uri-reference }
222
+ ```
223
+
224
+ → [`examples/openapi-3.2-query-method.yaml`](./examples/openapi-3.2-query-method.yaml)
225
+
226
+ `QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)) is **safe and idempotent** and carries a request body — it says "this is a read" in a way `POST` cannot, so intermediaries may cache it and clients may retry it. Return `Content-Location` when the same representation is also reachable by `GET`.
227
+
228
+ It became a standards-track RFC in June 2026, so the method itself is settled — but deployed support in intermediaries, client libraries and gateways trails a fresh RFC by some margin. Ship `POST /search` alongside it and let clients pick.
229
+
230
+ ### Referencing by URL or by copy
231
+
232
+ Both work, and they trade off differently:
233
+
234
+ | | Absolute `$id` URL | Bundled copy |
235
+ | --- | --- | --- |
236
+ | `$ref` | `https://…/v0.4.0/open-predicate-schema.json` | `./schemas/open-predicate-schema.json` |
237
+ | Upgrades | change one URL | re-vendor the file |
238
+ | Tooling | needs a resolver that fetches remote refs | works everywhere |
239
+ | MCP `inputSchema` | no — nothing on that path resolves remote refs | yes, and it is the only option |
240
+ | Field restriction | not possible | see below |
241
+
242
+ If you bundle, keep the `$id` intact. Consumers can then tell which version they are looking at — and if you nest the schema inside a larger one, as an MCP `inputSchema` does, its self-references have a base to resolve against. See [Exposing search to an agent](#exposing-search-to-an-agent), step 1.
243
+
244
+ The MCP row is not a preference. A server ships `inputSchema` inline in its `tools/list` response, so an absolute-URL `$ref` reaches the model as an opaque string and no grammar — see [Exposing search to an agent](#exposing-search-to-an-agent).
245
+
246
+ ### Restricting the queryable field set
247
+
248
+ The default grammar accepts any field name, because the set of queryable paths belongs to your resource, not to the language. Servers MUST reject unknown fields at runtime ([SPEC.md §3.5](./SPEC.md#35-which-paths-are-queryable)) — but you can also have it enforced by schema validation.
249
+
250
+ Bundle the schema and replace **one** definition, `$defs/FieldPath`:
251
+
252
+ ```json
253
+ {
254
+ "$id": "https://api.example.com/schemas/pet-filter.json",
255
+ "$ref": "#/$defs/Filter",
256
+ "$defs": {
257
+ "FieldPath": { "type": "string", "enum": ["id", "name", "status", "born"] },
258
+ "…": "everything else copied verbatim from open-predicate-schema.json"
259
+ }
260
+ }
261
+ ```
262
+
263
+ The narrowing applies at **every nesting level** — inside `$and`, inside `$not`, inside `$some` and `$every`, and to `$field` references — because `Filter` reaches field names through `propertyNames → $ref '#/$defs/FieldPath'`. There is one override point, and this is it. (`tests/validate.test.mjs` exercises exactly this.)
264
+
265
+ Narrowing `FieldPath` restricts *which* fields may be named. It cannot restrict what may be said about them — every path still shares one `Constraint`. To get per-field operators and operand domains as well, generate the schema instead; see [Generating a per-resource filter schema](#generating-a-per-resource-filter-schema).
266
+
267
+ > An earlier design used draft 2020-12 `$dynamicRef`/`$dynamicAnchor` so the override could be applied *without* copying the file. It was dropped: ajv 8.20 does not resolve it correctly even for the canonical recursive case, and OpenAPI tooling support is worse. A plain `$ref` works in every validator.
268
+
269
+ ## Conformance profiles
270
+
271
+ Not every backend can implement every operator, and silently ignoring a clause you cannot execute widens the result set — the worst possible failure for a filter. So operators are grouped into profiles, published in the schema under `x-profiles`:
272
+
273
+ `core` · `strings` · `regex` · `ranges` · `types` · `collections` · `refs` · `text`
274
+
275
+ Implement `core` in full; take the rest whole or not at all. Reject unsupported operators with an `unsupported-operator` error, and publish what you accept through a capability document. [SPEC.md §2](./SPEC.md#2-conformance).
276
+
277
+ ## Errors
278
+
279
+ A rejected filter is a `400` that says *which* of five things went wrong — `malformed-query` · `unknown-field` · `unsupported-operator` · `invalid-operand` · `query-too-complex` — and, ideally, where. The envelope is your API's business; if you have no error format already, [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details with a `pointer` at the offending clause is the recommended default:
280
+
281
+ ```json
282
+ {
283
+ "type": "https://openpredicate.tech/problems/unsupported-operator",
284
+ "title": "Unsupported operator",
285
+ "status": 400,
286
+ "detail": "$regex is not in this endpoint's advertised profiles (core, strings).",
287
+ "pointer": "/filter/$and/1/name/$regex"
288
+ }
289
+ ```
290
+
291
+ What matters is that the condition is distinguishable and the client can recover from it — an `unknown-field` error carrying the endpoint's queryable paths saves a round trip of guessing. [SPEC.md §8](./SPEC.md#8-errors).
292
+
293
+ ## Generating a per-resource filter schema
294
+
295
+ The published grammar shares one `Constraint` definition across every field, which is what makes it reusable — and what stops it carrying per-field domains. It can tell you `{"status": "Available"}` is well-formed. It cannot tell you `"Available"` is not one of the three values `status` takes, so the filter is accepted and matches nothing.
296
+
297
+ If you already have a JSON Schema for the resource, that information is sitting right there. [`tools/generate-filter-schema.mjs`](./tools/generate-filter-schema.mjs) reads it and emits a filter schema in which every queryable path has its own constraint subschema, carrying only the operators that apply to it and only the operands it can take.
298
+
299
+ ```bash
300
+ node tools/generate-filter-schema.mjs examples/pet.schema.json \
301
+ --id https://api.example.com/schemas/pet.filter.json \
302
+ --profiles core,strings,ranges,collections \
303
+ --capabilities pet.capabilities.json \
304
+ --out pet.filter.json
305
+ ```
306
+
307
+ Given [`examples/pet.schema.json`](./examples/pet.schema.json), the generated [`pet.filter.json`](./examples/pet.filter.json) turns each of these from an empty result set into a `400`:
308
+
309
+ | Filter | Rejected because |
310
+ | --- | --- |
311
+ | `{"status": "Available"}` | `status` is a closed domain of `available`, `pending`, `sold` |
312
+ | `{"tags": {"$in": ["urgent"]}}` | `tags` is an array; its element operators are `$some`, `$every` and `$hasAll` |
313
+ | `{"born": {"$gte": 2020}}` | `born` is a `date`-formatted string |
314
+ | `{"name": {"$gt": "M"}}` | ordering is offered on numbers and on date/time formats, not on free text |
315
+ | `{"species": {"$like": "ca%"}}` | pattern matching is not offered on an enumerated domain |
316
+ | `{"birthDate": "2020-01-01"}` | not a property of the resource |
317
+
318
+ It also writes the [SPEC.md §2.2](./SPEC.md#22-capability-discovery) capability document from the same source, so the schema and the published domains cannot drift apart.
319
+
320
+ That file is also what [`examples/mcp-server`](./examples/mcp-server) hands a model: a resource schema in, a tool definition out, with nothing written by hand in between.
321
+
322
+ What it decides, and why:
323
+
324
+ - **Operators follow the type.** Ordering and ranges go to numbers and to `date`/`date-time`/`time` strings; pattern matching goes to free text but not to enums or opaque formats like `uuid`; `$some`/`$every` go to arrays and recurse into arrays of objects, with `$hasAll` added for arrays of scalars. `$exists` is omitted where the property is required all the way up, `$isNull` where the type does not admit null, and `$unknownAs` where the field can be neither absent nor null — all three would be constants.
325
+ - **Operands follow the value domain.** `$eq`, `$in` and friends carry the field's `enum`, `pattern` and bounds. The ordering operators deliberately do not: `{"$gt": 0}` against a field whose `minimum` is 1 is a sensible predicate.
326
+ - **Prose comes from the grammar**, not from the generator, so operator descriptions stay in one place. `--descriptions brief` (the default) keeps them for the operators people get wrong and drops them for `$eq` and `$gt`, which matters when the output goes into an MCP tool definition.
327
+ - **Narrowing only.** Every filter the generated schema accepts is also valid against the published grammar, so a server implementing the published semantics evaluates it unchanged. The rule that makes this hold: the constraint object's own keywords are copied from the grammar, and only its *operator set* and *operand schemas* are narrowed. `tests/generator.test.mjs` asserts the property by sampling filters out of each generated schema's vocabulary — a list of examples can only re-check the leaks someone already thought of.
328
+
329
+ ### Selecting what you support
330
+
331
+ Everything above narrows the *value* side of a filter. The other half is the *feature* side: which operators, and which parts of the grammar, your backend can actually honour. Whatever you decline here the generated schema refuses, so a client finds out from validation rather than from a runtime error — or worse, from an empty result set.
332
+
333
+ Profiles are the coarse unit, and three shapes do not fit inside one:
334
+
335
+ | Your situation | Say |
336
+ | --- | --- |
337
+ | `LIKE` but no `POSITION`, so `$like` works and `$contains` does not | `--drop-operators '$contains'` |
338
+ | A key-value store with no notion of key absence, so `$exists` is unimplementable | `--drop-operators '$exists'` |
339
+ | A flat conjunctive index: one AND level and no shorthand | `--max-filter-depth 2 --no-shorthand` |
340
+ | A single-clause lookup: no logical operators at all | `--max-filter-depth 1` |
341
+
342
+ `--operators` takes the other direction — exactly these, intersected with `--profiles` — and `--limits` replaces SPEC §7's defaults with your real bounds in the capability document.
343
+
344
+ The combinations get long, and they are not a thing to retype, so the same selection goes in a file you check in beside the resource schema and regenerate from:
345
+
346
+ ```json
347
+ {
348
+ "resource": "pet.schema.json",
349
+ "id": "https://api.example.com/schemas/pet.filter.json",
350
+ "out": "pet.filter.json",
351
+ "capabilities": "pet.capabilities.json",
352
+ "profiles": ["core", "strings"],
353
+ "dropOperators": ["$contains", "$exists"],
354
+ "shorthand": false,
355
+ "limits": { "maxDepth": 4, "maxClauses": 40, "maxSetLength": 100 }
356
+ }
357
+ ```
358
+
359
+ ```bash
360
+ node tools/generate-filter-schema.mjs --config open-predicate.config.json # paths resolve against the config file
361
+ ```
362
+
363
+ One consequence worth knowing before you use it. [SPEC §2.1](./SPEC.md#21-profiles) says a profile other than `core` is implemented in full or not at all, so a profile you have narrowed is no longer one you can advertise: it drops out of the capability document's `profiles`, the per-field `operators` lists carry what you do offer, and the generator says on stderr which operator cost you the claim. Declining a `core` operator costs conformance outright, and it says that too. The *schema* stays legal either way — it accepts strictly fewer filters than the published grammar, which is the only rule generation has.
364
+
365
+ Opt a single property out, or override its operators, from the resource schema itself:
366
+
367
+ ```json
368
+ { "internalNotes": { "type": "string", "x-open-predicate": false } }
369
+ { "location": { "type": "string", "x-open-predicate": { "operators": ["$eq", "$in"] } } }
370
+ ```
371
+
372
+ `--include`, `--exclude`, `--max-depth` and `--pointer` do the rest. Run `--help` for the full list.
373
+
374
+ The tool ships inside the package as a `bin` named `open-predicate-generate`, so from a registry it is `npx @open-predicate/open-predicate` rather than a path — the package name, not the bin name, because the package is scoped and `npx` resolves packages. See [Status](#status) for whether that is reachable yet; from a clone or `npm install github:OpenPredicate/open-predicate` it always is.
375
+
376
+ ## Exposing search to an agent
377
+
378
+ The other integration path, and a working server for it lives in [`examples/mcp-server/`](./examples/mcp-server) — one tool, real records, `node examples/mcp-server/demo.mjs` to watch it answer and reject.
379
+
380
+ The integration is one property of one argument:
381
+
382
+ ```js
383
+ inputSchema: {
384
+ type: "object",
385
+ properties: {
386
+ filter: FILTER_SCHEMA, // the filter schema, inlined
387
+ limit: { type: "integer", minimum: 1, maximum: 50, default: 20 },
388
+ },
389
+ required: ["filter"],
390
+ }
391
+ ```
392
+
393
+ An agent sees only the tool definition you hand it. It does not fetch this schema and it does not read [SPEC.md](./SPEC.md), so everything a correct filter requires has to be *in* that definition — which is exactly what inlining the grammar achieves. Each operator arrives carrying its own `description`, written for a reader who has never seen the language, and each field arrives carrying the values it accepts. Nothing about the query language needs to go in your system prompt, and so nothing about it drifts when your prompt changes.
394
+
395
+ The language does the syntactic work for you. A malformed filter, an unknown field and an unsupported operator all come back as a `400` with a `pointer` at the offending clause ([SPEC.md §8](./SPEC.md#8-errors)) — enough for an agent to repair its own request in one round trip. What the language cannot catch is a filter that is *valid and wrong*. Those fail as an empty result set, which an agent cannot distinguish from "no such records", so it reports a confident false negative. Three cases account for most of them:
396
+
397
+ | Mistake | Why the agent makes it |
398
+ | --- | --- |
399
+ | `{"status": "Available"}` | Nothing told it the accepted values. |
400
+ | `{"status": {"$ne": "archived"}}`, meaning "not archived" | Three-valued logic drops the `null`s — [SPEC.md §4.1](./SPEC.md#41-three-valued-logic). Add `"$unknownAs": true`. |
401
+ | `{"tags": {"$in": ["urgent"]}}`, meaning array membership | `$in` compares the whole value. The element form is `{"$some": {"$in": [...]}}`. |
402
+
403
+ Five things close them, and [the generator](#generating-a-per-resource-filter-schema) does all five for you from your resource schema — which is why the example server hands the model a *generated* schema rather than the published grammar:
404
+
405
+ 1. **Inline the schema, and keep its `$id`.** An MCP server ships `inputSchema` inside its `tools/list` response, and nothing on that path resolves a remote `$ref` — an absolute-URL reference reaches the model as an opaque string and no grammar. Vendor the file; see [Referencing by URL or by copy](#referencing-by-url-or-by-copy). Keep the `$id` when you nest it under `properties.filter`: the schema refers to itself for nested filters and for each field's operand domain, and those fragments resolve against the nearest `$id`. Strip it and they resolve against the tool schema's root instead — a different document, which ajv refuses to compile at all.
406
+ 2. **Narrow the queryable paths to the fields you expose.** Otherwise the tool definition says nothing about what is queryable and the agent learns your field names one `unknown-field` at a time. Prefer `anyOf` of `const` + `description` over a bare `enum` if you want per-field prose to survive — an `enum` has nowhere to document its members.
407
+ 3. **Publish the value domains.** The grammar cannot express them: every path shares one `Constraint`, so per-field operand types are not representable. Put `type`, `format` and `values` in your capability document ([SPEC.md §2.2](./SPEC.md#22-capability-discovery)) and restate any closed domain in the tool description.
408
+ 4. **Trim the operators to your profiles.** If you implement `core` and `strings`, delete the rest from the bundled copy so `$regex` is unavailable rather than rejected at runtime. `x-profiles` maps each profile to its operators; dropping `regex` means dropping `$flags` and its `dependentRequired` entry with it.
409
+ 5. **State the two silent rules explicitly.** `$ne` and `$not` exclude nulls unless the constraint carries `"$unknownAs": true`, and `$in` is not array membership. An agent that has not been told will not infer either. The generated schema says both in its root `description`; if you bundle by hand, put them in the tool description yourself.
410
+
411
+ Two more, from building the example:
412
+
413
+ **Define one tool per resource** — `search_pets`, `search_orders` — rather than a single `search(resource, filter)`. `tools/list` is static, so a generic tool cannot vary its field list by argument, and that field list is most of what makes the tool usable.
414
+
415
+ **Return a rejection as a tool error, not a protocol error.** `isError: true` with the problem in the content puts the pointer in front of the model, which is what makes the repair-and-retry round trip happen at all. A JSON-RPC error gets swallowed by the client and the model learns nothing.
416
+
417
+ The cost is size: for the pet resource the tool definition is about 42 KB, paid once per session against a tool the agent may call many times, each call otherwise a guess. `--descriptions brief` and `--include` trim it; dropping profiles you do not implement trims it more.
418
+
419
+ ## Safety
420
+
421
+ A filter is user input that becomes a query plan. [SPEC.md §7](./SPEC.md#7-safety-limits) sets recommended bounds on nesting depth, clause count, set length and body size, and requires rejection rather than truncation when they are exceeded.
422
+
423
+ `$regex` is the largest exposure. Use a linear-time engine (RE2, Rust `regex`, Go `regexp`); if you only have a backtracking one, leave `regex` out of your advertised profiles and point clients at `$like`.
424
+
425
+
426
+ ## Repository layout
427
+
428
+ ```
429
+ open-predicate-schema.json the schema — the only file you need to consume
430
+ SPEC.md normative semantics
431
+ RELEASING.md how a release is cut (no artifacts are published)
432
+ COMPARISON.md how this relates to GraphQL, OData and JSON:API
433
+ tools/generate-filter-schema.mjs resource schema + capability selection -> filter schema + capabilities
434
+ examples/mcp-server/ a runnable MCP server; the language as a tool's inputSchema
435
+ examples/ working OpenAPI 3.1 and 3.2 documents
436
+ examples/pet.schema.json the generator's input, its config, and its committed output beside them
437
+ tests/validate.test.mjs meta-validation + fixture runner
438
+ tests/generator.test.mjs the generator: narrowing, soundness, recursion
439
+ tests/fixtures/valid/ one per operator; also the docs' example set
440
+ tests/fixtures/invalid/ every defect this version fixes, pinned
441
+ experiments/filter-to-sql/ an exercise: compile a filter to SQL, then judge the design by it
442
+ .github/workflows/ci.yml tests on Node 20/22/24 + OpenAPI lint
443
+ .github/workflows/release.yml verifies a GitHub Release; publishes nothing
444
+ ```
445
+
446
+ `npm test` meta-validates the schema under ajv's strict mode, checks that `x-profiles` covers exactly the operators the grammar defines, validates every inline example against its own subschema, and runs all fixtures — invalid ones asserting *which* keyword rejected them, so a fixture cannot pass for the wrong reason. It also compiles the generated pet filter schema, asserts that everything it accepts the published grammar accepts too, and checks the committed `examples/pet.filter.json` against a fresh run so it cannot drift. `npm run generate:example` refreshes it.
447
+
448
+ ## Status
449
+
450
+ **Work in progress.** Pre-1.0. This repository is published so the design can be read and argued with; it is not yet packaged for consumption, and the two should not be confused.
451
+
452
+ **The name is settled.** *OpenPredicate* is the name; [`OpenPredicate/open-predicate`](https://github.com/OpenPredicate/open-predicate) is the home; `openpredicate.tech` is the namespace every identifier derives from — the repository, both package names, the schema `$id`, the problem-type URIs, the CLI name and the vendor keyword alike. [`CHANGELOG.md`](./CHANGELOG.md) lists them in one table.
453
+
454
+ **What is settled is the naming, not yet the availability.** The identifiers below are final, but not all of them are reachable yet — so nobody has to discover it the hard way:
455
+
456
+ | What the README says | Reality today |
457
+ | --- | --- |
458
+ | `$id` / `$ref` — `https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json` | The permanent namespace, but not served yet. Used throughout [Using it from OpenAPI](#using-it-from-openapi) and in the capability document examples. |
459
+ | The package names `@open-predicate/open-predicate` and `@openpredicate/open-predicate` | The release pipeline publishes both on a GitHub Release — npmjs.com by OIDC trusted publishing, GitHub Packages by `GITHUB_TOKEN`. npm cannot mint the *first* version over OIDC, though, so the npmjs name is claimed by a one-time manual bootstrap; until that has run, neither registry has a copy. [`RELEASING.md`](./RELEASING.md#trusted-publishing-and-the-one-time-bootstrap) has the procedure. |
460
+ | The version line at the top, and the version inside the `$id` | May lag the latest tag. `CHANGELOG.md` is authoritative. |
461
+ | `npx @open-predicate/open-predicate` | Not reachable from a registry until the bootstrap in the row above has run. The `bin` entry is real and the tool ships inside the package, so this works from a clone or a git install either way. |
462
+
463
+ Serving the schema at its `$id` and publishing the package are the remaining work. Until then the only fetchable copy of the schema is raw GitHub:
464
+
465
+ ```bash
466
+ curl -O https://raw.githubusercontent.com/OpenPredicate/open-predicate/main/open-predicate-schema.json
467
+ ```
468
+
469
+ Vendor that file rather than referencing it remotely, and treat the resolvable-`$id` workflow the OpenAPI sections describe as the intended end state rather than a description of today.
470
+
471
+ **What is stable enough to review.** The grammar, the operator set and profile grouping, the null and three-valued semantics, and the error model. Those are what the schema, [`SPEC.md`](./SPEC.md) and the test suite pin down, and they are what feedback is most useful on. The grammar may still change before 1.0; each break is recorded in [`CHANGELOG.md`](./CHANGELOG.md) with a migration note.
472
+
473
+ ## About OpenPredicate
474
+
475
+ OpenPredicate is stewarded by the [**OpenPredicate**](https://openpredicate.tech) organisation, at [github.com/OpenPredicate](https://github.com/OpenPredicate). Its purpose is to take this predicate grammar from a single-author design to an **open standard**, and to push for its adoption at the places APIs are already described: `$ref`-ed from OpenAPI documents, inlined as MCP tool `inputSchema`s, and carried as the body of the HTTP `QUERY` method.
476
+
477
+ That goal sets the terms of the work. The grammar is specified normatively in [`SPEC.md`](./SPEC.md) rather than left to a reference implementation, so that independent implementations can agree; every breaking change is recorded with a migration note; and each significant design decision is argued in writing under [`decisions/`](./decisions) rather than settled by commit. Adoption arguments belong in the open too — [`COMPARISON.md`](./COMPARISON.md) is where the case against the nearest alternative is made and its gaps admitted.
478
+
479
+ The specification and this repository are [MIT](./LICENSE)-licensed, so the grammar can be implemented, vendored, extended and re-specified without permission. Issues and design discussion are welcome at [github.com/OpenPredicate/open-predicate](https://github.com/OpenPredicate/open-predicate/issues).
480
+
481
+ ## License
482
+
483
+ [MIT](./LICENSE)