@lovrozagar/oat 0.7.0 → 0.7.2

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.
Files changed (81) hide show
  1. package/README.md +327 -151
  2. package/dist/cli.js +32 -29
  3. package/dist/config/define-config.d.ts +31 -3
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.js +1 -0
  8. package/dist/reference/defects.d.ts +16 -0
  9. package/dist/reference/defects.d.ts.map +1 -1
  10. package/dist/reference/defects.js +16 -0
  11. package/dist/reference/defects.js.map +1 -1
  12. package/dist/reference/model.d.ts +9 -0
  13. package/dist/reference/model.d.ts.map +1 -1
  14. package/dist/reference/model.js +20 -0
  15. package/dist/reference/model.js.map +1 -1
  16. package/dist/reference/query.d.ts +5 -0
  17. package/dist/reference/query.d.ts.map +1 -1
  18. package/dist/reference/query.js +87 -21
  19. package/dist/reference/query.js.map +1 -1
  20. package/dist/reference/spec.d.ts.map +1 -1
  21. package/dist/reference/spec.js +47 -2
  22. package/dist/reference/spec.js.map +1 -1
  23. package/dist/reference/store-api.d.ts +5 -1
  24. package/dist/reference/store-api.d.ts.map +1 -1
  25. package/dist/reference/store-api.js +9 -1
  26. package/dist/reference/store-api.js.map +1 -1
  27. package/dist/reference/stores/memory.d.ts.map +1 -1
  28. package/dist/reference/stores/memory.js +11 -0
  29. package/dist/reference/stores/memory.js.map +1 -1
  30. package/dist/reference/stores/postgres.d.ts.map +1 -1
  31. package/dist/reference/stores/postgres.js +75 -19
  32. package/dist/reference/stores/postgres.js.map +1 -1
  33. package/dist/reference/stores/sqlite-driver.d.ts.map +1 -1
  34. package/dist/reference/stores/sqlite-driver.js +3 -0
  35. package/dist/reference/stores/sqlite-driver.js.map +1 -1
  36. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  37. package/dist/reference/stores/sqlite.js +78 -17
  38. package/dist/reference/stores/sqlite.js.map +1 -1
  39. package/dist/report/console.d.ts +7 -1
  40. package/dist/report/console.d.ts.map +1 -1
  41. package/dist/report/console.js +208 -1
  42. package/dist/report/console.js.map +1 -1
  43. package/dist/runtime/checks.d.ts +3 -0
  44. package/dist/runtime/checks.d.ts.map +1 -1
  45. package/dist/runtime/checks.js +1334 -15
  46. package/dist/runtime/checks.js.map +1 -1
  47. package/dist/runtime/client.d.ts +17 -1
  48. package/dist/runtime/client.d.ts.map +1 -1
  49. package/dist/runtime/client.js +21 -4
  50. package/dist/runtime/client.js.map +1 -1
  51. package/dist/runtime/progress.d.ts +21 -0
  52. package/dist/runtime/progress.d.ts.map +1 -1
  53. package/dist/runtime/progress.js +63 -21
  54. package/dist/runtime/progress.js.map +1 -1
  55. package/dist/runtime/query-capabilities.d.ts +24 -0
  56. package/dist/runtime/query-capabilities.d.ts.map +1 -0
  57. package/dist/runtime/query-capabilities.js +66 -0
  58. package/dist/runtime/query-capabilities.js.map +1 -0
  59. package/dist/runtime/run.d.ts +6 -2
  60. package/dist/runtime/run.d.ts.map +1 -1
  61. package/dist/runtime/run.js +105 -37
  62. package/dist/runtime/run.js.map +1 -1
  63. package/dist/runtime/runs.d.ts +23 -0
  64. package/dist/runtime/runs.d.ts.map +1 -0
  65. package/dist/runtime/runs.js +53 -0
  66. package/dist/runtime/runs.js.map +1 -0
  67. package/dist/spec/conventions.d.ts +10 -2
  68. package/dist/spec/conventions.d.ts.map +1 -1
  69. package/dist/spec/conventions.js +61 -7
  70. package/dist/spec/conventions.js.map +1 -1
  71. package/dist/spec/extensions.d.ts +11 -0
  72. package/dist/spec/extensions.d.ts.map +1 -1
  73. package/dist/spec/extensions.js +16 -4
  74. package/dist/spec/extensions.js.map +1 -1
  75. package/dist/spec/query-capabilities.d.ts +154 -0
  76. package/dist/spec/query-capabilities.d.ts.map +1 -0
  77. package/dist/spec/query-capabilities.js +734 -0
  78. package/dist/spec/query-capabilities.js.map +1 -0
  79. package/labs/annotated-openapi.yaml +21 -2
  80. package/labs/demo.mjs +4 -2
  81. package/package.json +1 -1
package/README.md CHANGED
@@ -190,7 +190,7 @@ Same object. `${NAME}` is interpolated after load. There is no `defineConfig` wr
190
190
  ],
191
191
  "seed": 42,
192
192
  "cohortSize": 7,
193
- "outDir": "./oat-out"
193
+ "outDir": "./.oat/runs"
194
194
  }
195
195
  ```
196
196
 
@@ -255,23 +255,23 @@ oat help
255
255
 
256
256
  Requires `--config`. CLI flags override the same field in the config when both are set.
257
257
 
258
- | flag | default | meaning |
259
- | ----------------- | ------------------------------ | --------------------------------------------------------------------- |
260
- | `--config` | required | module or JSON file, default export |
261
- | `--base-url` | `config.baseUrl` | backend origin |
262
- | `--only` | `config.only` or all entities | comma-separated entity names as `oat plan` prints them (singularised) |
263
- | `--seed` | `config.seed` or `1` | fixture generation seed (reproducible) |
264
- | `--out` | `config.outDir` or `./oat-out` | report directory |
265
- | `--max-in-flight` | `config.maxInFlight` or `4` | HTTP requests allowed at once |
266
- | `--keep-fixtures` | `config.keepFixtures` or false | do not DELETE what the run created |
267
- | `--quiet` | false | no stderr progress; files under `--out` still update |
258
+ | flag | default | meaning |
259
+ | ----------------- | -------------------------------- | ---------------------------------------------------------------------- |
260
+ | `--config` | required | module or JSON file, default export |
261
+ | `--base-url` | `config.baseUrl` | backend origin |
262
+ | `--only` | `config.only` or all entities | comma-separated entity names as `oat plan` prints them (singularised) |
263
+ | `--seed` | `config.seed` or `1` | fixture generation seed (reproducible) |
264
+ | `--out` | `config.outDir` or `./.oat/runs` | history root; each run writes `<out>/<datetime>/` and updates `latest` |
265
+ | `--max-in-flight` | `config.maxInFlight` or `4` | HTTP requests allowed at once |
266
+ | `--keep-fixtures` | `config.keepFixtures` or false | do not DELETE what the run created |
267
+ | `--quiet` | false | no stderr progress; files under `--out` still update |
268
268
 
269
269
  **Exit codes:** `0` no defects, `1` at least one root-cause finding (`BACKEND_BUG`, `SPEC_BUG`, `SECURITY`, `AMBIGUITY`), `2` usage error (missing `--config`, no principals, unknown flag). `COVERAGE_GAP` and `BLOCKED` do not fail the process.
270
270
 
271
271
  Example:
272
272
 
273
273
  ```bash
274
- oat run --config oat.config.ts --only store,product --out oat-out/prod
274
+ oat run --config oat.config.ts --only store,product --out .oat/runs/prod
275
275
  ```
276
276
 
277
277
  `--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).
@@ -434,29 +434,47 @@ export default defineConfig({
434
434
  maxInFlight: 4, // HTTP in flight
435
435
  only: ["store", "product"], // restrict entities
436
436
  keepFixtures: false,
437
- outDir: "./oat-out",
437
+ outDir: "./.oat/runs",
438
438
  outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 },
439
439
  origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
440
+ query: {
441
+ operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
442
+ emptyIn: "match-none",
443
+ maxInValues: 100,
444
+ searchEmpty: "match-all",
445
+ sort: { nulls: ["first", "last"], maxKeys: 3 },
446
+ select: { unknown: "reject" },
447
+ },
448
+ entities: {
449
+ row: {
450
+ query: {
451
+ identityFilter: "_id",
452
+ filterable: [{ field: "_id", type: "string", ops: ["eq", "neq", "in"] }],
453
+ },
454
+ },
455
+ },
440
456
  })
441
457
  ```
442
458
 
443
- | field | required | default | notes |
444
- | --------------- | -------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- |
445
- | `spec` | yes | | See [Spec loading](#spec-loading) |
446
- | `baseUrl` | yes | | Primary origin. OpenAPI `servers[]` is ignored |
447
- | `principals` | yes | | Non-empty. First is the writer |
448
- | `hooks` | no | | See [Hooks](#hooks) |
449
- | `uploads` | no | | `pool` globs, relative to the config file. JSON configs: pool only |
450
- | `globalHeaders` | no | `{}` | Merged first. `resolveHeaders` then caller headers then auth |
451
- | `origins` | no | `[]` | Extra `{ id, baseUrl, spec }` hosts. Auth JWT is reused. Do not merge those routes into `spec` |
452
- | `outOfBand` | no | `{ attempts: 6, initialMs: 200, maxMs: 3000 }` | Backoff for `resolveOutOfBand` and `resolvePrincipalAuth`. See [Hooks](#hooks) |
453
- | `roots` | no | `{}` | Shared path params (merged with each principal's `roots`) |
454
- | `seed` | no | `1` | Integer. Same seed → same fixture bodies |
455
- | `cohortSize` | no | `7` | Sliced from the 7 built-in variants. Larger repeats the pattern |
456
- | `maxInFlight` | no | `4` | Across the whole run |
457
- | `only` | no | all | Entity names from `oat plan` |
458
- | `keepFixtures` | no | `false` | Skip DELETE at the end |
459
- | `outDir` | no | `./oat-out` | Created if missing. Also writes `principals.json` after acquire |
459
+ | field | required | default | notes |
460
+ | --------------- | -------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
461
+ | `spec` | yes | | See [Spec loading](#spec-loading) |
462
+ | `baseUrl` | yes | | Primary origin. OpenAPI `servers[]` is ignored |
463
+ | `principals` | yes | | Non-empty. First is the writer |
464
+ | `hooks` | no | | See [Hooks](#hooks) |
465
+ | `uploads` | no | | `pool` globs, relative to the config file. JSON configs: pool only |
466
+ | `globalHeaders` | no | `{}` | Merged first. `resolveHeaders` then caller headers then auth |
467
+ | `origins` | no | `[]` | Extra `{ id, baseUrl, spec }` hosts. Auth JWT is reused. Do not merge those routes into `spec` |
468
+ | `outOfBand` | no | `{ attempts: 6, initialMs: 200, maxMs: 3000 }` | Backoff for `resolveOutOfBand` and `resolvePrincipalAuth`. See [Hooks](#hooks) |
469
+ | `roots` | no | `{}` | Shared path params (merged with each principal's `roots`) |
470
+ | `seed` | no | `1` | Integer. Same seed → same fixture bodies |
471
+ | `cohortSize` | no | `7` | Sliced from the 7 built-in variants. Larger repeats the pattern |
472
+ | `maxInFlight` | no | `4` | Across the whole run |
473
+ | `only` | no | all | Entity names from `oat plan` |
474
+ | `keepFixtures` | no | `false` | Skip DELETE at the end |
475
+ | `outDir` | no | `./.oat/runs` | History root. Each run writes `<outDir>/<datetime>/` and updates `latest`. Also writes `principals.json` after acquire |
476
+ | `query` | no | | Global query-catalog defaults. Overlay after `x-query`. Does not invent operators |
477
+ | `entities` | no | | Per-entity overlays. This release only reads `query`. Unknown names are ignored; `doctor` warns |
460
478
 
461
479
  `spec` may be a path relative to `baseUrl` (`/v1/openapi/spec`) or an absolute URL or a file.
462
480
 
@@ -699,11 +717,11 @@ import { defineConfig, loadPersistedPrincipals } from "@lovrozagar/oat"
699
717
  export default defineConfig({
700
718
  spec: "https://cdn.example.com/openapi.json",
701
719
  baseUrl: "https://cdn.example.com",
702
- principals: loadPersistedPrincipals("./oat-out/principals.json"),
720
+ principals: loadPersistedPrincipals("./.oat/runs/latest/principals.json"),
703
721
  })
704
722
  ```
705
723
 
706
- The CLI writes `oat-out/principals.json` after every run.
724
+ The CLI writes `principals.json` into each run folder (and `.oat/runs/latest/principals.json` via the `latest` symlink).
707
725
 
708
726
  ### Hooks
709
727
 
@@ -746,6 +764,12 @@ hooks: {
746
764
  const pair = await readHarvestedGoogle()
747
765
  return pair === null ? null : { credential: pair.access_token, refreshToken: pair.refresh_token, expiresIn: pair.expires_in }
748
766
  },
767
+ // After seed: add or replace any axis of the query catalog. Null keeps the merge.
768
+ resolveQueryCapabilities: async ({ entity, get }) => {
769
+ if (entity !== "row") return null
770
+ const body = await get("table.get")
771
+ return { filterable: /* harvest from body */ [] }
772
+ },
749
773
  // Optional extra stop condition while x-wait polls.
750
774
  awaitSideEffect: async ({ operationId, record }) => {
751
775
  if (operationId !== "webhook.deliver") return null
@@ -777,8 +801,38 @@ Without `teardownPrincipal`, provisioned accounts are reported as leftover rathe
777
801
 
778
802
  `resolveInput` is the JSON twin of `resolveUpload`. Return a value to replace that field (`payment_method_id` on `billing.subscribe`); `null` keeps the generator.
779
803
 
804
+ `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.
805
+
780
806
  `resolvePrincipalAuth` and `awaitSideEffect` are documented below.
781
807
 
808
+ Worked `query` overlay for a generic PostgREST-shaped API (not a named product):
809
+
810
+ ```ts
811
+ export default defineConfig({
812
+ spec: "https://api.example.com/openapi.json",
813
+ baseUrl: "https://api.example.com",
814
+ principals: [{ id: "alpha", headers: { authorization: `Bearer ${process.env.TOKEN}` } }],
815
+ query: {
816
+ operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
817
+ operatorsByType: {
818
+ string: ["eq", "neq", "like", "ilike", "in", "nin", "is"],
819
+ number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "is"],
820
+ date: ["eq", "neq", "gt", "gte", "lt", "lte", "is"],
821
+ boolean: ["eq", "neq", "is"],
822
+ },
823
+ aliases: { ne: "neq" },
824
+ emptyIn: "match-none",
825
+ maxInValues: 100,
826
+ maxFilterConditions: 20,
827
+ searchEmpty: "match-all",
828
+ sort: { nulls: ["first", "last"], maxKeys: 3 },
829
+ select: { nested: false, unknown: "reject" },
830
+ },
831
+ })
832
+ ```
833
+
834
+ New surface still has to be listed. oat will not silently enable `in` / `ilike` / `is` / `contains` / search modes / `nullsfirst` on every PostgREST-shaped document.
835
+
782
836
  ### Uploads
783
837
 
784
838
  Multipart and binary parts are filled in this order:
@@ -934,6 +988,7 @@ Checks do not look for a parameter _named_ `filter`. They resolve **roles** from
934
988
  | order | `order`, `order_by`, `sort`, `sort_by`, `ordering` |
935
989
  | select | `select`, `fields`, `field`, `include_fields`, `projection`, `only` |
936
990
  | search | `q`, `search`, `query_text`, `term`, `keyword`, `text` |
991
+ | search mode | `search_mode`, `searchmode`, `search_type`, `mode` (only if a search role exists) |
937
992
  | limit (page size) | `limit`, `per_page`, `page_size`, `pagesize`, `count`, `max_results`, `top`, `size` |
938
993
  | page | `page`, `page_number`, `pagenum`, `p` |
939
994
  | offset | `offset`, `skip`, `start`, `from` |
@@ -945,15 +1000,17 @@ A bounded integer with a default that matches no alias is still taken as page si
945
1000
 
946
1001
  **Filter grammars** — how oat **writes** a term:
947
1002
 
948
- | name | `eq` / `neq` / `gt` / `like` example | `and` / `or` |
949
- | ----------- | ------------------------------------------------------------------------- | ---------------------------------- |
950
- | `postgrest` | `filter=status.eq.active`, `name.neq.x`, `price.gt.10`, `name.like.*foo*` | `and(a.eq.1,b.eq.2)`, `or(...)` |
951
- | `colon` | `filter=status=eq:active` (comma-joined terms; no grouping) | not expressible; those checks skip |
952
- | `equality` | `?status=active` (one query param per field). Only `eq` is expressible | not expressible |
1003
+ | name | example | `and` / `or` |
1004
+ | ----------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------- |
1005
+ | `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(...)` |
1006
+ | `colon` | `filter=status=eq:active` (comma-joined terms; no grouping) | not expressible; those checks skip |
1007
+ | `equality` | `?status=active` (one query param per field). Only `eq` is expressible | not expressible |
953
1008
 
954
- Operators oat can emit: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`. Anything else a check needs that the grammar cannot write becomes **did not apply**, not a failed request.
1009
+ 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.
955
1010
 
956
- **Sort grammars:** `name.asc` (dotted), `-name` (prefixed / JSON:API; ascending is the bare name), `name:asc` (colon), `name asc` (spaced).
1011
+ 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.
1012
+
1013
+ **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.
957
1014
 
958
1015
  **Select grammars:** `id,name` (csv) or `fields[table]=id,name` (bracketed). If the parameter is already named `fields[articles]`, that name is used verbatim.
959
1016
 
@@ -969,8 +1026,26 @@ Otherwise oat concatenates the filter/order/select parameter's `example`, `examp
969
1026
 
970
1027
  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.
971
1028
 
1029
+ 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.
1030
+
972
1031
  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.
973
1032
 
1033
+ ### Declared or skip
1034
+
1035
+ Every list check consults one **effective capability map** per entity. Precedence, unchanged in spirit:
1036
+
1037
+ | order | source | what it contributes |
1038
+ | ----- | ------------------------------- | ---------------------------------------------------------------------------------------------------- |
1039
+ | 1 | `x-query` on the list operation | fields, structured rows, operators, modes, caps, harvest |
1040
+ | 2 | `config.entities[name].query` | union of fields; overlay of ops / modes / caps |
1041
+ | 3 | `config.query` | global defaults for the same keys |
1042
+ | 4 | heuristic | today's scalars / name-regex searchable — **only if that axis was not tagged and not set in config** |
1043
+ | 5 | skip | the check does not apply |
1044
+
1045
+ `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.
1046
+
1047
+ Missing capability → the check does not apply / unresolved. Never `BACKEND_BUG` for an undeclared op, mode, or nulls option.
1048
+
974
1049
  ## Pagination and envelopes
975
1050
 
976
1051
  Three page models, all first-class:
@@ -1010,16 +1085,16 @@ oat doctor --spec node_modules/@lovrozagar/oat/labs/annotated-openapi.yaml
1010
1085
 
1011
1086
  What each tag **unlocks** (otherwise the check cannot run):
1012
1087
 
1013
- | tag | checks unlocked |
1014
- | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
1015
- | `x-async` | `async.reaches-terminal-state`, `async.receipt-identifies-the-job` |
1016
- | `x-effects` | `effects.declared-effect-occurs` |
1017
- | `x-immutable` | `patch.immutable-field-rejected` |
1018
- | `x-invalidate` | `invalidation.declared-route-changes` (when the list names another entity) |
1019
- | `x-query` | `spec.declared-filterable-is-filterable`, `spec.declared-sortable-is-sortable`, `spec.declared-selectable-is-selectable` |
1020
- | `x-soft-delete` | `softdelete.absent-from-default-list` |
1021
- | `x-invite` | `auth.invite-grants-then-revokes` |
1022
- | `x-wait` | `effects.side-effect-arrives` |
1088
+ | tag | checks unlocked |
1089
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1090
+ | `x-async` | `async.reaches-terminal-state`, `async.receipt-identifies-the-job` |
1091
+ | `x-effects` | `effects.declared-effect-occurs` |
1092
+ | `x-immutable` | `patch.immutable-field-rejected` |
1093
+ | `x-invalidate` | `invalidation.declared-route-changes` (when the list names another entity) |
1094
+ | `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 / …) |
1095
+ | `x-soft-delete` | `softdelete.absent-from-default-list` |
1096
+ | `x-invite` | `auth.invite-grants-then-revokes` |
1097
+ | `x-wait` | `effects.side-effect-arrives` |
1023
1098
 
1024
1099
  What each tag **sharpens** (the check already runs, but the verdict changes):
1025
1100
 
@@ -1087,23 +1162,65 @@ Accept and revoke send the documented JSON request body when the operation decla
1087
1162
 
1088
1163
  ### `x-query`
1089
1164
 
1165
+ String arrays still work. Structured rows are optional. Either, not both required.
1166
+
1090
1167
  ```yaml
1091
1168
  x-query:
1092
- filterable: [id, name, slug, status, created_at]
1093
- sortable: [name, created_at, updated_at]
1169
+ grammar: postgrest # postgrest | colon | equality
1170
+ filterable: [id, name, created_at]
1171
+ sortable: [name, created_at]
1094
1172
  searchable: [name, slug]
1095
- selectable: [id, name, slug, created_at]
1173
+ selectable: [id, name, created_at]
1096
1174
  maxLimit: 100
1097
1175
  defaultOrder: created_at.desc
1098
1176
  stableTiebreak: id
1099
- grammar: postgrest # postgrest | colon | equality
1100
- ```
1101
1177
 
1102
- States what `filter` / `order` / `q` / `select` actually support. `stableTiebreak` is load-bearing for keyset pagination. `grammar` pins how oat writes values (see [Query roles and grammars](#query-roles-and-grammars)).
1103
-
1104
- **Fallback:** if those roles resolve, treat every scalar as capable and warn.
1105
-
1106
- **Unlocks:** `spec.declared-filterable-is-filterable`, `spec.declared-sortable-is-sortable`, `spec.declared-selectable-is-selectable`. Sharpens every other query check (without the tag they probe columns you may not have indexed).
1178
+ # structured (optional) — unlocks per-field ops / types / nulls
1179
+ filterable:
1180
+ - { field: id, type: string, ops: [eq, neq, in, nin] }
1181
+ - { field: name, type: string, ops: [eq, neq, like, ilike, in] }
1182
+ - { field: created_at, type: date, ops: [eq, gt, gte, lt, lte, is] }
1183
+ - { field: tags, type: array, ops: [contains, eq, is] }
1184
+ sortable:
1185
+ - { field: name, type: string }
1186
+ - { field: created_at, type: date, nulls: [first, last] }
1187
+
1188
+ operators: [eq, ne, neq, gt, gte, lt, lte, in, nin, like, ilike, is, contains]
1189
+ operatorsByType:
1190
+ string: [eq, ne, neq, like, ilike, in, nin, is]
1191
+ number: [eq, ne, neq, gt, gte, lt, lte, in, nin, is]
1192
+ date: [eq, ne, neq, gt, gte, lt, lte, is]
1193
+ enum: [eq, ne, neq, in, nin, is]
1194
+ boolean: [eq, ne, neq, is]
1195
+ array: [contains, eq, is]
1196
+ aliases: { ne: neq }
1197
+ identityFilter: _id # filter field when it differs from the JSON identity
1198
+ emptyIn: match-none # reject | match-none; undeclared → empty-in check skips
1199
+ maxInValues: 100
1200
+ maxFilterConditions: 20
1201
+ searchModes: [keyword] # free-form; undeclared → mode checks skip
1202
+ searchEmpty: match-all # ignore | match-all | reject
1203
+ sortNulls: [first, last]
1204
+ maxSortKeys: 3
1205
+ selectNested: true # `rel(col)` grammar; undeclared → nested check skips
1206
+ selectUnknown: reject # reject | ignore; undeclared → unknown-select check skips
1207
+
1208
+ # harvest any axis from another GET — no baked path
1209
+ filterableFrom: { operationId: table.get, path: $.columns[*].name, typePath: $.columns[*].type, typeMap: { text: string, int: number } }
1210
+ sortableFrom: { operationId: table.get, path: $.columns[*].name }
1211
+ searchableFrom: { operationId: table.get, path: $.columns[?(@.searchable==true)].name }
1212
+ selectableFrom: { operationId: table.get, path: $.columns[*].name }
1213
+ ```
1214
+
1215
+ `defaultOrder` / `stableTiebreak` / `maxLimit` stay. Harvest keys are generic JSON paths (`$`, dots, `[n]`, `[*]`, `[?(@.key==value)]`). Absent → no harvest.
1216
+
1217
+ String arrays + `operatorsByType` is enough for most APIs. Per-field `ops` / `nulls` close the list that field actually accepts.
1218
+
1219
+ **Fallback:** if those roles resolve and the axis was not tagged and not set in config, treat every scalar as capable and warn. Empty tagged `filterable: []` / `searchable: null` / `selectable: []` is an explicit claim of none.
1220
+
1221
+ **Unlocks:** `spec.declared-filterable-is-filterable`, `spec.declared-sortable-is-sortable`, `spec.declared-selectable-is-selectable`. Structured extras unlock the matching catalog checks (`in` = union of `eq`, `ilike` vs `like`, `is.null`, illegal op, empty `in`, caps, nulls, search empty/modes, unknown select, nested `rel(col)`). Sharpens every other query check (without the tag they probe columns you may not have indexed).
1222
+
1223
+ `oat doctor` prints, per entity, the effective map (all four axes, ops, modes, caps, harvest/hook) and which new checks will apply. Unknown `config.entities` names are ignored and warned.
1107
1224
 
1108
1225
  ### `x-async`
1109
1226
 
@@ -1315,72 +1432,108 @@ export default defineConfig({
1315
1432
 
1316
1433
  ## Checks
1317
1434
 
1318
- 57 checks. A check that cannot run says so (`did not apply` + `needs`). A check that depends on a broken primitive is `BLOCKED`. A check that ran and stopped is inconclusive, not a pass.
1435
+ 94 checks. A check that cannot run says so (`did not apply` + `needs`). A check that depends on a broken primitive is `BLOCKED`. A check that ran and stopped is inconclusive, not a pass.
1319
1436
 
1320
1437
  Order is fixed (foundations first) so cascade suppression has a cause to point at. Mutating checks run alone; read-only checks may share in-flight requests under `maxInFlight`.
1321
1438
 
1322
1439
  `depends` is the `dependsOn` list: if any of those already failed **for this entity**, this check is `BLOCKED` rather than reported as a second defect. Suppression is transitive.
1323
1440
 
1324
- | id | asserts | needs | depends |
1325
- | ------------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | --------------------------------------- |
1326
- | `list.read-after-write` | a just-created record appears on the list | create + a seeded record | — |
1327
- | `create.persists-submitted-fields` | every writable field sent on create is echoed | create that echoes the record | `list.read-after-write` |
1328
- | `payload.string-survives` | a documented-valid string is stored exactly; 4xx after an ASCII control is a fail | update or create+delete, item GET, unconstrained string | `list.read-after-write` |
1329
- | `create.status-matches-document` | create status is one the document declared | create | — |
1330
- | `response.status-is-documented` | every non-create exchange returns a status that operation names (`default` ≠ 201) | a modeled non-create operation oat invoked | — |
1331
- | `schema.success-response-matches-document` | create body validates against the success schema | success schema on create | `create.status-matches-document` |
1332
- | `schema.error-response-matches-document` | an error body validates against the documented error schema | error schema on the item route | — |
1333
- | `pagination.limit-bounds-page-size` | page size ≤ the requested limit | page-size _role_ (aliases include `limit`, `per_page`, …) | `list.read-after-write` |
1334
- | `pagination.limit-respects-documented-max` | requesting more than `maxLimit` does not return more | declared `maxLimit` and a larger cohort | `pagination.limit-bounds-page-size` |
1335
- | `pagination.has-more-is-accurate` | `hasMore` / `Link rel=next` matches whether another page exists | page-forward + `hasMore` or `Link rel=next` | `pagination.limit-bounds-page-size` |
1336
- | `pagination.page-walk-covers-set` | walking pages covers the collection with no gaps or dupes | page or offset + ≥3 records | `pagination.limit-bounds-page-size` |
1337
- | `pagination.cursor-agrees-with-page` | cursor walk and page walk yield the same set | both cursor and page | `pagination.limit-bounds-page-size` |
1338
- | `filter.unknown-field-rejected` | a filter on a field that does not exist is not silently ignored | a filter expression | — |
1339
- | `filter.equality-selects-exactly-one` | `id.eq.<one>` returns that one record | equality on the identity | `list.read-after-write` |
1340
- | `filter.zero-match-returns-none` | a filter that matches nothing returns an empty page, not the whole set | same | `list.read-after-write` |
1341
- | `filter.negation-partitions-the-set` | `eq` ∪ `neq` = whole set, intersection empty | eq and neq | `list.read-after-write`, equality |
1342
- | `filter.and-composes-as-intersection` | `and(A,B)` = A ∩ B | two filterable fields + AND | equality / list |
1343
- | `filter.or-composes-as-union` | `or(A,B)` = A ∪ B | `or()` — **postgrest grammar only** | equality / list |
1344
- | `filter.like-metacharacters-escaped` | `%` `_` `*` in a value are literals, not wildcards | like operator | `list.read-after-write` |
1345
- | `filter.numeric-comparison-is-numeric` | `gt`/`lt` on a number uses numeric order, not TEXT (`1,10,2`) | numeric field + a filter param | `list.read-after-write`, unknown-field |
1346
- | `error.malformed-filter-not-5xx` | garbage filter text is 4xx, never 5xx | a filter expression | — |
1347
- | `query.filter-selects-from-whole-set` | a filter is applied to the collection, not to the current page | filterable + ≥3 records | list / walk |
1348
- | `sort.order-is-applied` | requesting a sort actually rearranges the page | order + a sortable field | `pagination.limit-bounds-page-size` |
1349
- | `sort.reverse-symmetry` | desc is the reverse of asc (nulls included) | order + asc/desc | order-is-applied |
1350
- | `search.q-narrows-result` | a search term that matches one record does not return the whole set | search param + searchable fields | `list.read-after-write` |
1351
- | `select.projection-honoured` | `select=id,name` does not return undeclared fields | select param | — |
1352
- | `count.consistent-with-returned-page` | envelope total ≥ rows on this page, and is not zero when the page is not | envelope total | `list.read-after-write` |
1353
- | `count.matches-filtered-set` | filtered total equals the size of the filtered walk | total + a filter | list, equality |
1354
- | `query.axes-compose` | filter + sort together: filter still holds on the sorted page | filterable + sortable | filter + sort foundations |
1355
- | `query.filter-and-select-compose` | filter + select together | filterable + select | same |
1356
- | `query.search-and-filter-compose` | search + filter together | filterable + search | same |
1357
- | `query.filter-sort-select-compose` | filter + sort + select | filter + sort + select | same |
1358
- | `query.filter-search-sort-compose` | filter + search + sort | filter + search + sort | same |
1359
- | `query.filter-search-select-compose` | filter + search + select | filter + search + select | same |
1360
- | `spec.declared-filterable-is-filterable` | every `x-query.filterable` field actually accepts a filter | `x-query` naming filterable fields | filter foundations |
1361
- | `spec.declared-sortable-is-sortable` | every `x-query.sortable` field actually accepts a sort | `x-query` naming sortable fields | sort foundations |
1362
- | `spec.declared-selectable-is-selectable` | every `x-query.selectable` field actually accepts a select | `x-query` naming selectable fields | select |
1363
- | `tenant.item-not-readable-cross-tenant` | principal B cannot GET principal A's item | second principal, different `roots`, and a tagged or inferred tenant | — |
1364
- | `tenant.denial-does-not-reveal-existence` | 404 vs 403 (or equivalent) does not distinguish "exists other tenant" from "missing" | second principal, and a tagged or inferred tenant | `tenant.item-not-readable-cross-tenant` |
1365
- | `tenant.filter-does-not-bypass-scope` | `filter=id.eq.<other tenant>` does not return that row | second principal + a filter | `query.filter-selects-from-whole-set` |
1366
- | `auth.rank-is-monotonic` | a lower rank cannot do what a higher rank is denied | two same-tenant principals at different `rank` | `list.read-after-write` |
1367
- | `auth.invite-grants-then-revokes` | invite → accept grants; revoke takes it back | `x-invite` + peer with `inviteAs` | list, cross-tenant |
1368
- | `patch.immutable-field-rejected` | PATCHing an `x-immutable` field is rejected or ignored | `x-immutable` | — |
1369
- | `softdelete.absent-from-default-list` | a soft-deleted row is gone from the default list | `x-soft-delete` | `list.read-after-write` |
1370
- | `invalidation.declared-route-changes` | after a write, the other entity's listed route actually changes | `x-invalidate` naming another entity | list, persist |
1371
- | `effects.declared-effect-occurs` | `x-effects` cardinality delta is observed on the named list | `x-effects` | `list.read-after-write` |
1372
- | `effects.side-effect-arrives` | after the write, the named GET’s JSON path is occupied before `timeoutMs` | `x-wait` | `list.read-after-write` |
1373
- | `async.reaches-terminal-state` | `x-async` reaches `until` (poll, or a terminal SSE frame) before `timeoutMs` | `x-async` | — |
1374
- | `async.receipt-identifies-the-job` | `idFrom` on the receipt (JSON object or SSE event JSON) resolves to a pollable job | `x-async` + `idFrom` | — |
1375
- | `patch.minimality` | PATCH `{ name }` does not clear other writable fields | update + item route | — |
1376
- | `idempotency.replay-does-not-duplicate` | same Idempotency-Key + same body does not create a second row | create + documented Idempotency-Key header | list, persist |
1377
- | `delete.absent-record-returns-404` | DELETE of a missing id is 404, not 200 | delete | — |
1378
- | `concurrency.no-lost-update` | two PATCHes to different fields do not clobber each other | update + two writable strings | persist + patch |
1379
- | `validation.enum-enforced` | a value outside the enum is rejected | enum in the request schema | — |
1380
- | `validation.max-length-enforced` | a string over `maxLength` is rejected | maxLength | — |
1381
- | `validation.required-enforced` | omitting a required field is rejected | required field | — |
1382
- | `validation.content-type-enforced` | a wrong Content-Type is 415 when 415 is documented | documented 415 | — |
1383
- | `consistency.projections-agree` | list, item, and filtered views of the same field agree | item route + a comparable field | list + persist + filter |
1441
+ | id | asserts | needs | depends |
1442
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------- |
1443
+ | `list.read-after-write` | a just-created record appears on the list | create + a seeded record | — |
1444
+ | `create.persists-submitted-fields` | every writable field sent on create is echoed | create that echoes the record | `list.read-after-write` |
1445
+ | `payload.string-survives` | a documented-valid string is stored exactly; 4xx after an ASCII control is a fail | update or create+delete, item GET, unconstrained string | `list.read-after-write` |
1446
+ | `create.status-matches-document` | create status is one the document declared | create | — |
1447
+ | `response.status-is-documented` | every non-create exchange returns a status that operation names (`default` ≠ 201) | a modeled non-create operation oat invoked | — |
1448
+ | `schema.success-response-matches-document` | create body validates against the success schema | success schema on create | `create.status-matches-document` |
1449
+ | `schema.error-response-matches-document` | an error body validates against the documented error schema | error schema on the item route | — |
1450
+ | `pagination.limit-bounds-page-size` | page size ≤ the requested limit | page-size _role_ (aliases include `limit`, `per_page`, …) | `list.read-after-write` |
1451
+ | `pagination.limit-respects-documented-max` | requesting more than `maxLimit` does not return more | declared `maxLimit` and a larger cohort | `pagination.limit-bounds-page-size` |
1452
+ | `pagination.has-more-is-accurate` | `hasMore` / `Link rel=next` matches whether another page exists | page-forward + `hasMore` or `Link rel=next` | `pagination.limit-bounds-page-size` |
1453
+ | `pagination.page-walk-covers-set` | walking pages covers the collection with no gaps or dupes | page or offset + ≥3 records | `pagination.limit-bounds-page-size` |
1454
+ | `pagination.cursor-agrees-with-page` | cursor walk and page walk yield the same set | both cursor and page | `pagination.limit-bounds-page-size` |
1455
+ | `filter.unknown-field-rejected` | a filter on a field that does not exist is not silently ignored | a filter expression | — |
1456
+ | `filter.equality-selects-exactly-one` | `id.eq.<one>` returns that one record | equality on the identity | `list.read-after-write` |
1457
+ | `filter.zero-match-returns-none` | a filter that matches nothing returns an empty page, not the whole set | same | `list.read-after-write` |
1458
+ | `filter.negation-partitions-the-set` | `eq` ∪ `neq` = whole set, intersection empty | eq and neq | `list.read-after-write`, equality |
1459
+ | `filter.and-composes-as-intersection` | `and(A,B)` = A ∩ B | two filterable fields + AND | equality / list |
1460
+ | `filter.or-composes-as-union` | `or(A,B)` = A ∪ B | `or()` — **postgrest grammar only** | equality / list |
1461
+ | `filter.like-metacharacters-escaped` | `%` `_` `*` in a value are literals, not wildcards | like operator | `list.read-after-write` |
1462
+ | `filter.numeric-comparison-is-numeric` | `gt`/`lt` on a number uses numeric order, not TEXT (`1,10,2`) | numeric field + a filter param | `list.read-after-write`, unknown-field |
1463
+ | `filter.in-is-union-of-eq` | `in.(a,b)` = `eq.a` ∪ `eq.b` | field allows `in`; ≥2 distinct values | equality |
1464
+ | `filter.nin-complements-in` | `in` ∩ `nin` empty; union = set minus nulls | field allows `in` and `nin` | `in` |
1465
+ | `filter.gte-is-gt-or-eq` | `gte.x` = `gt.x` ∪ `eq.x` | field allows `gte` and `gt`; ordered type | numeric comparison |
1466
+ | `filter.lte-is-lt-or-eq` | `lte.x` = `lt.x` ∪ `eq.x` | field allows `lte` and `lt` | numeric comparison |
1467
+ | `filter.ordered-triple-partitions` | `lt` ∪ `eq` ∪ `gt` = set minus nulls; pairwise disjoint | field allows all three | numeric comparison |
1468
+ | `filter.ilike-is-case-insensitive` | case-flipped value: `ilike` matches; `like` does not (unresolved if both match) | field allows both | like |
1469
+ | `filter.is-null-selects-nulls` | `is.null` = nulls; `is.notnull` = complement | field allows `is`; cohort has a null | foundations |
1470
+ | `filter.contains-membership` | `contains.<el>` = records whose array value includes `el` | array field or ops include `contains` | foundations |
1471
+ | `filter.nested-and-or-distributes` | `and(A,or(B,C))` = (A∩B) ∪ (A∩C) | postgrest grammar; ≥2 filterable fields | `and` / `or` |
1472
+ | `filter.alias-matches-canonical` | each declared alias token selects the same id-set as its target | `aliases` non-empty | equality |
1473
+ | `filter.illegal-op-rejected` | one op **not** in that field's `ops` is 4xx | closed `ops` list | unknown-field |
1474
+ | `filter.empty-in` | `in.()` is 4xx (`reject`) or zero rows (`match-none`) | `emptyIn` set; field allows `in` | `in` |
1475
+ | `filter.in-over-limit-rejected` | `in` list of `maxInValues+1` is 4xx | `maxInValues` set | foundations |
1476
+ | `filter.condition-cap-rejected` | `maxFilterConditions+1` `eq` terms is 4xx | `maxFilterConditions` set | foundations |
1477
+ | `spec.declared-filterable-ops-accepted` | every declared field × every op in that field's `ops` returns <400 | closed `ops` | declared-filterable |
1478
+ | `spec.declared-filterable-illegal-op-rejected` | one illegal op per declared field is 4xx | closed `ops` | illegal-op |
1479
+ | `error.malformed-filter-not-5xx` | garbage filter text is 4xx, never 5xx | a filter expression | — |
1480
+ | `query.filter-selects-from-whole-set` | a filter is applied to the collection, not to the current page | filterable + ≥3 records | list / walk |
1481
+ | `sort.order-is-applied` | requesting a sort actually rearranges the page | order + a sortable field | `pagination.limit-bounds-page-size` |
1482
+ | `sort.reverse-symmetry` | desc is the reverse of asc (nulls included) | order + asc/desc | order-is-applied |
1483
+ | `sort.unknown-field-rejected` | `order` on an undeclared field is 4xx, not silent ignore | order param | — |
1484
+ | `sort.numeric-order-is-numeric` | `1,10,2` sorts as numbers, not text | numeric sortable field whose lexical order disagrees | order-is-applied |
1485
+ | `sort.nulls-first-last` | `nullsfirst` / `nullslast` put nulls at the start / end of asc | declared nulls token; dotted grammar; cohort has a null | order + reverse |
1486
+ | `sort.multi-key-tiebreak` | ties on the first key are ordered by the second | ≥2 sortable fields; `maxKeys` absent or ≥2 | order + reverse |
1487
+ | `sort.default-order-applied` | omitting `order` matches `defaultOrder` | `defaultOrder` set; walk complete | order + walk |
1488
+ | `sort.stable-tiebreak` | the same `order` twice yields the same sequence | `stableTiebreak` set | order-is-applied |
1489
+ | `spec.declared-sortable-nulls-accepted` | each declared nulls token returns <400 | some field/global declares `nulls` | order-is-applied |
1490
+ | `search.q-narrows-result` | a search term that matches one record does not return the whole set | search param + searchable fields | `list.read-after-write` |
1491
+ | `search.tokens-and` | `q=a b` = intersection (AND). Sameness of AND and OR passes | searchable fields; two tokens that split the cohort | `q-narrows` |
1492
+ | `search.case-insensitive` | case-flipped token matches the same set (unresolved if the backend is case-sensitive) | searchable field with a letter | `q-narrows` |
1493
+ | `search.empty-q` | `q=` is ignore / match-all / reject as declared | `searchEmpty` set | `q-narrows` |
1494
+ | `search.undeclared-field-not-required` | extra recall on a non-searchable field is not `SEARCH_IGNORED` | ≥1 searchable and ≥1 non-searchable string | `q-narrows` |
1495
+ | `search.mode-accepted` | each declared `searchModes` value is <400 on the mode param | `searchModes` set and a mode role | — |
1496
+ | `search.modes-differ` | two modes may differ; exact sameness is unresolved, not a fail | ≥2 `searchModes`; a mode param | `mode-accepted` |
1497
+ | `select.projection-honoured` | `select=id,name` does not return undeclared fields | select param | — |
1498
+ | `select.requested-fields-present` | every name in `select=` appears on each returned item | select param; ≥1 selectable field | projection |
1499
+ | `select.unknown-field-rejected` | unknown select name is 4xx (`reject`) or dropped (`ignore`) | `select.unknown` set | projection |
1500
+ | `select.nested-honoured` | `rel(col)` returns `rel` as an object/array carrying only `col` | `select.nested` and a named relation | projection |
1501
+ | `count.consistent-with-returned-page` | envelope total ≥ rows on this page, and is not zero when the page is not | envelope total | `list.read-after-write` |
1502
+ | `count.matches-filtered-set` | filtered total equals the size of the filtered walk | total + a filter | list, equality |
1503
+ | `query.axes-compose` | filter + sort together: filter still holds on the sorted page | filterable + sortable | filter + sort foundations |
1504
+ | `query.filter-and-select-compose` | filter + select together | filterable + select | same |
1505
+ | `query.search-and-filter-compose` | search + filter together | filterable + search | same |
1506
+ | `query.filter-sort-select-compose` | filter + sort + select | filter + sort + select | same |
1507
+ | `query.filter-search-sort-compose` | filter + search + sort | filter + search + sort | same |
1508
+ | `query.filter-search-select-compose` | filter + search + select | filter + search + select | same |
1509
+ | `query.sort-and-select-compose` | sort + select: order holds; extras dropped; requested fields present | sortable + selectable | sort + select |
1510
+ | `query.search-and-select-compose` | search + select: search membership holds; extras dropped | searchable + selectable | search + select |
1511
+ | `query.search-and-sort-compose` | search + sort: search membership holds; remaining rows ordered | searchable + sortable | search + sort |
1512
+ | `query.filter-search-sort-select-compose` | all four: filter ∩ search; order holds; extras dropped | all four axes declared | the triples |
1513
+ | `spec.declared-filterable-is-filterable` | every `x-query.filterable` field actually accepts a filter | `x-query` naming filterable fields | filter foundations |
1514
+ | `spec.declared-sortable-is-sortable` | every `x-query.sortable` field actually accepts a sort | `x-query` naming sortable fields | sort foundations |
1515
+ | `spec.declared-selectable-is-selectable` | every `x-query.selectable` field actually accepts a select | `x-query` naming selectable fields | select |
1516
+ | `tenant.item-not-readable-cross-tenant` | principal B cannot GET principal A's item | second principal, different `roots`, and a tagged or inferred tenant | — |
1517
+ | `tenant.denial-does-not-reveal-existence` | 404 vs 403 (or equivalent) does not distinguish "exists other tenant" from "missing" | second principal, and a tagged or inferred tenant | `tenant.item-not-readable-cross-tenant` |
1518
+ | `tenant.filter-does-not-bypass-scope` | `filter=id.eq.<other tenant>` does not return that row | second principal + a filter | `query.filter-selects-from-whole-set` |
1519
+ | `auth.rank-is-monotonic` | a lower rank cannot do what a higher rank is denied | two same-tenant principals at different `rank` | `list.read-after-write` |
1520
+ | `auth.invite-grants-then-revokes` | invite → accept grants; revoke takes it back | `x-invite` + peer with `inviteAs` | list, cross-tenant |
1521
+ | `patch.immutable-field-rejected` | PATCHing an `x-immutable` field is rejected or ignored | `x-immutable` | — |
1522
+ | `softdelete.absent-from-default-list` | a soft-deleted row is gone from the default list | `x-soft-delete` | `list.read-after-write` |
1523
+ | `invalidation.declared-route-changes` | after a write, the other entity's listed route actually changes | `x-invalidate` naming another entity | list, persist |
1524
+ | `effects.declared-effect-occurs` | `x-effects` cardinality delta is observed on the named list | `x-effects` | `list.read-after-write` |
1525
+ | `effects.side-effect-arrives` | after the write, the named GET’s JSON path is occupied before `timeoutMs` | `x-wait` | `list.read-after-write` |
1526
+ | `async.reaches-terminal-state` | `x-async` reaches `until` (poll, or a terminal SSE frame) before `timeoutMs` | `x-async` | — |
1527
+ | `async.receipt-identifies-the-job` | `idFrom` on the receipt (JSON object or SSE event JSON) resolves to a pollable job | `x-async` + `idFrom` | — |
1528
+ | `patch.minimality` | PATCH `{ name }` does not clear other writable fields | update + item route | — |
1529
+ | `idempotency.replay-does-not-duplicate` | same Idempotency-Key + same body does not create a second row | create + documented Idempotency-Key header | list, persist |
1530
+ | `delete.absent-record-returns-404` | DELETE of a missing id is 404, not 200 | delete | — |
1531
+ | `concurrency.no-lost-update` | two PATCHes to different fields do not clobber each other | update + two writable strings | persist + patch |
1532
+ | `validation.enum-enforced` | a value outside the enum is rejected | enum in the request schema | — |
1533
+ | `validation.max-length-enforced` | a string over `maxLength` is rejected | maxLength | — |
1534
+ | `validation.required-enforced` | omitting a required field is rejected | required field | — |
1535
+ | `validation.content-type-enforced` | a wrong Content-Type is 415 when 415 is documented | documented 415 | — |
1536
+ | `consistency.projections-agree` | list, item, and filtered views of the same field agree | item route + a comparable field | list + persist + filter |
1384
1537
 
1385
1538
  On a typical untagged CRUD document (create, list, item, `page`/`limit`, maybe `sort`):
1386
1539
 
@@ -1404,6 +1557,12 @@ SECURITY table GET /tables/{id} readable with the other tenant's key
1404
1557
  SPEC_BUG table x-query.filterable lists "ghost"; filter=ghost.eq.x is 400
1405
1558
  spec.declared-filterable-is-filterable
1406
1559
 
1560
+ BACKEND_BUG table in() is not the union of the equalities it lists
1561
+ filter.in-is-union-of-eq
1562
+
1563
+ BACKEND_BUG table an illegal filter operator is accepted
1564
+ filter.illegal-op-rejected
1565
+
1407
1566
  COVERAGE_GAP batch no x-async; receipt treated as the result
1408
1567
  async.reaches-terminal-state
1409
1568
  ```
@@ -1448,19 +1607,26 @@ Console (stdout) after a run:
1448
1607
 
1449
1608
  ## Reports
1450
1609
 
1451
- Written under `--out` (default `./oat-out`):
1610
+ Written under `--out` (default `./.oat/runs`). Each invocation creates a UTC timestamp folder and points `latest` at it:
1452
1611
 
1453
- | file | |
1454
- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
1455
- | `oat-report.md` | human report: summary, findings with request/response excerpts, coverage, latency p50/p95/max |
1456
- | `oat-report.json` | same data for CI |
1457
- | `matrix.html` | visual matrix of entities × checks |
1458
- | `matrix.json` | the same graph (AI-friendly), including a mermaid string |
1459
- | `issue-repro/*.sh` | one executable `curl` script per finding that has evidence. Directory is omitted when the run is clean. Old `repro/` is deleted at the start of each run |
1460
- | `progress.log` | logfmt, one event per line, never truncated |
1461
- | `progress.jsonl` | same events as JSON |
1462
- | `progress.tsv` | same columns, tab-separated |
1463
- | `progress.json` | latest snapshot only (overwritten ~1s) |
1612
+ ```
1613
+ .oat/runs/2026-08-18T12-00-00Z/
1614
+ .oat/runs/latest -> 2026-08-18T12-00-00Z
1615
+ ```
1616
+
1617
+ `--out` / `outDir` replace the root, not the leaf — `--out .oat/runs/prod` writes `.oat/runs/prod/<datetime>/`.
1618
+
1619
+ | file | |
1620
+ | ------------------ | ------------------------------------------------------------------------------------------------------ |
1621
+ | `oat-report.md` | human report: summary, findings with request/response excerpts, coverage, latency p50/p95/max |
1622
+ | `oat-report.json` | same data for CI |
1623
+ | `matrix.html` | visual matrix of entities × checks |
1624
+ | `matrix.json` | the same graph (AI-friendly), including a mermaid string |
1625
+ | `issue-repro/*.sh` | one executable `curl` script per finding that has evidence. Directory is omitted when the run is clean |
1626
+ | `progress.log` | logfmt, one event per line, never truncated |
1627
+ | `progress.jsonl` | same events as JSON |
1628
+ | `progress.tsv` | same columns, tab-separated |
1629
+ | `progress.json` | latest snapshot only (overwritten ~1s) |
1464
1630
 
1465
1631
  There is no HAR file. The JSON report and issue-repro scripts carry the exchanges.
1466
1632
 
@@ -1585,25 +1751,27 @@ curl -sS -X PATCH "$BASE/v1/products/p1" \
1585
1751
 
1586
1752
  `progress.log` starts with a glossary. Fields:
1587
1753
 
1588
- | key | |
1589
- | -------------------------- | ------------------------------------------------------------ |
1590
- | `ts` | ISO-8601 UTC when the line was written |
1591
- | `status` | `ok` or `stall` (`idle_ms` ≥ 15000) |
1592
- | `done` / `total` | entity index / how many entities |
1593
- | `phase` | `load` \| `auth` \| `seed` \| `test` \| `teardown` \| `done` |
1594
- | `entity` / `check` | current entity and check id |
1595
- | `msg` | phase note |
1596
- | `req` / `find` | request count, finding count |
1597
- | `method` / `path` / `http` | last HTTP call |
1598
- | `last_ms` | duration of that call |
1599
- | `idle_ms` | ms since it returned — if this climbs, the run is stuck |
1600
- | `elapsed_ms` | wall clock since start |
1754
+ | key | |
1755
+ | -------------------------- | -------------------------------------------------------------------------------- |
1756
+ | `ts` | ISO-8601 UTC when the line was written |
1757
+ | `status` | `ok`, `stall` (`idle_ms` ≥ 15000 after the last call returned), or `in_flight` |
1758
+ | `done` / `total` | entity index / how many entities |
1759
+ | `phase` | `load` \| `auth` \| `seed` \| `test` \| `teardown` \| `done` |
1760
+ | `entity` / `check` | current entity and check id |
1761
+ | `msg` | phase note. In-flight is `status`, not `msg` |
1762
+ | `req` / `find` | request count, finding count |
1763
+ | `method` / `path` / `http` | last completed call, or the in-flight call when `status=in_flight` (`http=-`) |
1764
+ | `last_ms` | duration of the last completed call. `-` while `in_flight` |
1765
+ | `idle_ms` | while `in_flight`: ms since this request started. otherwise ms since it returned |
1766
+ | `elapsed_ms` | wall clock since start |
1601
1767
 
1602
1768
  `--quiet` keeps the files and drops stderr.
1603
1769
 
1604
- Lines are written when the phase/entity/check/message changes, or every 2 s, or on `load`/`done`. `progress.json` is rewritten about once a second.
1770
+ Every HTTP call emits a **start** line (`status=in_flight`, `http=-`, `last_ms=-`) when `fetch` is issued, then a **completed** line when it returns (`http=200`, `last_ms` ≈ wall time, `idle_ms=0`). The 5 s heartbeat keeps the in-flight path and climbs `idle_ms` until the call returns; after return it climbs on the completed call (and becomes `stall` at 15 s). A 300 s `POST /extract` therefore stays `/extract` in the log, not the previous refresh or GET.
1771
+
1772
+ Lines are also written when the phase/entity/check/message changes, or every 2 s, or on `load`/`done`. `progress.json` is rewritten about once a second.
1605
1773
 
1606
- If `idle_ms` climbs through a long poll (`x-async`) that is expected. If it climbs on a simple GET, the process or the network is stuck. oat's own `fetch` has **no timeout**.
1774
+ If `idle_ms` climbs through a long poll (`x-async`) that is expected. If it climbs on a simple GET while `status` is not `in_flight`, the process or the network is stuck. oat's own `fetch` has **no timeout**.
1607
1775
 
1608
1776
  ## Programmatic API
1609
1777
 
@@ -1632,7 +1800,7 @@ const result = await run({
1632
1800
  seed: 1,
1633
1801
  maxInFlight: 4,
1634
1802
  onProgress: (snap) => {
1635
- // snap.phase, snap.entity, snap.check, snap.message, …
1803
+ // snap.phase, snap.entity, snap.check, snap.inflight, snap.last, snap.message, …
1636
1804
  },
1637
1805
  })
1638
1806
 
@@ -1652,7 +1820,7 @@ const { doc: resolved, externalRefs } = dereference(doc)
1652
1820
  const model = buildModel(resolved)
1653
1821
  ```
1654
1822
 
1655
- Types exported: `OatConfig`, `Principal`, `AuthFlow`, `AuthRefresh`, `AuthStep`, `HookAuth`, `Hooks`, `Uploads`, `UploadRequest`, `UploadFile`, `HeaderRequest`, `InputRequest`, `OriginSpec`, `OutOfBandConfig`, `RunOptions`, `RunResult`, `Finding`, `Verdict`, `Actor`, `SpecModel`, `EntityModel`, `OperationModel`, `OpenApiDocument`, `AuthRefreshRequiredError`, `loadPersistedPrincipals`, `worstCaseWaitMs`, matrix types.
1823
+ Types exported: `OatConfig`, `Principal`, `AuthFlow`, `AuthRefresh`, `AuthStep`, `HookAuth`, `Hooks`, `Uploads`, `UploadRequest`, `UploadFile`, `HeaderRequest`, `InputRequest`, `OriginSpec`, `OutOfBandConfig`, `RunOptions`, `RunResult`, `Finding`, `Verdict`, `Actor`, `SpecModel`, `EntityModel`, `OperationModel`, `OpenApiDocument`, `AuthRefreshRequiredError`, `loadPersistedPrincipals`, `worstCaseWaitMs`, `allocateRunDir`, `DEFAULT_RUNS_ROOT`, matrix types.
1656
1824
 
1657
1825
  ## CI
1658
1826
 
@@ -1672,7 +1840,7 @@ Against **your** API:
1672
1840
  API_TOKEN_B: ${{ secrets.API_TOKEN_B }}
1673
1841
  ```
1674
1842
 
1675
- Gate on exit code 1 (defects). Read `oat-out/oat-report.json` if you need to classify verdicts.
1843
+ Gate on exit code 1 (defects). Read `.oat/runs/latest/oat-report.json` if you need to classify verdicts.
1676
1844
 
1677
1845
  Wipe leftover rows on a shared database between runs if a previous `--keep-fixtures` or a crashed teardown left data. Leftover rows make numeric/filter checks look like type bugs (`1, 10, 2` from old TEXT-sorted leftovers mixed with a fresh numeric cohort).
1678
1846
 
@@ -1719,6 +1887,14 @@ Comma-separated. Each is one named lie the demo API can tell. Primary check is w
1719
1887
  | `SPEC_OVERCLAIMS_FILTERABLE` | `spec.declared-filterable-is-filterable` | `x-query` lists a field that 400s |
1720
1888
  | `SPEC_OVERCLAIMS_SORTABLE` | `spec.declared-sortable-is-sortable` | same for sort |
1721
1889
  | `SPEC_OVERCLAIMS_SELECTABLE` | `spec.declared-selectable-is-selectable` | same for select |
1890
+ | `FILTER_IN_FIRST_ONLY` | `filter.in-is-union-of-eq` | `in.(a,b)` matches only the first value |
1891
+ | `FILTER_GTE_IS_GT` | `filter.gte-is-gt-or-eq` | `gte` is compiled as `gt` |
1892
+ | `FILTER_ILIKE_IS_LIKE` | `filter.ilike-is-case-insensitive` | `ilike` is compiled as `like` |
1893
+ | `FILTER_IS_NULL_MATCHES_ALL` | `filter.is-null-selects-nulls` | `is.null` matches every row |
1894
+ | `FILTER_ILLEGAL_OP_IGNORED` | `filter.illegal-op-rejected` | an operator outside the allowlist is ignored |
1895
+ | `SORT_NUMERIC_AS_TEXT` | `sort.numeric-order-is-numeric` | a numeric field is ordered lexicographically |
1896
+ | `SORT_MULTI_KEY_IGNORED` | `sort.multi-key-tiebreak` | the second sort key is ignored |
1897
+ | `SELECT_FIELD_MISSING` | `select.requested-fields-present` | a requested select field is dropped |
1722
1898
  | `CROSS_TENANT_READ` | `tenant.item-not-readable-cross-tenant` | item GET is global by id |
1723
1899
  | `EXISTENCE_LEAK_VIA_STATUS` | `tenant.denial-does-not-reveal-existence` | 403 vs 404 reveals the other tenant's row |
1724
1900
  | `TENANT_LEAK_VIA_FILTER` | `tenant.filter-does-not-bypass-scope` | filter drops the tenant predicate |
@@ -1754,7 +1930,7 @@ oat run --config labs/local.config.ts --base-url <url>
1754
1930
 
1755
1931
  These are deliberate. An agent should not invent a flag for them.
1756
1932
 
1757
- - **No request timeout.** `fetch` waits until the server answers. Watch `idle_ms`.
1933
+ - **No request timeout.** `fetch` waits until the server answers. Watch `status=in_flight` and `idle_ms` on that request.
1758
1934
  - **No retry on 5xx / 429.** One 401 → force refresh + single retry. A second 401 is evidence.
1759
1935
  - **No OpenAPI `security`.** Put credentials in `principals`. Cookie auth is a `headers: { cookie: "…" }` (or a flow that sets that header).
1760
1936
  - **No `servers[]`.** Always set `baseUrl`. Extra hosts are `origins[]`, each with its own `spec`.