Skip to content

About

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. One filter grammar for every search endpoint in an API.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

5 stars

Watchers

2 watching

Forks

Repository files navigation

OpenPredicate

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.

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.

{
  "$and": [
    { "status": "available" },
    { "$or": [
        { "species": { "$in": ["cat", "dog"] } },
        { "tags":    { "$some": { "$in": ["rescue", "senior"] } } }
    ]},
    { "born": { "$gte": "2020-01-01" } }
  ]
}

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.

  • Install — npm i @open-predicate/open-predicate (Node >= 20.10) (npmjs.com). The package is the schema: require() it, or import schema from '@open-predicate/open-predicate' with { type: 'json' } — ESM needs that import attribute, and a bare import throws without it.
  • Schema — open-predicate-schema.json (JSON Schema draft 2020-12)
  • Semantics — SPEC.md — nulls, paths, coercion, errors, limits
  • MCP server — examples/mcp-server/ — a search tool with the language as its inputSchema, runnable
  • OpenAPI documents — examples/ — working 3.1 (POST /…/search) and 3.2 (QUERY) integrations
  • Generator — 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
  • Compared with GraphQL — COMPARISON.md — what this overlaps with, what it does not, and what a JSON-Schema-native alternative would still need
  • Stewardship — the OpenPredicate organisation — see About OpenPredicate
  • Version — 0.6.2. The schema's $id still names v0.4.0: the $id tracks the grammar, and neither 0.5.0 nor 0.6.0 touched it. See CHANGELOG.md for this release, and decisions/0001 for the v0.4.0 migration.

Pre-1.0, and installable. The name is settled — OpenPredicate, stewarded by the OpenPredicate organisation, with the repository, both package names, the schema $id and the problem-type URIs all derived from it — the package is on npm as @open-predicate/open-predicate, and the schema is served at its $id — so $ref it by URL, or from a packaged copy, whichever suits. One thing is still provisional: the grammar may still break before 1.0, with each break recorded in CHANGELOG.md. See Status for what is reachable today.


Why

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 descriptions carried along as the instructions.

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.

The two rules worth learning first

Sibling members AND together. At every level.

{ "department": "sales", "age": { "$gte": 18 } }

is department = 'sales' AND age >= 18. That holds for operators on one field too — {"age": {"$gt": 18, "$ne": 30}} is a single valid constraint.

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.

Operator reference

Every operator below has a matching fixture in tests/fixtures/valid/ — the table and the test suite are the same list. Full semantics in SPEC.md §5.

Logical — profile core

Operator Example Meaning
$and {"$and": [{"a": 1}, {"b": 2}]} All must be TRUE
$or {"$or": [{"a": 1}, {"b": 2}]} At least one TRUE
$nor {"$nor": [{"a": 1}]} None TRUE
$not {"$not": {"a": 1}} Negation

Comparison — profile core

Operator Example Meaning
$eq {"name": {"$eq": "Alice"}} Equal. Accepts any JSON value, including null, arrays and objects
$ne {"name": {"$ne": "Alice"}} Not equal
$gt $gte {"price": {"$gt": 50}} Greater than / or equal
$lt $lte {"born": {"$lte": "2023-12-31"}} Less than / or equal
$in {"color": {"$in": ["red", "green"]}} Value is one of
$nin {"color": {"$nin": ["red"]}} Value is none of
$exists {"archivedAt": {"$exists": false}} Key present on the record
$isNull {"middleName": {"$isNull": true}} Value is null
$unknownAs {"s": {"$ne": "x", "$unknownAs": true}} Resolve this constraint's UNKNOWN to true/false

Ranges — profile ranges

Operator Example Meaning
$between {"score": {"$between": [10, 20]}} Within [lo, hi], inclusive
$nbetween {"score": {"$nbetween": [10, 20]}} Outside [lo, hi]

Strings — profile strings

Operator Example Meaning
$like {"description": {"$like": "%urgent%"}} SQL pattern: % any run, _ one char, \ escapes
$nlike {"code": {"$nlike": "TMP-%"}} Negated $like
$ilike $nilike {"title": {"$ilike": "%k8s%"}} Case-insensitive $like
$startsWith {"sku": {"$startsWith": "INV-"}} Literal prefix — wildcards not interpreted
$endsWith {"file": {"$endsWith": ".pdf"}} Literal suffix
$contains {"body": {"$contains": "100%"}} Literal substring. String-only — for arrays, quantify with $some

Regular expressions — profile regex

Operator Example Meaning
$regex {"ref": {"$regex": "^inv-[0-9]{4}$"}} ECMA-262, matched unanchored
$flags {"ref": {"$regex": "^inv-", "$flags": "i"}} i, m, s. Only valid alongside $regex

Types — profile types

Operator Example Meaning
$type {"quantity": {"$type": "integer"}} One of string number integer boolean object array null

Collections — profile collections

Operator Example Meaning
$some {"items": {"$some": {"qty": {"$gt": 2}}}} At least one element satisfies the condition
$every {"tags": {"$every": {"$startsWith": "a-"}}} Every element satisfies it. TRUE for []
$hasAll {"tags": {"$hasAll": ["a", "b"]}} Array contains every member of the list
$size {"tags": {"$size": {"$gte": 1}}} Array length — exact, or a comparison

Field references — profile refs

Operator Example Meaning
$field {"price": {"$gt": {"$field": "cost"}}} Compare two fields — SQL's WHERE price > cost
$literal {"p": {"$eq": {"$literal": {"$field": "x"}}}} Force an object operand to be read as data

Free text — profile text

Operator Example Meaning
$search {"title": {"$search": "kubernetes ingress"}} Server-defined text match

Three things that will bite you

$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:

{ "status": { "$ne": "archived", "$unknownAs": true } }

$unknownAs resolves that constraint's UNKNOWN, applied after every sibling operator including a field-level $not. SPEC.md §4.6.

Missing is not null. {"a": null} and {} are different records. $exists tests the key, $isNull tests the value. SPEC.md §4.2 has the full table.

$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.

Field paths

A member name is a path into the record:

Path Addresses
name a top-level field
address.city a nested field
items[0].sku an array element by index
a\.b a single key whose literal name contains a dot
$$price a single key whose literal name is $price

$ 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.

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.

Using it from OpenAPI

One of the two integration paths this repo exists for; Exposing search to an agent covers the other. Complete, CI-linted documents live in examples/.

OpenAPI 3.1 — POST /…/search

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:

paths:
  /pets/search:
    post:
      operationId: searchPets
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PetSearchRequest'
      responses:
        '200': { $ref: '#/components/responses/PetPage' }
        '400': { $ref: '#/components/responses/InvalidQuery' }

components:
  schemas:
    Filter:
      $ref: 'https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json'
    PetSearchRequest:
      type: object
      required: [filter]
      properties:
        filter: { $ref: '#/components/schemas/Filter' }

→ examples/openapi-3.1-post-search.yaml

OpenAPI 3.2 — the QUERY method

3.2 added additionalOperations, which is how methods outside the fixed set are described:

paths:
  /pets:
    additionalOperations:
      QUERY:
        operationId: queryPets
        requestBody:
          required: true
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PetSearchRequest' }
        responses:
          '200':
            description: Matching pets
            headers:
              Content-Location:
                schema: { type: string, format: uri-reference }

→ examples/openapi-3.2-query-method.yaml

QUERY (RFC 10008) 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.

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.

Referencing by URL or by copy

Both work, and they trade off differently:

Absolute $id URL Bundled copy
$ref https://…/v0.4.0/open-predicate-schema.json ./schemas/open-predicate-schema.json
Where the copy comes from resolved at use npm i @open-predicate/open-predicate, or curl from raw GitHub
Upgrades change one URL re-vendor the file
Tooling needs a resolver that fetches remote refs works everywhere
MCP inputSchema no — nothing on that path resolves remote refs yes, and it is the only option
Field restriction not possible see below

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, step 1.

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.

Restricting the queryable field set

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) — but you can also have it enforced by schema validation.

Bundle the schema and replace one definition, $defs/FieldPath:

{
  "$id": "https://api.example.com/schemas/pet-filter.json",
  "$ref": "#/$defs/Filter",
  "$defs": {
    "FieldPath": { "type": "string", "enum": ["id", "name", "status", "born"] },
    "…": "everything else copied verbatim from open-predicate-schema.json"
  }
}

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.)

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.

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.

Conformance profiles

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:

core · strings · regex · ranges · types · collections · refs · text

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.

Errors

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 Problem Details with a pointer at the offending clause is the recommended default:

{
  "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"
}

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.

Generating a per-resource filter schema

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.

If you already have a JSON Schema for the resource, that information is sitting right there. 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.

node tools/generate-filter-schema.mjs examples/pet.schema.json \
  --id https://api.example.com/schemas/pet.filter.json \
  --profiles core,strings,ranges,collections \
  --capabilities pet.capabilities.json \
  --out pet.filter.json

Given examples/pet.schema.json, the generated pet.filter.json turns each of these from an empty result set into a 400:

Filter Rejected because
{"status": "Available"} status is a closed domain of available, pending, sold
{"tags": {"$in": ["urgent"]}} tags is an array; its element operators are $some, $every and $hasAll
{"born": {"$gte": 2020}} born is a date-formatted string
{"name": {"$gt": "M"}} ordering is offered on numbers and on date/time formats, not on free text
{"species": {"$like": "ca%"}} pattern matching is not offered on an enumerated domain
{"birthDate": "2020-01-01"} not a property of the resource

It also writes the SPEC.md §2.2 capability document from the same source, so the schema and the published domains cannot drift apart.

That file is also what examples/mcp-server hands a model: a resource schema in, a tool definition out, with nothing written by hand in between.

What it decides, and why:

  • 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.
  • 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.
  • 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.
  • 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.

Selecting what you support

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.

Profiles are the coarse unit, and three shapes do not fit inside one:

Your situation Say
LIKE but no POSITION, so $like works and $contains does not --drop-operators '$contains'
A key-value store with no notion of key absence, so $exists is unimplementable --drop-operators '$exists'
A flat conjunctive index: one AND level and no shorthand --max-filter-depth 2 --no-shorthand
A single-clause lookup: no logical operators at all --max-filter-depth 1

--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.

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:

{
  "resource": "pet.schema.json",
  "id": "https://api.example.com/schemas/pet.filter.json",
  "out": "pet.filter.json",
  "capabilities": "pet.capabilities.json",
  "profiles": ["core", "strings"],
  "dropOperators": ["$contains", "$exists"],
  "shorthand": false,
  "limits": { "maxDepth": 4, "maxClauses": 40, "maxSetLength": 100 }
}
node tools/generate-filter-schema.mjs --config open-predicate.config.json   # paths resolve against the config file

One consequence worth knowing before you use it. SPEC §2.1 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.

Opt a single property out, or override its operators, from the resource schema itself:

{ "internalNotes": { "type": "string", "x-open-predicate": false } }
{ "location":      { "type": "string", "x-open-predicate": { "operators": ["$eq", "$in"] } } }

--include, --exclude, --max-depth and --pointer do the rest. Run --help for the full list.

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. From a clone it stays node tools/generate-filter-schema.mjs.

Exposing search to an agent

The other integration path, and a working server for it lives in examples/mcp-server/ — one tool, real records, node examples/mcp-server/demo.mjs to watch it answer and reject.

The integration is one property of one argument:

inputSchema: {
  type: "object",
  properties: {
    filter: FILTER_SCHEMA,      // the filter schema, inlined
    limit:  { type: "integer", minimum: 1, maximum: 50, default: 20 },
  },
  required: ["filter"],
}

An agent sees only the tool definition you hand it. It does not fetch this schema and it does not read 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.

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) — 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:

Mistake Why the agent makes it
{"status": "Available"} Nothing told it the accepted values.
{"status": {"$ne": "archived"}}, meaning "not archived" Three-valued logic drops the nulls — SPEC.md §4.1. Add "$unknownAs": true.
{"tags": {"$in": ["urgent"]}}, meaning array membership $in compares the whole value. The element form is {"$some": {"$in": [...]}}.

Five things close them, and the generator 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:

  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. 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.
  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.
  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) and restate any closed domain in the tool description.
  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.
  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.

Two more, from building the example:

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.

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.

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.

Safety

A filter is user input that becomes a query plan. SPEC.md §7 sets recommended bounds on nesting depth, clause count, set length and body size, and requires rejection rather than truncation when they are exceeded.

$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.

Repository layout

open-predicate-schema.json     the schema — the only file you need to consume
SPEC.md                        normative semantics
CONTRIBUTING.md                how to object, implement, or claim conformance
GOVERNANCE.md                  how decisions get made, who makes them, and the IPR posture
RELEASING.md                   how a release is cut, and how trusted publishing is set up
COMPARISON.md                  how this relates to GraphQL, OData and JSON:API
decisions/                     one numbered record per significant design decision
tools/generate-filter-schema.mjs   resource schema + capability selection -> filter schema + capabilities
examples/mcp-server/           a runnable MCP server; the language as a tool's inputSchema
examples/                      working OpenAPI 3.1 and 3.2 documents
examples/pet.schema.json       the generator's input, its config, and its committed output beside them
tests/validate.test.mjs        meta-validation + fixture runner
tests/generator.test.mjs       the generator: narrowing, soundness, recursion
tests/fixtures/valid/          one per operator; also the docs' example set
tests/fixtures/invalid/        every defect this version fixes, pinned
experiments/filter-to-sql/     an exercise: compile a filter to SQL, then judge the design by it
.github/ISSUE_TEMPLATE/        design objection, spec ambiguity, implementation report
.github/workflows/ci.yml       tests on Node 20/22/24 + OpenAPI lint
.github/workflows/release.yml  publishes to npmjs over OIDC when a GitHub Release is published

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.

Status

Work in progress. Pre-1.0. The design is published so it can be read and argued with, and the package is installable — but the grammar may still break before 1.0, so pin an exact version if you depend on it.

The name is settled. OpenPredicate is the name; 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 lists them in one table.

Naming and availability are both settled. The identifiers below are final and reachable. One exception remains — GitHub Packages — and it is called out so nobody has to discover it the hard way:

What the README says Reality today
$id / $ref — https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json Served, as application/schema+json, with Access-Control-Allow-Origin: * and immutable caching, byte-identical to the copy in this repository. $ref it directly, as Using it from OpenAPI and the capability document examples do.
@open-predicate/open-predicate on npmjs.com Published, from v0.6.0 on. Public, no credential needed to install.
@openpredicate/open-predicate on GitHub Packages Published, from 0.6.1 on — the first copy went up with that release. Installing from it needs an .npmrc and a token even though it is public, so npmjs.com is the easier path.
The version line at the top, and the version inside the $id May lag the latest tag. CHANGELOG.md is authoritative.
npx @open-predicate/open-predicate Works from 0.6.1. Broken in 0.6.0, where the bin exited 0 having printed nothing — the entry guard compared process.argv[1] against import.meta.url, and npm installs a bin as a symlink. Use the package name, not the bin name open-predicate-generate, which is not a package.

The $id resolves. The schema is served from the URL that identifies it, so the workflow the OpenAPI sections describe is a description of today rather than an intended end state:

curl -sI https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json
# content-type: application/schema+json; charset=utf-8
# access-control-allow-origin: *
# cache-control: public,max-age=31536000,immutable

Those three headers are the ones that matter: the media type so validators recognise it, the CORS header so browser-based tooling can fetch it, and immutable caching because a versioned $id never changes (§9). $ref it by URL and you are done.

A local copy is still the right choice for air-gapped builds, for pinning independently of DNS, or for the bundle-and-narrow workflow in Restricting the queryable field set. Either get it from the package:

npm i @open-predicate/open-predicate
# -> node_modules/@open-predicate/open-predicate/open-predicate-schema.json

or with no package manager in the way — pinning a tag rather than main:

curl -O https://raw.githubusercontent.com/OpenPredicate/open-predicate/v0.6.2/open-predicate-schema.json

The served copy and the packaged copy are the same bytes: the site vendors the schema out of this repository at a tag, so a release moves both together. See RELEASING.md.

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 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 with a migration note.

About OpenPredicate

OpenPredicate is stewarded by the OpenPredicate organisation, at 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 inputSchemas, and carried as the body of the HTTP QUERY method.

That goal sets the terms of the work. The grammar is specified normatively in 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/ rather than settled by commit. Adoption arguments belong in the open too — COMPARISON.md is where the case against the nearest alternative is made and its gaps admitted.

GOVERNANCE.md is where that process is written down: what each class of change costs, how a disputed design call is resolved — including that dissent is recorded in the decision record rather than discarded — what earns commit rights, and the project's royalty-free patent posture. It also states plainly that there is currently one maintainer, and treats that as the defect it is.

The specification and this repository are MIT-licensed, so the grammar can be implemented, vendored, extended and re-specified without permission, commercially or otherwise.

Disagreement is the most useful contribution at this stage, and CONTRIBUTING.md says how to make it: file a design objection, build an implementation (or help turn the fixtures into a real conformance suite), or claim conformance for a library you already maintain. Open-ended design talk belongs in Discussions; a defect or an objection belongs in an issue.

License

MIT

About

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. One filter grammar for every search endpoint in an API.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

5 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages