@lovrozagar/oat 0.7.3 → 0.7.5

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 (59) hide show
  1. package/README.md +181 -136
  2. package/dist/cli.js +19 -1
  3. package/dist/config/define-config.d.ts +29 -4
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/report/console.d.ts.map +1 -1
  7. package/dist/report/console.js +1 -0
  8. package/dist/report/console.js.map +1 -1
  9. package/dist/report/matrix.d.ts.map +1 -1
  10. package/dist/report/matrix.js +4 -1
  11. package/dist/report/matrix.js.map +1 -1
  12. package/dist/report/render.d.ts +4 -0
  13. package/dist/report/render.d.ts.map +1 -1
  14. package/dist/report/render.js +12 -2
  15. package/dist/report/render.js.map +1 -1
  16. package/dist/runtime/body.d.ts.map +1 -1
  17. package/dist/runtime/body.js +18 -2
  18. package/dist/runtime/body.js.map +1 -1
  19. package/dist/runtime/checks.d.ts.map +1 -1
  20. package/dist/runtime/checks.js +199 -123
  21. package/dist/runtime/checks.js.map +1 -1
  22. package/dist/runtime/client.d.ts +7 -1
  23. package/dist/runtime/client.d.ts.map +1 -1
  24. package/dist/runtime/client.js +3 -1
  25. package/dist/runtime/client.js.map +1 -1
  26. package/dist/runtime/effects.d.ts +43 -0
  27. package/dist/runtime/effects.d.ts.map +1 -0
  28. package/dist/runtime/effects.js +141 -0
  29. package/dist/runtime/effects.js.map +1 -0
  30. package/dist/runtime/exchanges.d.ts +37 -0
  31. package/dist/runtime/exchanges.d.ts.map +1 -0
  32. package/dist/runtime/exchanges.js +298 -0
  33. package/dist/runtime/exchanges.js.map +1 -0
  34. package/dist/runtime/finding.d.ts +6 -4
  35. package/dist/runtime/finding.d.ts.map +1 -1
  36. package/dist/runtime/finding.js +13 -8
  37. package/dist/runtime/finding.js.map +1 -1
  38. package/dist/runtime/run.d.ts +9 -0
  39. package/dist/runtime/run.d.ts.map +1 -1
  40. package/dist/runtime/run.js +17 -1
  41. package/dist/runtime/run.js.map +1 -1
  42. package/dist/runtime/upload-each.d.ts +14 -0
  43. package/dist/runtime/upload-each.d.ts.map +1 -0
  44. package/dist/runtime/upload-each.js +69 -0
  45. package/dist/runtime/upload-each.js.map +1 -0
  46. package/dist/runtime/upload.d.ts +8 -0
  47. package/dist/runtime/upload.d.ts.map +1 -1
  48. package/dist/runtime/upload.js +26 -4
  49. package/dist/runtime/upload.js.map +1 -1
  50. package/dist/runtime/world.d.ts.map +1 -1
  51. package/dist/runtime/world.js +57 -27
  52. package/dist/runtime/world.js.map +1 -1
  53. package/dist/spec/extensions.d.ts +4 -1
  54. package/dist/spec/extensions.d.ts.map +1 -1
  55. package/dist/spec/extensions.js +8 -2
  56. package/dist/spec/extensions.js.map +1 -1
  57. package/dist/spec/graph.js +1 -1
  58. package/dist/spec/graph.js.map +1 -1
  59. package/package.json +1 -1
package/README.md CHANGED
@@ -103,7 +103,7 @@ The first principal is the writer. Isolation needs a second principal with diffe
103
103
 
104
104
  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.
105
105
 
106
- 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 / `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.
106
+ 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.
107
107
 
108
108
  ## Quick start
109
109
 
@@ -255,16 +255,17 @@ 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/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 |
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
+ | `--save-exchanges` / `--no-save-exchanges` | on unless `--profile cheap` | persist every HTTP exchange under the run dir (`exchanges.jsonl`, `exchanges/`, `blobs/`) |
268
269
 
269
270
  **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
271
 
@@ -426,7 +427,7 @@ export default defineConfig({
426
427
  baseUrl: "https://api.example.com",
427
428
  principals: [/* at least one; see below */],
428
429
  hooks: {/* optional */},
429
- uploads: { pool: ["./fixtures/**/*"] },
430
+ uploads: { pool: ["./fixtures/**/*"], eachMax: 24 },
430
431
  globalHeaders: { "x-request-id": "oat" }, // sent on every request; oat does not inspect them
431
432
  roots: { org_id: "org_shared" }, // path params oat cannot create; also declarable via x-root
432
433
  seed: 42, // fixture generation; a failing run with the same seed is identical
@@ -435,6 +436,7 @@ export default defineConfig({
435
436
  only: ["store", "product"], // restrict entities
436
437
  keepFixtures: false,
437
438
  outDir: "./.oat/runs",
439
+ saveExchanges: true, // default on unless --profile cheap; --quiet does not turn this off
438
440
  outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 },
439
441
  origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
440
442
  query: {
@@ -462,7 +464,7 @@ export default defineConfig({
462
464
  | `baseUrl` | yes | | Primary origin. OpenAPI `servers[]` is ignored |
463
465
  | `principals` | yes | | Non-empty. First is the writer |
464
466
  | `hooks` | no | | See [Hooks](#hooks) |
465
- | `uploads` | no | | `pool` globs, relative to the config file. JSON configs: pool only |
467
+ | `uploads` | no | | `pool` globs; optional `each` (operationId → globs) and `eachMax`. JSON configs may set all three |
466
468
  | `globalHeaders` | no | `{}` | Merged first. `resolveHeaders` then caller headers then auth |
467
469
  | `origins` | no | `[]` | Extra `{ id, baseUrl, spec }` hosts. Auth JWT is reused. Do not merge those routes into `spec` |
468
470
  | `outOfBand` | no | `{ attempts: 6, initialMs: 200, maxMs: 3000 }` | Backoff for `resolveOutOfBand` and `resolvePrincipalAuth`. See [Hooks](#hooks) |
@@ -473,12 +475,13 @@ export default defineConfig({
473
475
  | `only` | no | all | Entity names from `oat plan` |
474
476
  | `keepFixtures` | no | `false` | Skip DELETE at the end |
475
477
  | `outDir` | no | `./.oat/runs` | History root. Each run writes `<outDir>/<datetime>/` and updates `latest`. Also writes `principals.json` after acquire |
478
+ | `saveExchanges` | no | on unless `profile` is `cheap` | Persist every HTTP exchange under the run dir. `--save-exchanges` / `--no-save-exchanges` override. `--quiet` does not |
476
479
  | `query` | no | | Global query-catalog defaults. Overlay after `x-query`. Does not invent operators |
477
480
  | `entities` | no | | Per-entity overlays. This release only reads `query`. Unknown names are ignored; `doctor` warns |
478
481
 
479
482
  `spec` may be a path relative to `baseUrl` (`/v1/openapi/spec`) or an absolute URL or a file.
480
483
 
481
- CLI `--base-url`, `--only`, `--seed`, `--out`, `--max-in-flight`, `--keep-fixtures` override these when passed.
484
+ CLI `--base-url`, `--only`, `--seed`, `--out`, `--max-in-flight`, `--keep-fixtures`, `--save-exchanges` / `--no-save-exchanges` override these when passed.
482
485
 
483
486
  ### Spec loading
484
487
 
@@ -837,9 +840,12 @@ New surface still has to be listed. oat will not silently enable `in` / `ilike`
837
840
 
838
841
  Multipart and binary parts are filled in this order:
839
842
 
840
- 1. `hooks.resolveUpload` — a non-null return wins (`UploadFile` replaces that field; `{ fields }` replaces the whole request).
841
- 2. `uploads.pool` — first file whose extension / sniffed type matches the part's `contentMediaType`. Same `seed` + field + index → same pick.
842
- 3. A tiny dummy with sniffable magic (`%PDF-1.1`, 1×1 PNG, empty zip, …). Unknown types become 16 octet-stream bytes, not a skip.
843
+ 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.
844
+ 2. `uploads.each` fixture for this invocation, when that operation is listed.
845
+ 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.
846
+ 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.
847
+
848
+ `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.
843
849
 
844
850
  ```ts
845
851
  export default defineConfig({
@@ -847,13 +853,17 @@ export default defineConfig({
847
853
  baseUrl: "https://api.example.com",
848
854
  principals: [/* … */],
849
855
  uploads: {
850
- pool: ["./fixtures/docs/**/*", "./fixtures/images/*.png"],
856
+ pool: ["./fixtures/**/*"],
857
+ each: {
858
+ "extract.once": ["./fixtures/**/*"],
859
+ "extract.stream": ["./fixtures/**/*"],
860
+ },
861
+ eachMax: 24,
851
862
  },
852
863
  hooks: {
853
- resolveUpload: async ({ operationId, field }) => {
864
+ resolveUpload: async ({ operationId, field, fixture }) => {
854
865
  if (operationId === "extract.once" && field === "file") {
855
- const bytes = await Deno.readFile("./invoices/known.pdf")
856
- return { bytes, filename: "known.pdf", mediaType: "application/pdf" }
866
+ return { fields: { columns: "vendor,date,amount" } }
857
867
  }
858
868
  return null
859
869
  },
@@ -861,9 +871,21 @@ export default defineConfig({
861
871
  })
862
872
  ```
863
873
 
864
- A missing pool path warns once and falls through. An empty match uses a dummy. The run does not fail. JSON configs may set `uploads.pool` only — `resolveUpload` is TypeScript.
874
+ | case | outcome |
875
+ | ------------------------------- | ----------------------------------------------------------- |
876
+ | `each` omitted | pick-one (today) |
877
+ | glob matches 0 files | warn once, no extra invocations, fall through to pool/dummy |
878
+ | one path in the list is missing | drop that slot, warn once, never `BACKEND_BUG` |
879
+ | `eachMax` < match count | first `eachMax` after sort, warn that it capped |
880
+ | fixture unreadable | drop that slot, warn once, not `BACKEND_BUG` |
881
+
882
+ 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.
883
+
884
+ `--profile cheap` (or any profile that excludes the op) still drops the whole family. `each` does not punch through a profile.
865
885
 
866
- oat does not OCR. It sends bytes and checks HTTP / JSON. A 4xx because the dummy is “not a real invoice” is not automatically a backend defect if the dummy matched the declared `contentMediaType`.
886
+ Findings from an `each` invocation carry `fixture: "invoice.pdf"` and render as `extract.once · invoice.pdf`. One 5xx is one finding on that file.
887
+
888
+ 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`.
867
889
 
868
890
  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.
869
891
 
@@ -1250,11 +1272,20 @@ A spec may leave both `x-async` and `text/event-stream` on the same operation. T
1250
1272
 
1251
1273
  ```yaml
1252
1274
  x-effects:
1253
- - { entity: table, op: create, count: 1 }
1254
- - { entity: delivery, op: append, count: 1 }
1275
+ - { entity: table, op: create }
1276
+ - { entity: row, op: append, min: 1 }
1255
1277
  ```
1256
1278
 
1257
- `op`: `create` | `append` | `update` | `delete` | `replace`. oat asserts an exact cardinality delta on the named entity's list. `count` defaults to `1` when omitted.
1279
+ `op`: `create` | `append` | `update` | `delete` | `replace`. Each item is `{ entity, op, count?, min? }`.
1280
+
1281
+ - `count` is an **exact** cardinality delta on that entity's list.
1282
+ - `min` is **at-least** (`delta >= min` and `added.length >= min`). Use this when the child count is data-dependent (an extract that appends 1 / 2 / 5 rows).
1283
+ - Omit both → `count: 1` (invite / create).
1284
+ - Set both on one item → rejected at load / `oat doctor` (`x-effects` gap). The item is not checked.
1285
+
1286
+ After a write that declares `create` on A, oat binds the new A id from the write response (`table_id`, or the entity identity) or from A's list delta — the same adopt idea as a 402 plan-limit reuse. Later items in **that same** `x-effects` array whose child list is under A (`GET .../tables/{table_id}/rows`) fill the path with that id. `x-wait` after the same write uses the same bound id. No tester hook.
1287
+
1288
+ An extract-shaped write (create table + append ≥1 row) fails when the new table's row list is empty, and passes when it has 5.
1258
1289
 
1259
1290
  **Fallback:** derived from `x-entity.action` for this entity only.
1260
1291
 
@@ -1270,7 +1301,7 @@ x-wait:
1270
1301
 
1271
1302
  Put this on the **write**. After that write succeeds, oat polls `operationId` until `until` (JSON path `$.items.0` or JSON pointer `/items/0`) is non-empty, or `hooks.awaitSideEffect` returns `true`. Default `timeoutMs` is **30s**. Timeout is a **finding** (`effects.side-effect-arrives`), not a coverage gap.
1272
1303
 
1273
- Use this for queue consumers and webhook inboxes (1–30 s), not for the same request. `x-effects` still asserts cardinality; `x-wait` asserts “this other GET eventually has a body”.
1304
+ Use this for queue consumers and webhook inboxes (1–30 s), not for the same request. `x-effects` still asserts cardinality; `x-wait` asserts “this other GET eventually has a body”. If the poll path needs a parent id the write created, oat binds it the same way `x-effects` does.
1274
1305
 
1275
1306
  **Fallback:** the check does not run.
1276
1307
 
@@ -1405,6 +1436,8 @@ Exclusion applies to every invocation, not only create/read/update/delete. `effe
1405
1436
 
1406
1437
  The run summary states what a profile skipped: `skipped 12 operation(s) under --profile cheap (12 high-cost)` — the same principle as `did not apply` for a coverage gap: a report that only shows what ran invites the reader to assume the rest was verified.
1407
1438
 
1439
+ `--profile cheap` also defaults the exchange journal **off**. `--save-exchanges` turns it back on; `--quiet` does not change it.
1440
+
1408
1441
  ### `x-rate-limit` — pacing oat's own traffic
1409
1442
 
1410
1443
  ```yaml
@@ -1438,102 +1471,102 @@ Order is fixed (foundations first) so cascade suppression has a cause to point a
1438
1471
 
1439
1472
  `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.
1440
1473
 
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, and a tagged or inferred tenant | `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 |
1474
+ | id | asserts | needs | depends |
1475
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | --------------------------------------- |
1476
+ | `list.read-after-write` | a just-created record appears on the list | create + a seeded record | — |
1477
+ | `create.persists-submitted-fields` | every writable field sent on create is echoed | create that echoes the record | `list.read-after-write` |
1478
+ | `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` |
1479
+ | `create.status-matches-document` | create status is one the document declared | create | — |
1480
+ | `response.status-is-documented` | every non-create exchange returns a status that operation names (`default` ≠ 201) | a modeled non-create operation oat invoked | — |
1481
+ | `schema.success-response-matches-document` | create body validates against the success schema | success schema on create | `create.status-matches-document` |
1482
+ | `schema.error-response-matches-document` | an error body validates against the documented error schema | error schema on the item route | — |
1483
+ | `pagination.limit-bounds-page-size` | page size ≤ the requested limit | page-size _role_ (aliases include `limit`, `per_page`, …) | `list.read-after-write` |
1484
+ | `pagination.limit-respects-documented-max` | requesting more than `maxLimit` does not return more | declared `maxLimit` and a larger cohort | `pagination.limit-bounds-page-size` |
1485
+ | `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` |
1486
+ | `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` |
1487
+ | `pagination.cursor-agrees-with-page` | cursor walk and page walk yield the same set | both cursor and page | `pagination.limit-bounds-page-size` |
1488
+ | `filter.unknown-field-rejected` | a filter on a field that does not exist is not silently ignored | a filter expression | — |
1489
+ | `filter.equality-selects-exactly-one` | `id.eq.<one>` returns that one record | equality on the identity | `list.read-after-write` |
1490
+ | `filter.zero-match-returns-none` | a filter that matches nothing returns an empty page, not the whole set | same | `list.read-after-write` |
1491
+ | `filter.negation-partitions-the-set` | `eq` ∪ `neq` = whole set, intersection empty | eq and neq | `list.read-after-write`, equality |
1492
+ | `filter.and-composes-as-intersection` | `and(A,B)` = A ∩ B | two filterable fields + AND | equality / list |
1493
+ | `filter.or-composes-as-union` | `or(A,B)` = A ∪ B | `or()` — **postgrest grammar only** | equality / list |
1494
+ | `filter.like-metacharacters-escaped` | `%` `_` `*` in a value are literals, not wildcards | like operator | `list.read-after-write` |
1495
+ | `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 |
1496
+ | `filter.in-is-union-of-eq` | `in.(a,b)` = `eq.a` ∪ `eq.b` | field allows `in`; ≥2 distinct values | equality |
1497
+ | `filter.nin-complements-in` | `in` ∩ `nin` empty; union = set minus nulls | field allows `in` and `nin` | `in` |
1498
+ | `filter.gte-is-gt-or-eq` | `gte.x` = `gt.x` ∪ `eq.x` | field allows `gte` and `gt`; ordered type | numeric comparison |
1499
+ | `filter.lte-is-lt-or-eq` | `lte.x` = `lt.x` ∪ `eq.x` | field allows `lte` and `lt` | numeric comparison |
1500
+ | `filter.ordered-triple-partitions` | `lt` ∪ `eq` ∪ `gt` = set minus nulls; pairwise disjoint | field allows all three | numeric comparison |
1501
+ | `filter.ilike-is-case-insensitive` | case-flipped value: `ilike` matches; `like` does not (unresolved if both match) | field allows both | like |
1502
+ | `filter.is-null-selects-nulls` | `is.null` = nulls; `is.notnull` = complement | field allows `is`; cohort has a null | foundations |
1503
+ | `filter.contains-membership` | `contains.<el>` = records whose array value includes `el` | array field or ops include `contains` | foundations |
1504
+ | `filter.nested-and-or-distributes` | `and(A,or(B,C))` = (A∩B) ∪ (A∩C) | postgrest grammar; ≥2 filterable fields | `and` / `or` |
1505
+ | `filter.alias-matches-canonical` | each declared alias token selects the same id-set as its target | `aliases` non-empty | equality |
1506
+ | `filter.illegal-op-rejected` | one op **not** in that field's `ops` is 4xx | closed `ops` list | unknown-field |
1507
+ | `filter.empty-in` | `in.()` is 4xx (`reject`) or zero rows (`match-none`) | `emptyIn` set; field allows `in` | `in` |
1508
+ | `filter.in-over-limit-rejected` | `in` list of `maxInValues+1` is 4xx | `maxInValues` set | foundations |
1509
+ | `filter.condition-cap-rejected` | `maxFilterConditions+1` `eq` terms is 4xx | `maxFilterConditions` set | foundations |
1510
+ | `spec.declared-filterable-ops-accepted` | every declared field × every op in that field's `ops` returns <400 | closed `ops` | declared-filterable |
1511
+ | `spec.declared-filterable-illegal-op-rejected` | one illegal op per declared field is 4xx | closed `ops` | illegal-op |
1512
+ | `error.malformed-filter-not-5xx` | garbage filter text is 4xx, never 5xx | a filter expression | — |
1513
+ | `query.filter-selects-from-whole-set` | a filter is applied to the collection, not to the current page | filterable + ≥3 records | list / walk |
1514
+ | `sort.order-is-applied` | requesting a sort actually rearranges the page | order + a sortable field | `pagination.limit-bounds-page-size` |
1515
+ | `sort.reverse-symmetry` | desc is the reverse of asc (nulls included) | order + asc/desc | order-is-applied |
1516
+ | `sort.unknown-field-rejected` | `order` on an undeclared field is 4xx, not silent ignore | order param | — |
1517
+ | `sort.numeric-order-is-numeric` | `1,10,2` sorts as numbers, not text | numeric sortable field whose lexical order disagrees | order-is-applied |
1518
+ | `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 |
1519
+ | `sort.multi-key-tiebreak` | ties on the first key are ordered by the second | ≥2 sortable fields; `maxKeys` absent or ≥2 | order + reverse |
1520
+ | `sort.default-order-applied` | omitting `order` matches `defaultOrder` | `defaultOrder` set; walk complete | order + walk |
1521
+ | `sort.stable-tiebreak` | the same `order` twice yields the same sequence | `stableTiebreak` set | order-is-applied |
1522
+ | `spec.declared-sortable-nulls-accepted` | each declared nulls token returns <400 | some field/global declares `nulls` | order-is-applied |
1523
+ | `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` |
1524
+ | `search.tokens-and` | `q=a b` = intersection (AND). Sameness of AND and OR passes | searchable fields; two tokens that split the cohort | `q-narrows` |
1525
+ | `search.case-insensitive` | case-flipped token matches the same set (unresolved if the backend is case-sensitive) | searchable field with a letter | `q-narrows` |
1526
+ | `search.empty-q` | `q=` is ignore / match-all / reject as declared | `searchEmpty` set | `q-narrows` |
1527
+ | `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` |
1528
+ | `search.mode-accepted` | each declared `searchModes` value is <400 on the mode param | `searchModes` set and a mode role | — |
1529
+ | `search.modes-differ` | two modes may differ; exact sameness is unresolved, not a fail | ≥2 `searchModes`; a mode param | `mode-accepted` |
1530
+ | `select.projection-honoured` | `select=id,name` does not return undeclared fields | select param | — |
1531
+ | `select.requested-fields-present` | every name in `select=` appears on each returned item | select param; ≥1 selectable field | projection |
1532
+ | `select.unknown-field-rejected` | unknown select name is 4xx (`reject`) or dropped (`ignore`) | `select.unknown` set | projection |
1533
+ | `select.nested-honoured` | `rel(col)` returns `rel` as an object/array carrying only `col` | `select.nested` and a named relation | projection |
1534
+ | `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` |
1535
+ | `count.matches-filtered-set` | filtered total equals the size of the filtered walk | total + a filter | list, equality |
1536
+ | `query.axes-compose` | filter + sort together: filter still holds on the sorted page | filterable + sortable | filter + sort foundations |
1537
+ | `query.filter-and-select-compose` | filter + select together | filterable + select | same |
1538
+ | `query.search-and-filter-compose` | search + filter together | filterable + search | same |
1539
+ | `query.filter-sort-select-compose` | filter + sort + select | filter + sort + select | same |
1540
+ | `query.filter-search-sort-compose` | filter + search + sort | filter + search + sort | same |
1541
+ | `query.filter-search-select-compose` | filter + search + select | filter + search + select | same |
1542
+ | `query.sort-and-select-compose` | sort + select: order holds; extras dropped; requested fields present | sortable + selectable | sort + select |
1543
+ | `query.search-and-select-compose` | search + select: search membership holds; extras dropped | searchable + selectable | search + select |
1544
+ | `query.search-and-sort-compose` | search + sort: search membership holds; remaining rows ordered | searchable + sortable | search + sort |
1545
+ | `query.filter-search-sort-select-compose` | all four: filter ∩ search; order holds; extras dropped | all four axes declared | the triples |
1546
+ | `spec.declared-filterable-is-filterable` | every `x-query.filterable` field actually accepts a filter | `x-query` naming filterable fields | filter foundations |
1547
+ | `spec.declared-sortable-is-sortable` | every `x-query.sortable` field actually accepts a sort | `x-query` naming sortable fields | sort foundations |
1548
+ | `spec.declared-selectable-is-selectable` | every `x-query.selectable` field actually accepts a select | `x-query` naming selectable fields | select |
1549
+ | `tenant.item-not-readable-cross-tenant` | principal B cannot GET principal A's item | second principal, different `roots`, and a tagged or inferred tenant | — |
1550
+ | `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` |
1551
+ | `tenant.filter-does-not-bypass-scope` | `filter=id.eq.<other tenant>` does not return that row | second principal, a filter, and a tagged or inferred tenant | `query.filter-selects-from-whole-set` |
1552
+ | `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` |
1553
+ | `auth.invite-grants-then-revokes` | invite → accept grants; revoke takes it back | `x-invite` + peer with `inviteAs` | list, cross-tenant |
1554
+ | `patch.immutable-field-rejected` | PATCHing an `x-immutable` field is rejected or ignored | `x-immutable` | — |
1555
+ | `softdelete.absent-from-default-list` | a soft-deleted row is gone from the default list | `x-soft-delete` | `list.read-after-write` |
1556
+ | `invalidation.declared-route-changes` | after a write, the other entity's listed route actually changes | `x-invalidate` naming another entity | list, persist |
1557
+ | `effects.declared-effect-occurs` | `x-effects` cardinality delta is observed on the named list (`count` exact, `min` at-least; nested child lists bind the created parent id) | `x-effects` | `list.read-after-write` |
1558
+ | `effects.side-effect-arrives` | after the write, the named GET’s JSON path is occupied before `timeoutMs` | `x-wait` | `list.read-after-write` |
1559
+ | `async.reaches-terminal-state` | `x-async` reaches `until` (poll, or a terminal SSE frame) before `timeoutMs` | `x-async` | — |
1560
+ | `async.receipt-identifies-the-job` | `idFrom` on the receipt (JSON object or SSE event JSON) resolves to a pollable job | `x-async` + `idFrom` | — |
1561
+ | `patch.minimality` | PATCH `{ name }` does not clear other writable fields | update + item route | — |
1562
+ | `idempotency.replay-does-not-duplicate` | same Idempotency-Key + same body does not create a second row | create + documented Idempotency-Key header | list, persist |
1563
+ | `delete.absent-record-returns-404` | DELETE of a missing id is 404, not 200 | delete | — |
1564
+ | `concurrency.no-lost-update` | two PATCHes to different fields do not clobber each other | update + two writable strings | persist + patch |
1565
+ | `validation.enum-enforced` | a value outside the enum is rejected | enum in the request schema | — |
1566
+ | `validation.max-length-enforced` | a string over `maxLength` is rejected | maxLength | — |
1567
+ | `validation.required-enforced` | omitting a required field is rejected | required field | — |
1568
+ | `validation.content-type-enforced` | a wrong Content-Type is 415 when 415 is documented | documented 415 | — |
1569
+ | `consistency.projections-agree` | list, item, and filtered views of the same field agree | item route + a comparable field | list + persist + filter |
1537
1570
 
1538
1571
  On a typical untagged CRUD document (create, list, item, `page`/`limit`, maybe `sort`):
1539
1572
 
@@ -1616,19 +1649,31 @@ Written under `--out` (default `./.oat/runs`). Each invocation creates a UTC tim
1616
1649
 
1617
1650
  `--out` / `outDir` replace the root, not the leaf — `--out .oat/runs/prod` writes `.oat/runs/prod/<datetime>/`.
1618
1651
 
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) |
1652
+ | file | |
1653
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
1654
+ | `oat-report.md` | human report: summary, findings with request/response excerpts, coverage, latency p50/p95/max |
1655
+ | `oat-report.json` | same data for CI. Summary only — full exchanges are not inlined here |
1656
+ | `matrix.html` | visual matrix of entities × checks |
1657
+ | `matrix.json` | the same graph (AI-friendly), including a mermaid string |
1658
+ | `issue-repro/*.sh` | one executable `curl` script per finding that has evidence. Directory is omitted when the run is clean |
1659
+ | `progress.log` | logfmt, one event per line, never truncated |
1660
+ | `progress.jsonl` | same events as JSON |
1661
+ | `progress.tsv` | same columns, tab-separated. `req_id` is the join key to the exchange journal |
1662
+ | `progress.json` | latest snapshot only (overwritten ~1s) |
1663
+ | `exchanges.jsonl` | one line per request (`seq`, `requestId`, method, url, status, bytes, …). Greppable |
1664
+ | `exchanges/<requestId>.json` | full exchange: status, headers, described bodies. Missing id → `seq-<n>.json`. Duplicate id → `-<seq>` |
1665
+ | `blobs/<sha256>` | content-addressed file parts and oversized / binary bodies |
1666
+
1667
+ The journal is oat `Exchange` JSON, not HAR (HAR export is out of scope). Default **on** unless `--profile cheap`. `--no-save-exchanges` skips `exchanges/` entirely. `--quiet` does not.
1668
+
1669
+ Bodies are described, never base64 multipart. JSON / text inline up to 256 KiB, then `blobs/<sha256>`. `FormData` file parts and binary downloads (`application/pdf`, spreadsheet, image, `octet-stream`) are always blobs; the same fixture bytes POSTed N times share one file. SSE is stored as parsed `{ event, data }[]` frames.
1670
+
1671
+ Redaction is on by default (not opt-in), applied on write:
1672
+
1673
+ - Headers: `authorization`, `cookie`, `set-cookie`, `proxy-authorization`, and any `x-*-key` / `x-*-secret` / `x-ia-tester-key` (case-insensitive).
1674
+ - JSON bodies, any depth: `access_token`, `refresh_token`, `id_token`, `password`, `token`, `secret`, `api_key` → `"<redacted>"`.
1630
1675
 
1631
- There is no HAR file. The JSON report and issue-repro scripts carry the exchanges.
1676
+ Join `progress.tsv` `req_id` to `exchanges.jsonl` `requestId` (response `x-request-id` / `request-id` / `x-correlation-id` / `correlation-id` wins; else what oat sent). `oat-report.md` includes one line such as `1841 exchanges → exchanges/`.
1632
1677
 
1633
1678
  ### `oat-report.json`
1634
1679
 
@@ -1934,7 +1979,7 @@ These are deliberate. An agent should not invent a flag for them.
1934
1979
  - **No retry on 5xx / 429.** One 401 → force refresh + single retry. A second 401 is evidence.
1935
1980
  - **No OpenAPI `security`.** Put credentials in `principals`. Cookie auth is a `headers: { cookie: "…" }` (or a flow that sets that header).
1936
1981
  - **No `servers[]`.** Always set `baseUrl`. Extra hosts are `origins[]`, each with its own `spec`.
1937
- - **No OCR.** Multipart and file parts are sent as dummy / pool / `resolveUpload` bytes. oat checks HTTP status and JSON responses, not whether a PDF is a real invoice.
1982
+ - **No OCR.** Multipart and file parts are sent as dummy / pool / `each` / `resolveUpload` bytes. oat checks HTTP status and JSON responses, not whether a PDF is a real invoice.
1938
1983
  - **No webhook / callback / link-object following.**
1939
1984
  - **External `$ref`s are not fetched.** In-document `$ref`s are.
1940
1985
  - **`x-idempotent` is not the idempotency check.** The check keys off a documented `Idempotency-Key` header.