npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lovrozagar/oat

v0.7.8

Published

OpenAPI Tester — matrix-test a live API against its OpenAPI spec

Readme

oat

npm

npm i -D @lovrozagar/oat

OpenAPI Tester — live matrix testing of a backend against its own OpenAPI document.

It reads the spec, talks to the running API, and treats every way to see a record as a cell in a matrix. Then it checks that those cells agree. It does not read your source, assume your framework, or hardcode a route.

A single-response check asks did this JSON match its schema? oat asks that on every request it sends — generated bodies, 4xx probes, 500s, documented statuses. Most production bugs still pass that test:

  • the row is on GET /tables/{id} and missing from GET /tables
  • ?filter=status.eq.nope returns every row (the backend dropped the param)
  • limit=2 yields 9 rows; limit=100 yields 10 (the sort has no total order)
  • PATCH { name } also cleared instruction
  • ?filter=id.eq.<another tenant's id> returns the row

Those are disagreements between projections of the same fact. That is the matrix.

How the matrix is built. oat inverts x-invalidate (or path heuristics) into an entity graph. Each entity gets a read surface: collection, item, filter, sort, page, cursor, select, search, parent routes, other tenants. It seeds a discriminating cohort (values whose lexical and numeric order disagree, LIKE metacharacters, unicode, nulls). Then it walks:

  • foundations — create landed, the page walk covers the set, equality selects one, sort actually sorts
  • composition — filter+sort, filter+select, search+filter, the triples; a filter must apply to the collection, not to the current page
  • writes — PATCH is minimal, immutable fields stay put, two PATCHes do not clobber, replay does not duplicate
  • isolation — a second principal with different roots, a same-tenant rank lattice, an invite that grants and then revokes
  • spec as adversary — every field you declared filterable / sortable / selectable actually is

There is no ground-truth database. A filter and its negation must partition the set. A page walk must cover the collection without gaps or dupes. List, item, and id.eq. must show the same field. One root cause is one finding; checks that depend on a broken primitive are BLOCKED, not a page of copies.

This file is the operator manual. An agent that has read it can install oat, write every kind of config, run every command, tag a document, interpret every outcome, and know what each check needs and asserts.

Table of contents

Install

npm i -D @lovrozagar/oat

Requires Node.js 20+. The published CLI is compiled JavaScript; npx oat / ./node_modules/.bin/oat is the entry.

The unscoped name oat on npm is a different project. Always install @lovrozagar/oat. The binary on PATH is still oat.

SQLite conformance (npm test, oat conformance with the sqlite backend) needs node --experimental-sqlite on Node 22. The published oat binary does not pass that flag for you; npm test in this repo does.

oat help          # same as oat --help
oat --help

Unknown commands, unknown flags, and missing required flags exit 2.

How a run works

  1. Load the OpenAPI document (URL or path). Internal $refs are inlined. External $refs are reported, never fetched.
  2. Model entities by inverting x-invalidate (or path heuristics) into a read surface per entity.
  3. Authenticate every configured principal (static headers and/or an auth flow). Credentials refresh on a countdown from exp (default 30s buffer) before every dispatch and each async poll.
  4. Seed a cohort of records per entity, in parent-before-child order, using each entity's create operation.
  5. Test the matrix, one entity at a time: foundations first, then composition, writes, isolation, declared effects. Entities run in series. Checks inside an entity stay ordered.
  6. Teardown everything the run created, unless --keep-fixtures / keepFixtures: true.

The first principal is the writer. Isolation needs a second principal with different roots. A rank lattice needs two or more principals that share roots and differ in rank. Invite checks need x-invite plus a peer with inviteAs.

oat never needs ground truth about your data. A filter and its negation must partition the set; a page walk must cover the collection; a record read four ways must read the same.

oat does not use OpenAPI security / securitySchemes, servers[], cookies, webhooks, callbacks, or links. Auth is the config. The primary origin is baseUrl. Extra hosts go in origins[], each with its own spec — do not merge them into the primary document. Request bodies follow the document: JSON, multipart/form-data (scalars + dummy / pool / each / resolveUpload files), or application/x-www-form-urlencoded. hooks.resolveInput can replace a generated JSON field (a Stripe test pm_…); hooks.resolveHeaders can attach a one-shot header (Turnstile) per request.

Quick start

The package ships a demo API (the same reference backend the self-test uses):

# terminal 1 — prints a url, spec, and demo keys
oat serve --defects STALE_LIST,PATCH_REPLACES

# terminal 2
oat run --config node_modules/@lovrozagar/oat/labs/local.config.ts --base-url <url from serve>

Inside this repository (after npm run build):

oat serve --defects STALE_LIST,PATCH_REPLACES
oat run --config labs/local.config.ts --base-url <url>

oat serve with no --defects is a correct backend. The suite should report nothing.

Against your API:

oat doctor --spec https://api.example.com/openapi.json
oat plan   --spec https://api.example.com/openapi.json
oat run    --config oat.config.ts

doctor is the adoption command. It runs offline against the spec alone and reports every coverage gap, naming the tag that would close it.

Complete configs

These are copy-paste starting points. Real configs import defineConfig from @lovrozagar/oat. Files inside this repository import from ../dist/index.js because they live in the source tree.

Static API keys, two tenants (smallest useful)

// oat.config.ts
import { defineConfig } from "@lovrozagar/oat"

export default defineConfig({
	spec: "https://api.example.com/openapi.json",
	baseUrl: "https://api.example.com",
	principals: [
		{
			id: "alpha",
			headers: { authorization: "Bearer ${API_TOKEN}" },
			roots: { project_id: "${PROJECT_A}" },
		},
		{
			id: "beta",
			headers: { authorization: "Bearer ${API_TOKEN_B}" },
			roots: { project_id: "${PROJECT_B}" },
		},
	],
})
export API_TOKEN=… API_TOKEN_B=… PROJECT_A=proj_a PROJECT_B=proj_b
oat run --config oat.config.ts

One principal is enough to seed and run CRUD / query / schema checks. The second principal, with different roots, is what makes tenant.* run. Without it those checks are did not apply, not a pass.

Shipped as labs/minimal.config.ts (and in the npm package).

JSON config

Same object. ${NAME} is interpolated after load. There is no defineConfig wrapper.

{
	"spec": "https://api.example.com/openapi.json",
	"baseUrl": "https://api.example.com",
	"principals": [
		{
			"id": "alpha",
			"headers": { "authorization": "Bearer ${API_TOKEN}" },
			"roots": { "project_id": "${PROJECT_A}" }
		}
	],
	"seed": 42,
	"cohortSize": 7,
	"outDir": "./.oat/runs"
}
oat run --config oat.config.json

Demo server (operation-id login)

Shipped as labs/local.config.ts. Points at oat serve.

import { defineConfig } from "@lovrozagar/oat"

export default defineConfig({
	spec: "/v1/openapi/spec",
	baseUrl: "http://127.0.0.1:8787",
	seed: 42,
	principals: [
		{
			id: "alpha",
			roots: { project_id: "proj_alpha" },
			auth: {
				credentialFrom: "$.access_token",
				steps: [{ operationId: "auth.token", body: { key: "key_alpha" } }],
			},
		},
		{
			id: "beta",
			roots: { project_id: "proj_beta" },
			auth: {
				credentialFrom: "$.access_token",
				steps: [{ operationId: "auth.token", body: { key: "key_beta" } }],
			},
		},
	],
})

spec: "/v1/openapi/spec" is resolved against baseUrl. --base-url on the CLI overrides the origin without editing the file.

Two tenants plus a same-tenant rank lattice

See Principals. Isolation keys off roots. Rank keys off rank with shared roots.

Commands

oat run          --config <file>     test a live backend and write a report
oat doctor       --spec <url|file>   what oat can and cannot test, and why
oat plan         --spec <url|file>   print the derived model (offline)
oat serve        [--defects A,B]     run the demo API
oat conformance                      self-test: injected defects vs detection
oat help

--spec for doctor / plan can be replaced by --config (the spec is read from the config). --json makes those two commands emit machine-readable output. --base-url on doctor / plan is only used to resolve a relative spec path.

--untagged is a serve flag (and a conformance concern). It is not a run flag.

oat run

Requires --config. CLI flags override the same field in the config when both are set.

| flag | default | meaning | | ------------------------------------------ | -------------------------------- | ----------------------------------------------------------------------------------------- | | --config | required | module or JSON file, default export | | --base-url | config.baseUrl | backend origin | | --only | config.only or all entities | comma-separated entity names as oat plan prints them (singularised) | | --seed | config.seed or 1 | fixture generation seed (reproducible) | | --out | config.outDir or ./.oat/runs | history root; each run writes <out>/<datetime>/ and updates latest | | --max-in-flight | config.maxInFlight or 4 | HTTP requests allowed at once | | --keep-fixtures | config.keepFixtures or false | do not DELETE what the run created | | --quiet | false | no stderr progress; files under --out still update | | --save-exchanges / --no-save-exchanges | on unless --profile cheap | persist every HTTP exchange under the run dir (exchanges.jsonl, exchanges/, blobs/) |

Exit codes: 0 no defects, 1 at least one root-cause finding (BACKEND_BUG, SPEC_BUG, SECURITY, AMBIGUITY) or the run stopped because the network never came back, 2 usage error (missing --config, no principals, unknown flag). COVERAGE_GAP and BLOCKED do not fail the process.

Example:

oat run --config oat.config.ts --only store,product --out .oat/runs/prod

--only store,product matches the entity names from oat plan, not path segments. /v1/stores is usually the entity store. If a name is unknown, that entity is simply not tested (the others still run).

A run with no principals exits 2. Isolation checks then need a second principal; they are skipped, not failed, when only one is present.

oat doctor

Offline. Loads the document, builds the model, prints coverage.

oat doctor --spec https://api.example.com/openapi.json
oat doctor --config oat.config.ts --json
oat doctor --spec ./openapi.yaml --base-url https://api.example.com

Human output:

  • trackable — entities with an identity and a read surface
  • listable — those that also have a list (query checks need this)
  • tags that are absent, and the checks each tag would unlock
  • tags that would sharpen checks that already run (x-query, x-tenant)
  • per-operation gaps (x-entity could not be inferred, assumed tenant param, …)
  • external $refs that were not fetched

--json shape:

{
	"blocking": 1,
	"entities": 12,
	"trackableEntities": 10,
	"testableEntities": 10,
	"listableEntities": 8,
	"roots": ["organization_id"],
	"externalRefs": ["https://example.com/shared.yaml"],
	"gaps": [{ "operationId": "table.list", "tag": "x-query", "detail": "…" }]
}

Exit 1 if there are blocking gaps: entities that are not trackable (no identity / no read), or the document has roots oat cannot create. Advisory gaps (missing x-query, no x-async) print and still exit 0.

oat plan

Offline. Prints the derived entity graph, operations, and query capability.

oat plan --spec ./openapi.yaml
oat plan --config oat.config.ts --json

Human columns:

entity              CLRUD  ident      read surface
store               CLRU·  id         2 route(s) (inferred)
                                      GET /v1/stores
                                      GET /v1/stores/{store_id}

CLRUD is Create / List / Read / Update / Delete. · means that slot is missing. ident is the identity property. Read surface is declared (x-invalidate) or inferred (sibling collection/item routes).

--json is { entities, operations, roots } — the full SpecModel maps, including conventions, query capability, async, invite, and path params. Use this when you need to know what oat will call something.

oat serve

In-process demo API. Same fixture as conformance.

oat serve
oat serve --defects STALE_LIST,PATCH_REPLACES
oat serve --backend sqlite --dialect classic
oat serve --untagged

| flag | default | meaning | | ------------ | ----------- | -------------------------------------------------------------------------------------- | | --backend | memory | memory | sqlite | postgres | | --dialect | postgrest | postgrest | classic | linked | jsonapi | plain | | --defects | none | comma-separated names from Reference defects | | --untagged | false | serve the same API behind a spec with every x-* tag stripped |

Printed keys: key_alpha (tenant proj_alpha), key_beta (tenant proj_beta). Spec: {url}/v1/openapi/spec. Stop with ctrl-c.

labs/local.config.ts is written for this server.

Dialects are reference-backend shapes, not something you configure against your API. They exist so conformance proves checks read the document rather than one fixture's spelling:

| dialect | filter | sort | select | page model | envelope | | ----------- | --------------------------- | ---------- | ---------------- | ------------------- | --------------------------------- | | postgrest | filter=status.eq.active | name.asc | select=id,name | page + cursor | entity-named + count/hasMore | | classic | filter=status=eq:active | sort= | fields= | page + per_page | { data, total_count, has_more } | | linked | postgrest | dotted | fields= | offset + limit | raw array + Link: rel=next | | jsonapi | postgrest | -name | fields[table]= | page + size | { data, total, has_more } | | plain | ?status=active (equality) | name:asc | fields= | page + limit | { items, total, has_more } |

postgres needs a server on the default postgres database (local, default postgres driver connection). sqlite needs Node's node:sqlite (--experimental-sqlite on Node 22). Missing backends fail at serve time rather than falling back.

oat conformance

Self-test. Not for your API. Injects named defects into the reference backend and asserts oat reports the matching check.

# this repo
npm test

# after install, from a checkout with --experimental-sqlite if you want sqlite
oat conformance
oat conformance --backend memory --dialect plain
oat conformance --fuzz 300 --max-defects 12 --seed 7
oat conformance --precision 60 --backend memory
oat conformance --parser
oat conformance --backend d1
oat conformance --only STALE_LIST,PATCH_REPLACES

| flag | meaning | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | --backend | memory | sqlite | postgres | d1. Default: every local backend that is available. d1 is never default — it is remote | | --dialect | pin one shape; default runs postgrest on each backend plus classic/linked/jsonapi/plain on memory | | --fuzz [n] | random sets of defects (default 25 if flag is bare) | | --max-defects | cap per fuzz combination (default 4) | | --precision [n] | vary data against a correct backend; any finding is a false positive (default 50 if flag is bare) | | --seed | replay a fuzz/precision run | | --parser | only the hostile-document + example-spec + tag-unlock suites | | --only | restrict injected defects (comma-separated STALE_LIST,…) |

A default oat conformance (no --fuzz / --precision / --parser) also runs a 40-case combination smoke on memory after the one-at-a-time matrix.

D1 needs CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_D1_DATABASE_ID, CLOUDFLARE_API_TOKEN. Postgres needs a reachable server on the default connection (database: "postgres"). Missing backends are skipped with a printed reason, not treated as a pass.

--parser still always runs first (hostile documents, labs/annotated-openapi.yaml model lock, tag-unlock map). Exit 1 if any parser, matrix, fuzz, or precision case fails.

Configuration

Two inputs, always: the spec and a config file. Backend-specific knowledge lives in x-* tags and this file. A backend adopts oat by adding tags, not by adapting to oat.

A config is:

  • .ts / .js / .mjs with export default defineConfig({ ... }), or
  • .json with the same object.

Named export without default is also accepted (module.default ?? module).

Top-level fields

import { defineConfig } from "@lovrozagar/oat"

export default defineConfig({
	spec: "https://api.example.com/openapi.json", // URL or filesystem path; JSON or YAML
	baseUrl: "https://api.example.com",
	principals: [/* at least one; see below */],
	hooks: {/* optional */},
	uploads: { pool: ["./fixtures/**/*"], eachMax: 24 },
	globalHeaders: { "x-request-id": "oat" }, // sent on every request; oat does not inspect them
	roots: { org_id: "org_shared" }, // path params oat cannot create; also declarable via x-root
	seed: 42, // fixture generation; a failing run with the same seed is identical
	cohortSize: 12, // records created per entity (default 7)
	maxInFlight: 4, // HTTP in flight
	only: ["store", "product"], // restrict entities
	keepFixtures: false,
	outDir: "./.oat/runs",
	saveExchanges: true, // default on unless --profile cheap; --quiet does not turn this off
	network: { retries: 4, waitMs: 60_000 }, // fetch-threw: retry, then wait for the link
	outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 },
	origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
	query: {
		operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
		emptyIn: "match-none",
		maxInValues: 100,
		searchEmpty: "match-all",
		sort: { nulls: ["first", "last"], maxKeys: 3 },
		select: { unknown: "reject" },
	},
	entities: {
		row: {
			query: {
				identityFilter: "_id",
				filterable: [{ field: "_id", type: "string", ops: ["eq", "neq", "in"] }],
			},
		},
	},
})

| field | required | default | notes | | --------------- | -------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | spec | yes | | See Spec loading | | baseUrl | yes | | Primary origin. OpenAPI servers[] is ignored | | principals | yes | | Non-empty. First is the writer | | hooks | no | | See Hooks | | uploads | no | | pool globs; optional each (operationId → globs) and eachMax. JSON configs may set all three | | globalHeaders | no | {} | Merged first. resolveHeaders then caller headers then auth | | origins | no | [] | Extra { id, baseUrl, spec } hosts. Auth JWT is reused. Do not merge those routes into spec | | outOfBand | no | { attempts: 6, initialMs: 200, maxMs: 3000 } | Backoff for resolveOutOfBand and resolvePrincipalAuth. See Hooks | | roots | no | {} | Shared path params (merged with each principal's roots) | | seed | no | 1 | Integer. Same seed → same fixture bodies | | cohortSize | no | 7 | Sliced from the 7 built-in variants. Larger repeats the pattern | | maxInFlight | no | 4 | Across the whole run | | only | no | all | Entity names from oat plan | | keepFixtures | no | false | Skip DELETE at the end | | outDir | no | ./.oat/runs | History root. Each run writes <outDir>/<datetime>/ and updates latest. Also writes principals.json after acquire | | saveExchanges | no | on unless profile is cheap | Persist every HTTP exchange under the run dir. --save-exchanges / --no-save-exchanges override. --quiet does not | | network | no | { retries: 4, waitMs: 60000 } | When fetch throws (offline / DNS / reset / timeout): retry, then wait once for the link. Not a 5xx policy. requestTimeoutMs is optional | | query | no | | Global query-catalog defaults. Overlay after x-query. Does not invent operators | | entities | no | | Per-entity overlays. This release only reads query. Unknown names are ignored; doctor warns |

spec may be a path relative to baseUrl (/v1/openapi/spec) or an absolute URL or a file.

CLI --base-url, --only, --seed, --out, --max-in-flight, --keep-fixtures, --save-exchanges / --no-save-exchanges override these when passed.

Spec loading

Resolved in this order, never by guessing the string's "look":

  1. Absolute http(s):// or file:// — used as given.
  2. A path that exists on disk, relative to the working directory.
  3. Anything else, when baseUrl is known — resolved against it (/v1/openapi/spec, openapi.json).

JSON if the first non-space character is { or [, otherwise YAML. Empty files error. A JSON document with more opening than closing brackets is diagnosed as truncated (proxy / download limit), not as a syntax error.

OpenAPI 3.0 and 3.1 both work. oat reads paths, operations, parameters, request/response JSON schemas, and x-* extensions. It does not require a particular openapi: version string.

Internal $refs are dereferenced. External $refs stay unresolved and show up in oat doctor / the JSON externalRefs list.

Principals

{
  id: "alpha",                          // required, stable name in reports
  headers: { authorization: "Bearer …" }, // static; enough for a long-lived key
  auth: { /* AuthFlow — see below */ },
  roots: { org_id: "org_alpha" },       // this principal's tenant / path params
  rootsFromFlow: { org_id: "orgId" },   // take path params from values the auth flow bound
  role: "owner",                        // free-form label in reports
  rank: 2,                              // higher can do everything a lower rank can; default 0
  inviteAs: "key_beta",                 // how an owner names this principal in an invite body
}

Rules that matter:

  • Isolation (tenant.*) needs two principals whose roots differ.
  • Rank (auth.rank-is-monotonic) needs two principals with the same roots and different rank.
  • Invite (auth.invite-grants-then-revokes) needs x-invite on the spec and a different-tenant principal with inviteAs set.
  • Extra principals are not ignored. Isolation picks the first different-roots peer. Rank uses the same-tenant pair.
  • headers and auth compose: static headers are sent, then the flow's credential header is merged on top.
  • A principal with only headers (no auth) never hits a login route.
  • auth may be a harvested credential instead of a step chain: { fromHook: "oauth-google" }. See Harvested principal.

Example — two tenants plus a same-tenant lattice:

principals: [
	{
		id: "alpha",
		role: "owner",
		rank: 2,
		auth: {
			credentialFrom: "$.access_token",
			steps: [{ operationId: "auth.token", body: { key: "key_alpha" } }],
		},
		roots: { org_id: "org_alpha" },
	},
	{
		id: "alpha_member",
		role: "member",
		rank: 1,
		auth: {
			credentialFrom: "$.access_token",
			steps: [{ operationId: "auth.token", body: { key: "key_alpha_member" } }],
		},
		roots: { org_id: "org_alpha" },
	},
	{
		id: "beta",
		role: "owner",
		rank: 2,
		inviteAs: "key_beta",
		auth: {
			credentialFrom: "$.access_token",
			steps: [{ operationId: "auth.token", body: { key: "key_beta" } }],
		},
		roots: { org_id: "org_beta" },
	},
]

Auth flows

auth: {
  steps: [ /* register / verify — first acquire only */ ],
  credentialFrom: "$.access_token", // JSON path in the last (or saved) response
  expiresInFrom: "$.expires_in",    // lifetime in seconds
  header: "authorization",          // default
  template: "Bearer {credential}",  // default
  assumeTtlMs: 3600000,             // used only if neither expiresInFrom nor JWT exp is present
  refreshBufferMs: 30_000,          // optional; default 30s. Proactive when expiresAt - now <= this
  refresh: {                        // signup flows must set this — re-running steps is not a refresh
    steps: [
      {
        operationId: "auth.refreshToken",
        body: { refresh_token: "{refreshToken}" },
        saveAs: {
          credential: "$.access_token",
          refreshToken: "$.refresh_token",
        },
      },
    ],
  },
}

Expiry is expiresInFrom (seconds) → JWT exp claim → assumeTtlMs. assumeTtlMs is only the fallback lifetime when nothing else revealed expiry — it is not used for the refresh threshold when expiresAt is known.

Refresh is countdown-based (expiresAt - refreshBufferMs, default 30s) before every dispatch and before each async poll. Signup flows declare auth.refresh (refresh-token operation). Re-running steps is only the fallback when refresh is omitted (API-key / token-exchange principals). A register-like first hop without refresh fails closed (AUTH_REFRESH_REQUIRED) rather than signing up again.

One 401 → force refresh + single retry with live headers. A second 401 is evidence. 5xx / 429 are never a refresh trigger. expiresAt === null (static-header principal) never proactive-refreshes.

Each step is one of:

Operation step (prefer this — survives the path moving):

{
  operationId: "auth.token",
  body: { key: "${API_KEY}" },
  headers: { "x-extra": "1" },
  query: { realm: "test" },
  saveAs: { credential: "$.access_token", refreshToken: "$.refresh_token" },
  saveClaimsFrom: { token: "$.access_token", bind: { orgId: "orgs.0.oid" } },
  bind: { address: "[email protected]" }, // literals, with {name} interpolation
  expect: [200],                            // default: any 2xx
}

Request step (when the document has no auth operations):

{
  method: "POST",
  path: "/v1/auth/register/email",
  body: { email: "{address}", password: "…" },
  bind: { address: "[email protected]" },
}

Out-of-band step (email link, OTP — oat cannot collect this itself):

{ outOfBand: { address: "{address}", kind: "email-verify", as: "verifyToken" } }

Later steps interpolate {name} from the flow scope. saveAs paths are $.foo.bar / $.orgs.0.id (dot + numeric index only; no JSON Pointer, no filters). Bind saveAs.refreshToken from $.refresh_token so {refreshToken} interpolates in auth.refresh. saveClaimsFrom reads a JWT's claims (signature is not verified — oat is reading its own credential). rootsFromFlow maps path parameter names to those bound keys.

bind on a step runs before the request. saveAs / saveClaimsFrom run after. credentialFrom is read from the last HTTP response unless a step already saved credential.

If a step's status is not acceptable, auth fails the run (not a finding): oat: principal "alpha" failed at auth step 2 (POST /v1/…).

A complete register → verify → use-claims example:

function signUp(email: string): AuthFlow {
	return {
		credentialFrom: "$.access_token",
		expiresInFrom: "$.access_token_expires_in",
		refresh: {
			steps: [
				{
					body: { refresh_token: "{refreshToken}" },
					method: "POST",
					path: "/v1/auth/refresh",
					saveAs: { credential: "$.access_token", refreshToken: "$.refresh_token" },
				},
			],
		},
		steps: [
			{
				bind: { address: email },
				body: { email, password: "…" },
				method: "POST",
				path: "/v1/auth/register/email",
			},
			{ outOfBand: { address: email, as: "verifyToken", kind: "email-verify" } },
			{
				body: { token: "{verifyToken}" },
				method: "POST",
				path: "/v1/auth/email/verify",
				saveAs: { credential: "$.access_token", refreshToken: "$.refresh_token" },
				saveClaimsFrom: {
					token: "$.access_token",
					bind: { orgId: "orgs.0.oid", projectId: "orgs.0.pids.0" },
				},
			},
		],
	}
}

principals: [
	{
		id: "alpha",
		auth: signUp("[email protected]"),
		rootsFromFlow: { organization_id: "orgId", project_id: "projectId" },
	},
]

The address used for teardownPrincipal is scope.address or scope.email (set via bind: { address } or bind: { email }).

Harvested principal

When the credential is produced outside oat (a human finishes Google OAuth on a harvest page, a pair lands in KV), do not make oat drive authorize / callback:

{
  id: "google-user",
  auth: { fromHook: "oauth-google" },
}

oat polls hooks.resolvePrincipalAuth("oauth-google") with the same outOfBand backoff as mail. Return { credential, refreshToken?, expiresIn? } or null to retry. Refresh re-calls the hook (401 and countdown). oat does not speak OAuth.

Secondary origins

One run, one primary baseUrl. A CDN (or any second host) keeps its own OpenAPI.

export default defineConfig({
	spec: "https://api.example.com/openapi.json",
	baseUrl: "https://api.example.com",
	principals: [/* acquire JWT on the API */],
	origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
})

After primary auth, oat snapshots the principals, binds those credentials to the other host, and runs the matrix against that document. Auth steps may set origin: "cdn" to send one hop to a named origin during acquire.

Do not merge CDN routes into the API gateway document.

A second defineConfig can reuse the first run's snapshot instead:

import { defineConfig, loadPersistedPrincipals } from "@lovrozagar/oat"

export default defineConfig({
	spec: "https://cdn.example.com/openapi.json",
	baseUrl: "https://cdn.example.com",
	principals: loadPersistedPrincipals("./.oat/runs/latest/principals.json"),
})

The CLI writes principals.json into each run folder (and .oat/runs/latest/principals.json via the latest symlink).

Hooks

hooks: {
  // Return null to retry (attempt is 1-based). oat backs off until a value arrives.
  resolveOutOfBand: async ({ address, kind, attempt }) => {
    const token = await readMailCatcher(address, kind)
    return token // or null
  },
  // Remove a principal this run provisioned (and everything it created).
  teardownPrincipal: async (address) => {
    await fetch(`https://api.example.com/test/cleanup?email=${address}`, { method: "DELETE" })
  },
  // Return a file to send, `{ fields }` to replace the whole request, or null to fall through.
  resolveUpload: async ({ operationId, field, contentMediaType }) => {
    if (operationId === "extract.once" && field === "file") {
      const bytes = await Deno.readFile("./invoices/known.pdf")
      return { bytes, filename: "known.pdf", mediaType: "application/pdf" }
    }
    return null
  },
  // After globalHeaders, before auth. Return null to add nothing.
  resolveHeaders: async ({ operationId, method, url }) => {
    if (operationId === "auth.register" || operationId === "auth.login") {
      return { "cf-turnstile-response": await harvestTurnstile() }
    }
    return null
  },
  // Replace a generated JSON field. Null keeps the generator.
  resolveInput: async ({ operationId, field }) => {
    if (operationId === "billing.subscribe" && field === "payment_method_id") {
      return process.env.STRIPE_TEST_PM
    }
    return null
  },
  // Harvested OAuth pair. Null retries with the outOfBand backoff.
  resolvePrincipalAuth: async (fromHook) => {
    if (fromHook !== "oauth-google") return null
    const pair = await readHarvestedGoogle()
    return pair === null ? null : { credential: pair.access_token, refreshToken: pair.refresh_token, expiresIn: pair.expires_in }
  },
  // After seed: add or replace any axis of the query catalog. Null keeps the merge.
  resolveQueryCapabilities: async ({ entity, get }) => {
    if (entity !== "row") return null
    const body = await get("table.get")
    return { filterable: /* harvest from body */ [] }
  },
  // Optional extra stop condition while x-wait polls.
  awaitSideEffect: async ({ operationId, record }) => {
    if (operationId !== "webhook.deliver") return null
    return Array.isArray((record as { items?: unknown }).items) && (record as { items: unknown[] }).items.length > 0
      ? true
      : null
  },
}

Without resolveOutOfBand, an outOfBand step cannot complete. oat polls the hook; the hook must not sleep. Returning "" is treated like null.

Default schedule (0.6.2, unchanged unless outOfBand is set): 6 attempts, first sleep 200 ms, doubling, cap 3000 ms. oat sleeps after every miss, including the last, so the worst-case wait is

200 + 400 + 800 + 1600 + 3000 + 3000 = 9000 ms.

That is too short for real mail (often 10–60 s) and for a human finishing Google OAuth or a Turnstile harvest. Configure it:

outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 }
// worst case: 1000 + 2000 + 4000 + 8000×17 = 143000 ms

Worst-case wait is sum_{i=0}^{attempts-1} min(initialMs × 2^i, maxMs). worstCaseWaitMs() from the package computes it. Existing configs that omit outOfBand do not slow down.

Without teardownPrincipal, provisioned accounts are reported as leftover rather than cascade-deleted. Per-record DELETE still runs for seeded rows when a delete (or x-cleanup) exists.

resolveHeaders is called on every dispatch (including the 401 retry). Merge order: globalHeaders → hook → per-request headers → principal credential. Use ctx.operationId / ctx.method / ctx.url to attach a one-shot captcha only on captcha ops. oat does not speak Turnstile.

resolveInput is the JSON twin of resolveUpload. Return a value to replace that field (payment_method_id on billing.subscribe); null keeps the generator.

resolveQueryCapabilities runs once per entity after seed. get(operationId) (or "GET /path") uses the seeded parent scope so a follow-up read can harvest dynamic columns. A provided filterable / sortable / searchable / selectable list replaces that axis; omitted axes stay. JSON configs have no hook.

resolvePrincipalAuth and awaitSideEffect are documented below.

Worked query overlay for a generic PostgREST-shaped API (not a named product):

export default defineConfig({
	spec: "https://api.example.com/openapi.json",
	baseUrl: "https://api.example.com",
	principals: [{ id: "alpha", headers: { authorization: `Bearer ${process.env.TOKEN}` } }],
	query: {
		operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
		operatorsByType: {
			string: ["eq", "neq", "like", "ilike", "in", "nin", "is"],
			number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "is"],
			date: ["eq", "neq", "gt", "gte", "lt", "lte", "is"],
			boolean: ["eq", "neq", "is"],
		},
		aliases: { ne: "neq" },
		emptyIn: "match-none",
		maxInValues: 100,
		maxFilterConditions: 20,
		searchEmpty: "match-all",
		sort: { nulls: ["first", "last"], maxKeys: 3 },
		select: { nested: false, unknown: "reject" },
	},
})

New surface still has to be listed. oat will not silently enable in / ilike / is / contains / search modes / nullsfirst on every PostgREST-shaped document.

Uploads

Multipart and binary parts are filled in this order:

  1. hooks.resolveUpload — a non-null UploadFile wins. { fields } that includes a file part replaces the whole request. { fields } that omits the file part overlays scalars and keeps the each / pool / dummy bytes.
  2. uploads.each fixture for this invocation, when that operation is listed.
  3. uploads.pool — first file whose extension / sniffed type matches the part's contentMediaType. Same seed + field + index → same pick. Ops not in each stay pick-one.
  4. A tiny dummy with sniffable magic (%PDF-1.1, 1×1 PNG, empty zip, …). Unknown types become 16 octet-stream bytes, not a skip.

uploads.each is a matrix, not a source. operationId → globs means that operation is invoked once per matched file (after eachMax). Same seed does not collapse each. A hook that ignores request.fixture and always returns the same file will send that file N times.

export default defineConfig({
	spec: "openapi.json",
	baseUrl: "https://api.example.com",
	principals: [/* … */],
	uploads: {
		pool: ["./fixtures/**/*"],
		each: {
			"extract.once": ["./fixtures/**/*"],
			"extract.stream": ["./fixtures/**/*"],
		},
		eachMax: 24,
	},
	hooks: {
		resolveUpload: async ({ operationId, field, fixture }) => {
			if (operationId === "extract.once" && field === "file") {
				return { fields: { columns: "vendor,date,amount" } }
			}
			return null
		},
	},
})

| case | outcome | | ------------------------------- | ----------------------------------------------------------- | | each omitted | pick-one (today) | | glob matches 0 files | warn once, no extra invocations, fall through to pool/dummy | | one path in the list is missing | drop that slot, warn once, never BACKEND_BUG | | eachMax < match count | first eachMax after sort, warn that it capped | | fixture unreadable | drop that slot, warn once, not BACKEND_BUG |

A missing pool path warns once and falls through. An empty pool match uses a dummy. The run does not fail. JSON configs may set pool, each, and eachMax. resolveUpload is TypeScript.

--profile cheap (or any profile that excludes the op) still drops the whole family. each does not punch through a profile.

Findings from an each invocation carry fixture: "invoice.pdf" and render as extract.once · invoice.pdf. One 5xx is one finding on that file.

oat does not OCR. It sends bytes and checks HTTP / JSON. A 200 with empty extract rows is not automatically a backend defect. A 4xx because the dummy is “not a real invoice” is not automatically a backend defect if the dummy matched the declared contentMediaType.

Prefer multipart/form-data when the operation documents it, even if JSON is also listed. Text form fields still use the string generator (format / pattern / maxLength). A part that is either text or file is sent as a file when the schema is binary.

Environment interpolation

After the module loads, every string in the config is scanned for ${NAME}:

headers: {
	authorization: "Bearer ${API_TOKEN}"
}

If API_TOKEN is unset, oat exits with an error. Do not commit secrets; put them in the environment.

Template literals in a .ts config (Bearer ${process.env.API_TOKEN}) are evaluated by Node before oat sees the object. Either style works; ${NAME} is what a .json config can use.

Names match [A-Z0-9_]+ case-insensitively.

Loading TypeScript configs

.js / .mjs / .json load everywhere.

.ts configs require a runtime that can import TypeScript: Node 22.6+ with --experimental-strip-types, or Node 23+. The published oat binary is itself JS; it still has to import() your config. If that fails, the error says so. Workaround: compile the config, or write .mjs.

node --experimental-strip-types ./node_modules/@lovrozagar/oat/dist/cli.js run --config oat.config.ts

How the model is derived

oat plan is this model. Checks never see raw paths; they see entities, actions, and query roles.

Entity name and action

Explicit: x-entity: { name, action, identity? } on the operation.

Heuristic (when the tag is absent):

  1. Split the path on /. Ignore {param} segments.
  2. The last non-parameter segment is the noun. v1 / v2 / vN is never a noun.
  3. Singularise that noun (storesstore, batchesbatch). Irregulars include people→person, categories→category, campuses→campus, statuses→status, children→child, companies→company, addresses→address, indices→index, queries→query, properties→property, entities→entity, inboxes→inbox. Endings us|ss|is|os|as|ics|ews|ess|ous|sis are left alone (status stays status).
  4. If a non-parameter segment follows the noun (/rows/aggregate, /tables/{id}/restore), action is action.
  5. Otherwise: GET collection → list, GET item → read, POST collection → create, POST item → action, PUT/PATCHupdate, DELETEdelete.

If no noun can be found, the operation is untracked and doctor records an x-entity gap.

--only and report entity names are these singular names.

Identity

x-entity.identity wins. Else the first of id, uuid, slug, key, name that is required on the item schema, else the first of those that exists, else the trailing path-param suffix ({table_id}id). Without an identity the entity is not trackable.

Read surface

The set of GET routes through which an instance is visible.

  • Declared: every "METHOD /path" in any x-invalidate that refers to this entity.
  • Inferred: sibling collection and item routes on the same path prefix as a mutator.

invalidation.declared-route-changes only runs when a mutator's x-invalidate names another entity's route.

Generated / immutable / soft-delete / tenant

See OpenAPI meta tags. Fallbacks:

  • readOnly: true counts as generated (omitted from create bodies).
  • No immutability testing without x-immutable.
  • Tenant param: x-tenant or a path param matching org|organization|tenant|workspace|account|project|app + optional _id/_slug. Inferred tenants make a cross-tenant read AMBIGUITY, not SECURITY. With neither a tag nor an inferred name, the check does not apply.

Idempotency

No meta tag. If create declares a header matching Idempotency-Key / Idempotence-Key / X-Idempotency-Key (spaces ignored, case-insensitive), idempotency.replay-does-not-duplicate runs.

Seeding

Per entity, oat POSTs the create body built from the request schema (JSON, multipart, or urlencoded).

Default cohort is 7 records, one of each variant, sliced by cohortSize:

| variant | what it is for | | --------------- | -------------------------------------------------- | | baseline | "Quarterly Report N" | | lexical-first | sorts first ("aaa first alphabetically") | | lexical-last | sorts last ("zzz last alphabetically") | | null-heavy | null on every nullable field | | unicode | "äöüß čćžšđ 日本語 中文 한글 привет مرحبا 🙂" | | metacharacter | "100% _off_ *everything*" — LIKE / escape probes | | boundary | empty / maxLength / numeric maximum |

Numbers use the ladder 1, 2, 5, 10, 20, 50, 100 so lexical order ≠ numeric order (otherwise a TEXT compare looks correct). Enums walk index % enum.length. readOnly / x-generated fields are omitted. Required fields that cannot be generated get a type fallback (0, false, [], {}, "value"). Arrays honour minItems (never send [] when minItems ≥ 1). Nested objects stop at depth 4.

Empty schemas ({}, true, additionalProperties: {}) and cyclic $refs after inlining stop the walk — they become a scalar or {}, never another object descent. A RangeError during generation is a COVERAGE_GAP on that entity naming the operationId and JSON pointer (fixture generation overflow on table.create (/)), not blocked by unknown.

String fields honour format, pattern, and minLength together: emailoat-{variant}-{index}@example.test, uri / urlhttps://example.test/..., uuid → a fixed-shape UUID, pattern → a string that matches (or the field is omitted / the entity is a gap). A generated string is padded to minLength (repeat the last character) without breaking pattern. "Quarterly Report N" is only used when the document does not constrain the string. minLength greater than maxLength omits an optional field and records missingRequired on a required one.

An operation with x-invite is not entity.create. oat does not POST a generated invitee. The invite check sends granteeField = the peer's inviteAs. Missing inviteAs is a coverage gap naming the tag.

A create whose operationId appears in any principal auth.steps, or that declares x-fresh-principal, is not seeded. Those rows were provisioned by the auth flow.

Parent path parameters are created first (depth-first through the owning entity's create). Config / principal roots fill parameters oat cannot create.

A create that returns >= 300 on the first variant fails the entity (downstream checks BLOCKED). Later variants that fail just shorten the cohort — a partial cohort is still used.

HTTP 429 is retried first — Retry-After or exponential backoff, up to five times — on seed, checks, and teardown, whether or not the operation declared x-rate-limit. The first 429 is never a seed failure. A leftover 429 after those retries is a gap, not a backend defect.

A 402 / plan-limit (payment_required, *_plan_limit) on create is not a backend defect when the same-tenant list already has a row — typically an earlier x-effects create that filled a free-plan quota. oat reuses that id so children (a row after extract created a table) can still seed. It does not invent records. Write-path checks on the adopted entity stand down.

A documented feature-gate 403 is the exception: if create declares x-feature-gate and the body is vars.type: feature_gate (and vars.feature matches the tag when present), oat records a COVERAGE_GAP naming the tag rather than a seed defect. See x-feature-gate.

When create is tagged x-unique, a first-variant seed 409 with a nonempty same-tenant list adopts that row (world.seed COVERAGE_GAP naming x-unique — the create could not insert). Unique-conflict checks still run against that row. Write-path oracles that need a body oat submitted stay skipped. 409 with an empty list stays BLOCKED (could not seed). A 409 without the tag is still today's seed failure. The seed 409 itself is not create.unique-conflict-rejected passing — that check is the explicit second POST. See x-unique. Later variants that 409 only shorten the cohort. Generated values for unique body columns differ across variants (a suffix) without weakening maxLength / pattern; if the document cannot express two distinct values, oat records a gap and skips extra variants.

--seed / seed makes the bodies identical across runs. It does not make server-assigned ids identical.

Teardown DELETEs created rows (or the x-cleanup route) newest-first. Failures and missing delete routes are printed as leftovers, not as check findings. keepFixtures: true skips this.

Query roles and grammars

Checks do not look for a parameter named filter. They resolve roles from aliases, then write values in the grammar the document demonstrates.

| role | aliases (normalised: case, _ / -, perPageper_page) | | ----------------- | ----------------------------------------------------------------------------------- | | filter | filter, where, query, conditions | | order | order, order_by, sort, sort_by, ordering | | select | select, fields, field, include_fields, projection, only | | search | q, search, query_text, term, keyword, text | | search mode | search_mode, searchmode, search_type, mode (only if a search role exists) | | limit (page size) | limit, per_page, page_size, pagesize, count, max_results, top, size | | page | page, page_number, pagenum, p | | offset | offset, skip, start, from | | cursor | cursor, after, starting_after, next, page_token, continuation |

A bracketed suffix is a value in the name (fields[articles], filter[status]), not part of the role. count is a page size only when it looks like one (has maximum or a default); otherwise it is treated as a total.

A bounded integer with a default that matches no alias is still taken as page size. A 1-based integer with no maximum is taken as page number.

Filter grammars — how oat writes a term:

| name | example | and / or | | ----------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------- | | postgrest | status.eq.active, name.neq.x, id.in.(a,b), name.ilike.FOO, note.is.null, tags.contains.x | and(a.eq.1,b.eq.2), or(...) | | colon | filter=status=eq:active (comma-joined terms; no grouping) | not expressible; those checks skip | | equality | ?status=active (one query param per field). Only eq is expressible | not expressible |

Operators the postgrest writer can emit: eq, ne, neq, gt, gte, lt, lte, in, nin, like, ilike, is, contains. Colon stays on eq / neq / gt / gte / lt / lte / like. Equality is eq only. Anything a grammar cannot write becomes did not apply, not a failed request.

A check that needs in, ilike, is, contains, a search mode, or nullsfirst / nullslast also needs that capability declared. oat does not infer new operators, modes, or nulls tokens onto an API that never listed them.

Sort grammars: name.asc (dotted), -name (prefixed / JSON:API; ascending is the bare name), name:asc (colon), name asc (spaced). Dotted (PostgREST-shaped) can also emit name.asc.nullsfirst / name.desc.nullslast when the capability map allows that token. Colon / prefixed / spaced stay as they are.

Select grammars: id,name (csv) or fields[table]=id,name (bracketed). If the parameter is already named fields[articles], that name is used verbatim.

How a grammar is inferred

x-query.grammar wins when it is postgrest | colon | equality.

Otherwise oat concatenates the filter/order/select parameter's example, examples, and description:

  • Filter: /postgrest/i or field.op.value / status.eq.activepostgrest; status=eq:colon; else equality. A free-text filter string that still looks like equality produces an x-query gap telling you to declare the grammar.
  • Sort: name.asc → dotted; name:desc → colon; name desc → spaced; leading -field → prefixed; else dotted.
  • Select: parameter name or description contains fields[…] → bracketed; else csv.

Without x-query, if a filter/order/select/search role resolves, oat assumes every scalar is filterable/sortable/selectable (string / number / integer / boolean, including nullable unions). Searchable-without-tag is further narrowed to names matching name|title|slug|label|description|email. doctor warns. Pagination-only lists stay uncovered.

That heuristic runs only for an axis that was not tagged and not set in config. An explicit empty claim — filterable: [], searchable: null, selectable: [], sortable: [] — is a claim of none. oat will not infer scalars over it.

Searchable / filterable / sortable / selectable from the tag are used as given. maxLimit is also taken from a page-size parameter's schema.maximum when the tag omits it.

Declared or skip

Every list check consults one effective capability map per entity. Precedence, unchanged in spirit:

| order | source | what it contributes | | ----- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | | 1 | x-query on the list operation | fields, structured rows, operators, modes, caps, harvest | | 2 | config.entities[name].query | union of fields; overlay of ops / modes / caps | | 3 | config.query | global defaults for the same keys | | 4 | heuristic | today's scalars / name-regex searchable — only if that axis was not tagged and not set in config | | 5 | skip | the check does not apply |

hooks.resolveQueryCapabilities runs after 1–3 (and after any *From harvest) and may add or replace any axis. Return null to keep the merge. Called once per entity after seed, with the seeded parent scope so a follow-up GET can run.

Missing capability → the check does not apply / unresolved. Never BACKEND_BUG for an undeclared op, mode, or nulls option.

Pagination and envelopes

Three page models, all first-class:

  • Page number (page + limit roles).
  • Offset (offset + limit). Checks that say "page 3" translate to offset = (page - 1) * size (size defaults to 20 only for that translation).
  • Cursor (cursor role + envelope nextCursor or a Link: rel=next header).

hasMore is taken from the body (hasMore, has_more, hasNextPage, more) or from a documented Link response header. Under Link pagination, absence of rel="next" means no more pages.

Collection shape is derived from the success JSON schema, not from hardcoded wrapper names:

  • Response type: array → the body is the list (key: null).
  • Otherwise the array property whose items are objects, skipping sidecar names error(s), warning(s), message(s), meta, links. Resource-named envelopes ({ tables: [...] }) work.
  • Sibling keys become envelope fields:

| role | accepted property names | | ---------- | ----------------------------------------------------------- | | total | count, total, totalCount, total_count, totalItems | | hasMore | hasMore, has_more, hasNextPage, more | | nextCursor | nextCursor, next_cursor, cursor, next, endCursor | | page | page, pageNumber, page_number, offset | | limit | limit, perPage, per_page, pageSize, page_size |

Success schema is the first JSON media type on responses 200, 201, 202, 2XX, or default. Request schema is taken from requestBody preferring multipart/form-data, then application/x-www-form-urlencoded, then JSON. A success response that lists text/event-stream is a stream: oat consumes it to the end and does not treat the raw body as a JSON schema defect. Media type is the stream tag — there is no x-stream.

OpenAPI meta tags

Vendor-neutral x-* extensions. Every one is optional. Precedence: explicit tag → heuristic → skip with a coverage gap.

A complete document with every tag in place is shipped as labs/annotated-openapi.yaml (also in the npm package). oat conformance asserts the derived model matches that file.

oat plan   --spec node_modules/@lovrozagar/oat/labs/annotated-openapi.yaml
oat doctor --spec node_modules/@lovrozagar/oat/labs/annotated-openapi.yaml

What each tag unlocks (otherwise the check cannot run):

| tag | checks unlocked | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | x-async | async.reaches-terminal-state, async.receipt-identifies-the-job | | x-effects | effects.declared-effect-occurs | | x-immutable | patch.immutable-field-rejected | | x-invalidate | invalidation.declared-route-changes (when the list names another entity) | | x-query | spec.declared-filterable-is-filterable, spec.declared-sortable-is-sortable, spec.declared-selectable-is-selectable, plus the declared-or-skip catalog checks (in / ilike / is / nulls / caps / …) | | x-soft-delete | softdelete.absent-from-default-list | | x-invite | auth.invite-grants-then-revokes | | x-wait | effects.side-effect-arrives | | x-unique | create.unique-conflict-rejected, update.unique-conflict-rejected |

What each tag sharpens (the check already runs, but the verdict changes):

| tag | without it | | ---------- | --------------------------------------------------------------------------------------------------------------------------- | | x-query | every scalar is probed, including columns you never indexed — expect findings y