@lovrozagar/oat 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/README.md +26 -1936
  2. package/dist/cli.js +326 -60
  3. package/dist/config/define-config.d.ts +15 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/config/validate.d.ts +12 -0
  7. package/dist/config/validate.d.ts.map +1 -0
  8. package/dist/config/validate.js +86 -0
  9. package/dist/config/validate.js.map +1 -0
  10. package/dist/conformance/effects.d.ts +9 -0
  11. package/dist/conformance/effects.d.ts.map +1 -0
  12. package/dist/conformance/effects.js +276 -0
  13. package/dist/conformance/effects.js.map +1 -0
  14. package/dist/conformance/expected-failures.d.ts +18 -0
  15. package/dist/conformance/expected-failures.d.ts.map +1 -0
  16. package/dist/conformance/expected-failures.js +48 -0
  17. package/dist/conformance/expected-failures.js.map +1 -0
  18. package/dist/conformance/feature-gate.d.ts +7 -0
  19. package/dist/conformance/feature-gate.d.ts.map +1 -0
  20. package/dist/conformance/feature-gate.js +428 -0
  21. package/dist/conformance/feature-gate.js.map +1 -0
  22. package/dist/conformance/fuzz.d.ts +108 -0
  23. package/dist/conformance/fuzz.d.ts.map +1 -0
  24. package/dist/conformance/fuzz.js +246 -0
  25. package/dist/conformance/fuzz.js.map +1 -0
  26. package/dist/conformance/leaks.d.ts +10 -0
  27. package/dist/conformance/leaks.d.ts.map +1 -0
  28. package/dist/conformance/leaks.js +23 -0
  29. package/dist/conformance/leaks.js.map +1 -0
  30. package/dist/conformance/pool.d.ts +49 -0
  31. package/dist/conformance/pool.d.ts.map +1 -0
  32. package/dist/conformance/pool.js +75 -0
  33. package/dist/conformance/pool.js.map +1 -0
  34. package/dist/conformance/rate-limit.d.ts +7 -0
  35. package/dist/conformance/rate-limit.d.ts.map +1 -0
  36. package/dist/conformance/rate-limit.js +372 -0
  37. package/dist/conformance/rate-limit.js.map +1 -0
  38. package/dist/conformance/scope.d.ts +15 -0
  39. package/dist/conformance/scope.d.ts.map +1 -0
  40. package/dist/conformance/scope.js +78 -0
  41. package/dist/conformance/scope.js.map +1 -0
  42. package/dist/conformance/seed-contract.d.ts +11 -0
  43. package/dist/conformance/seed-contract.d.ts.map +1 -0
  44. package/dist/conformance/seed-contract.js +680 -0
  45. package/dist/conformance/seed-contract.js.map +1 -0
  46. package/dist/conformance/shapes.d.ts +35 -0
  47. package/dist/conformance/shapes.d.ts.map +1 -0
  48. package/dist/conformance/shapes.js +162 -0
  49. package/dist/conformance/shapes.js.map +1 -0
  50. package/dist/conformance/specs.d.ts +21 -0
  51. package/dist/conformance/specs.d.ts.map +1 -0
  52. package/dist/conformance/specs.js +306 -0
  53. package/dist/conformance/specs.js.map +1 -0
  54. package/dist/conformance/suite.d.ts +209 -0
  55. package/dist/conformance/suite.d.ts.map +1 -0
  56. package/dist/conformance/suite.js +1538 -0
  57. package/dist/conformance/suite.js.map +1 -0
  58. package/dist/conformance/unique.d.ts +7 -0
  59. package/dist/conformance/unique.d.ts.map +1 -0
  60. package/dist/conformance/unique.js +586 -0
  61. package/dist/conformance/unique.js.map +1 -0
  62. package/dist/conformance/worker.d.ts +3 -0
  63. package/dist/conformance/worker.d.ts.map +1 -0
  64. package/dist/conformance/worker.js +27 -0
  65. package/dist/conformance/worker.js.map +1 -0
  66. package/dist/index.d.ts +10 -3
  67. package/dist/index.js +6 -2
  68. package/dist/reference/defects.d.ts +71 -1
  69. package/dist/reference/defects.d.ts.map +1 -1
  70. package/dist/reference/defects.js +74 -8
  71. package/dist/reference/defects.js.map +1 -1
  72. package/dist/reference/dialect.d.ts +6 -4
  73. package/dist/reference/dialect.d.ts.map +1 -1
  74. package/dist/reference/dialect.js +1 -0
  75. package/dist/reference/dialect.js.map +1 -1
  76. package/dist/reference/http.d.ts +17 -25
  77. package/dist/reference/http.d.ts.map +1 -1
  78. package/dist/reference/http.js +574 -224
  79. package/dist/reference/http.js.map +1 -1
  80. package/dist/reference/listing.d.ts +51 -0
  81. package/dist/reference/listing.d.ts.map +1 -0
  82. package/dist/reference/listing.js +256 -0
  83. package/dist/reference/listing.js.map +1 -0
  84. package/dist/reference/model.d.ts +35 -1
  85. package/dist/reference/model.d.ts.map +1 -1
  86. package/dist/reference/model.js +42 -0
  87. package/dist/reference/model.js.map +1 -1
  88. package/dist/reference/query.d.ts +132 -43
  89. package/dist/reference/query.d.ts.map +1 -1
  90. package/dist/reference/query.js +397 -315
  91. package/dist/reference/query.js.map +1 -1
  92. package/dist/reference/shapes.d.ts +60 -0
  93. package/dist/reference/shapes.d.ts.map +1 -0
  94. package/dist/reference/shapes.js +142 -0
  95. package/dist/reference/shapes.js.map +1 -0
  96. package/dist/reference/spec.d.ts +9 -2
  97. package/dist/reference/spec.d.ts.map +1 -1
  98. package/dist/reference/spec.js +291 -71
  99. package/dist/reference/spec.js.map +1 -1
  100. package/dist/reference/store-api.d.ts +14 -43
  101. package/dist/reference/store-api.d.ts.map +1 -1
  102. package/dist/reference/store-api.js +8 -58
  103. package/dist/reference/store-api.js.map +1 -1
  104. package/dist/reference/stores/memory.d.ts +12 -15
  105. package/dist/reference/stores/memory.d.ts.map +1 -1
  106. package/dist/reference/stores/memory.js +22 -70
  107. package/dist/reference/stores/memory.js.map +1 -1
  108. package/dist/reference/stores/postgres.d.ts +11 -23
  109. package/dist/reference/stores/postgres.d.ts.map +1 -1
  110. package/dist/reference/stores/postgres.js +53 -334
  111. package/dist/reference/stores/postgres.js.map +1 -1
  112. package/dist/reference/stores/sql-compile.d.ts +36 -0
  113. package/dist/reference/stores/sql-compile.d.ts.map +1 -0
  114. package/dist/reference/stores/sql-compile.js +95 -0
  115. package/dist/reference/stores/sql-compile.js.map +1 -0
  116. package/dist/reference/stores/sqlite.d.ts +12 -27
  117. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  118. package/dist/reference/stores/sqlite.js +54 -355
  119. package/dist/reference/stores/sqlite.js.map +1 -1
  120. package/dist/report/console.d.ts +2 -1
  121. package/dist/report/console.d.ts.map +1 -1
  122. package/dist/report/console.js +56 -10
  123. package/dist/report/console.js.map +1 -1
  124. package/dist/report/matrix.d.ts +10 -13
  125. package/dist/report/matrix.d.ts.map +1 -1
  126. package/dist/report/matrix.js +82 -18
  127. package/dist/report/matrix.js.map +1 -1
  128. package/dist/report/render.d.ts +18 -1
  129. package/dist/report/render.d.ts.map +1 -1
  130. package/dist/report/render.js +169 -14
  131. package/dist/report/render.js.map +1 -1
  132. package/dist/runtime/async.d.ts +16 -3
  133. package/dist/runtime/async.d.ts.map +1 -1
  134. package/dist/runtime/async.js +24 -19
  135. package/dist/runtime/async.js.map +1 -1
  136. package/dist/runtime/auth.d.ts +2 -9
  137. package/dist/runtime/auth.d.ts.map +1 -1
  138. package/dist/runtime/auth.js +38 -62
  139. package/dist/runtime/auth.js.map +1 -1
  140. package/dist/runtime/body.d.ts +15 -3
  141. package/dist/runtime/body.d.ts.map +1 -1
  142. package/dist/runtime/body.js +61 -12
  143. package/dist/runtime/body.js.map +1 -1
  144. package/dist/runtime/check-costs.d.ts +15 -0
  145. package/dist/runtime/check-costs.d.ts.map +1 -0
  146. package/dist/runtime/check-costs.js +114 -0
  147. package/dist/runtime/check-costs.js.map +1 -0
  148. package/dist/runtime/check-ids.d.ts +15 -0
  149. package/dist/runtime/check-ids.d.ts.map +1 -0
  150. package/dist/runtime/check-ids.js +140 -0
  151. package/dist/runtime/check-ids.js.map +1 -0
  152. package/dist/runtime/checks.d.ts +122 -18
  153. package/dist/runtime/checks.d.ts.map +1 -1
  154. package/dist/runtime/checks.js +3437 -1933
  155. package/dist/runtime/checks.js.map +1 -1
  156. package/dist/runtime/client.d.ts +112 -0
  157. package/dist/runtime/client.d.ts.map +1 -1
  158. package/dist/runtime/client.js +271 -39
  159. package/dist/runtime/client.js.map +1 -1
  160. package/dist/runtime/cookies.d.ts +16 -2
  161. package/dist/runtime/cookies.d.ts.map +1 -1
  162. package/dist/runtime/cookies.js +83 -22
  163. package/dist/runtime/cookies.js.map +1 -1
  164. package/dist/runtime/exchanges.d.ts +4 -5
  165. package/dist/runtime/exchanges.d.ts.map +1 -1
  166. package/dist/runtime/exchanges.js +33 -55
  167. package/dist/runtime/exchanges.js.map +1 -1
  168. package/dist/runtime/exit.d.ts +40 -0
  169. package/dist/runtime/exit.d.ts.map +1 -0
  170. package/dist/runtime/exit.js +48 -0
  171. package/dist/runtime/exit.js.map +1 -0
  172. package/dist/runtime/feature-gate.d.ts +0 -9
  173. package/dist/runtime/feature-gate.d.ts.map +1 -1
  174. package/dist/runtime/feature-gate.js +0 -21
  175. package/dist/runtime/feature-gate.js.map +1 -1
  176. package/dist/runtime/finding.d.ts +61 -7
  177. package/dist/runtime/finding.d.ts.map +1 -1
  178. package/dist/runtime/finding.js +74 -12
  179. package/dist/runtime/finding.js.map +1 -1
  180. package/dist/runtime/fixture.d.ts +35 -28
  181. package/dist/runtime/fixture.d.ts.map +1 -1
  182. package/dist/runtime/fixture.js +92 -731
  183. package/dist/runtime/fixture.js.map +1 -1
  184. package/dist/runtime/generate.d.ts +63 -0
  185. package/dist/runtime/generate.d.ts.map +1 -0
  186. package/dist/runtime/generate.js +635 -0
  187. package/dist/runtime/generate.js.map +1 -0
  188. package/dist/runtime/headers.d.ts +6 -0
  189. package/dist/runtime/headers.d.ts.map +1 -0
  190. package/dist/runtime/headers.js +16 -0
  191. package/dist/runtime/headers.js.map +1 -0
  192. package/dist/runtime/path.d.ts +7 -0
  193. package/dist/runtime/path.d.ts.map +1 -0
  194. package/dist/runtime/path.js +44 -0
  195. package/dist/runtime/path.js.map +1 -0
  196. package/dist/runtime/payloads.d.ts +2 -0
  197. package/dist/runtime/payloads.d.ts.map +1 -1
  198. package/dist/runtime/payloads.js +2 -1
  199. package/dist/runtime/payloads.js.map +1 -1
  200. package/dist/runtime/rate-limit.d.ts +6 -4
  201. package/dist/runtime/rate-limit.d.ts.map +1 -1
  202. package/dist/runtime/rate-limit.js +22 -33
  203. package/dist/runtime/rate-limit.js.map +1 -1
  204. package/dist/runtime/redact.d.ts +24 -0
  205. package/dist/runtime/redact.d.ts.map +1 -0
  206. package/dist/runtime/redact.js +98 -0
  207. package/dist/runtime/redact.js.map +1 -0
  208. package/dist/runtime/run.d.ts +51 -2
  209. package/dist/runtime/run.d.ts.map +1 -1
  210. package/dist/runtime/run.js +463 -195
  211. package/dist/runtime/run.js.map +1 -1
  212. package/dist/runtime/runs.d.ts +5 -0
  213. package/dist/runtime/runs.d.ts.map +1 -1
  214. package/dist/runtime/runs.js +7 -1
  215. package/dist/runtime/runs.js.map +1 -1
  216. package/dist/runtime/scope.d.ts +163 -0
  217. package/dist/runtime/scope.d.ts.map +1 -0
  218. package/dist/runtime/scope.js +423 -0
  219. package/dist/runtime/scope.js.map +1 -0
  220. package/dist/runtime/teardown.d.ts +17 -4
  221. package/dist/runtime/teardown.d.ts.map +1 -1
  222. package/dist/runtime/teardown.js +49 -16
  223. package/dist/runtime/teardown.js.map +1 -1
  224. package/dist/runtime/transcript.d.ts +34 -2
  225. package/dist/runtime/transcript.d.ts.map +1 -1
  226. package/dist/runtime/transcript.js +119 -24
  227. package/dist/runtime/transcript.js.map +1 -1
  228. package/dist/runtime/unique.d.ts +2 -11
  229. package/dist/runtime/unique.d.ts.map +1 -1
  230. package/dist/runtime/unique.js +27 -53
  231. package/dist/runtime/unique.js.map +1 -1
  232. package/dist/runtime/validate.d.ts +13 -2
  233. package/dist/runtime/validate.d.ts.map +1 -1
  234. package/dist/runtime/validate.js +51 -31
  235. package/dist/runtime/validate.js.map +1 -1
  236. package/dist/runtime/wait.d.ts +2 -1
  237. package/dist/runtime/wait.d.ts.map +1 -1
  238. package/dist/runtime/wait.js +2 -36
  239. package/dist/runtime/wait.js.map +1 -1
  240. package/dist/runtime/world.d.ts +31 -3
  241. package/dist/runtime/world.d.ts.map +1 -1
  242. package/dist/runtime/world.js +113 -32
  243. package/dist/runtime/world.js.map +1 -1
  244. package/dist/spec/collection.d.ts +8 -2
  245. package/dist/spec/collection.d.ts.map +1 -1
  246. package/dist/spec/collection.js +47 -7
  247. package/dist/spec/collection.js.map +1 -1
  248. package/dist/spec/extensions.d.ts +5 -1
  249. package/dist/spec/extensions.d.ts.map +1 -1
  250. package/dist/spec/extensions.js +9 -2
  251. package/dist/spec/extensions.js.map +1 -1
  252. package/dist/spec/graph.d.ts +46 -0
  253. package/dist/spec/graph.d.ts.map +1 -1
  254. package/dist/spec/graph.js +175 -11
  255. package/dist/spec/graph.js.map +1 -1
  256. package/dist/spec/load.d.ts +25 -5
  257. package/dist/spec/load.d.ts.map +1 -1
  258. package/dist/spec/load.js +160 -59
  259. package/dist/spec/load.js.map +1 -1
  260. package/dist/spec/query-capabilities.d.ts +4 -0
  261. package/dist/spec/query-capabilities.d.ts.map +1 -1
  262. package/dist/spec/query-capabilities.js +6 -0
  263. package/dist/spec/query-capabilities.js.map +1 -1
  264. package/dist/spec/schema.d.ts +20 -0
  265. package/dist/spec/schema.d.ts.map +1 -0
  266. package/dist/spec/schema.js +118 -0
  267. package/dist/spec/schema.js.map +1 -0
  268. package/dist/spec/tag-schema.d.ts +12 -0
  269. package/dist/spec/tag-schema.d.ts.map +1 -0
  270. package/dist/spec/tag-schema.js +143 -0
  271. package/dist/spec/tag-schema.js.map +1 -0
  272. package/dist/spec/types.js +17 -1
  273. package/dist/spec/types.js.map +1 -1
  274. package/docs/api.md +59 -0
  275. package/docs/assumptions.md +99 -0
  276. package/docs/checks.md +183 -0
  277. package/docs/commands.md +214 -0
  278. package/docs/configuration.md +650 -0
  279. package/docs/model.md +184 -0
  280. package/docs/reference-defects.md +94 -0
  281. package/docs/reports.md +211 -0
  282. package/docs/tags.md +400 -0
  283. package/package.json +3 -1
package/docs/model.md ADDED
@@ -0,0 +1,184 @@
1
+ # oat — How oat reads a document
2
+
3
+ [← back to the overview](../README.md)
4
+
5
+ ## How the model is derived
6
+
7
+ `oat plan` is this model. Checks never see raw paths; they see entities, actions, and query **roles**.
8
+
9
+ ### Entity name and action
10
+
11
+ **Explicit:** `x-entity: { name, action, identity? }` on the operation.
12
+
13
+ **Heuristic** (when the tag is absent):
14
+
15
+ 1. Split the path on `/`. Ignore `{param}` segments.
16
+ 2. The last non-parameter segment is the noun. `v1` / `v2` / `vN` is never a noun.
17
+ 3. Singularise that noun (`stores` → `store`, `batches` → `batch`). Irregulars include `people→person`, `categories→category`, `campuses→campus`, `statuses→status`, `children→child`, `companies→company`, `addresses→address`, `indices→index`, `queries→query`, `properties→property`, `entities→entity`, `inboxes→inbox`. Endings `us|ss|is|os|as|ics|ews|ess|ous|sis` are left alone (`status` stays `status`).
18
+ 4. If a non-parameter segment follows the noun (`/rows/aggregate`, `/tables/{id}/restore`), action is `action`.
19
+ 5. Otherwise: `GET` collection → `list`, `GET` item → `read`, `POST` collection → `create`, `POST` item → `action`, `PUT`/`PATCH` → `update`, `DELETE` → `delete`.
20
+
21
+ If no noun can be found, the operation is untracked and `doctor` records an `x-entity` gap.
22
+
23
+ `--only` and report entity names are these singular names.
24
+
25
+ ### Identity
26
+
27
+ `x-entity.identity` wins. Else the first of `id`, `uuid`, `slug`, `key`, `name` that is **required** on the item schema, else the first of those that exists, else the trailing path-param suffix (`{table_id}` → `id`). Without an identity the entity is not trackable.
28
+
29
+ ### Read surface
30
+
31
+ The set of `GET` routes through which an instance is visible.
32
+
33
+ - Declared: every `"METHOD /path"` in any `x-invalidate` that refers to this entity.
34
+ - Inferred: sibling collection and item routes on the same path prefix as a mutator.
35
+
36
+ `invalidation.declared-route-changes` only runs when a mutator's `x-invalidate` names **another** entity's route.
37
+
38
+ ### Generated / immutable / soft-delete / tenant
39
+
40
+ See [OpenAPI meta tags](tags.md#openapi-meta-tags). Fallbacks:
41
+
42
+ - `readOnly: true` counts as generated (omitted from create bodies).
43
+ - No immutability testing without `x-immutable`.
44
+ - Tenant param: `x-tenant` or a path param matching `org|organization|tenant|workspace|account|project|app` + optional `_id`/`_slug`. Inferred tenants make a cross-tenant read `AMBIGUITY`, not `SECURITY`. With neither a tag nor an inferred name, the check does not apply.
45
+
46
+ ### Idempotency
47
+
48
+ No meta tag. If create declares a header matching `Idempotency-Key` / `Idempotence-Key` / `X-Idempotency-Key` (spaces ignored, case-insensitive), `idempotency.replay-does-not-duplicate` runs.
49
+
50
+ ## Seeding
51
+
52
+ Per entity, oat POSTs the create body built from the request schema (JSON, multipart, or urlencoded).
53
+
54
+ Default cohort is **7** records, one of each variant, sliced by `cohortSize`:
55
+
56
+ | variant | what it is for |
57
+ | --------------- | -------------------------------------------------- |
58
+ | `baseline` | `"Quarterly Report N"` |
59
+ | `lexical-first` | sorts first (`"aaa first alphabetically"`) |
60
+ | `lexical-last` | sorts last (`"zzz last alphabetically"`) |
61
+ | `null-heavy` | `null` on every nullable field |
62
+ | `unicode` | `"äöüß čćžšđ 日本語 中文 한글 привет مرحبا 🙂"` |
63
+ | `metacharacter` | `"100% _off_ *everything*"` — LIKE / escape probes |
64
+ | `boundary` | empty / maxLength / numeric `maximum` |
65
+
66
+ Numbers use the ladder `1, 2, 5, 10, 20, 50, 100` so **lexical order ≠ numeric order** (otherwise a TEXT compare looks correct). Enums walk `index % enum.length`. `readOnly` / `x-generated` fields are omitted. Required fields that cannot be generated get a type fallback (`0`, `false`, `[]`, `{}`, `"value"`). Arrays honour `minItems` (never send `[]` when `minItems ≥ 1`). Nested objects stop at depth 4.
67
+
68
+ Empty schemas (`{}`, `true`, `additionalProperties: {}`) and cyclic `$ref`s after inlining stop the walk — they become a scalar or `{}`, never another object descent. A `RangeError` during generation is a `COVERAGE_GAP` on that entity naming the operationId and JSON pointer (`fixture generation overflow on table.create (/)`), not `blocked by unknown`.
69
+
70
+ String fields honour `format`, `pattern`, and `minLength` together: `email` → `oat-{variant}-{index}@example.test`, `uri` / `url` → `https://example.test/...`, `uuid` → a fixed-shape UUID, `pattern` → a string that matches (or the field is omitted / the entity is a gap). A generated string is padded to `minLength` (repeat the last character) without breaking `pattern`. `"Quarterly Report N"` is only used when the document does not constrain the string. `minLength` greater than `maxLength` omits an optional field and records `missingRequired` on a required one.
71
+
72
+ An operation with `x-invite` is not `entity.create`. oat does not POST a generated invitee. The invite check sends `granteeField` = the peer's `inviteAs`. Missing `inviteAs` is a coverage gap naming the tag.
73
+
74
+ A create whose `operationId` appears in any principal `auth.steps`, or that declares `x-fresh-principal`, is not seeded. Those rows were provisioned by the auth flow.
75
+
76
+ Parent path parameters are created first (depth-first through the owning entity's create). Config / principal `roots` fill parameters oat cannot create.
77
+
78
+ A create that returns `>= 300` on the first variant fails the entity (downstream checks `BLOCKED`). Later variants that fail just shorten the cohort — a partial cohort is still used.
79
+
80
+ HTTP 429 is retried first — `Retry-After` or exponential backoff, up to five times — on seed, checks, and teardown, whether or not the operation declared `x-rate-limit`. The first 429 is never a seed failure. A leftover 429 after those retries is a gap, not a backend defect.
81
+
82
+ A 402 / plan-limit (`payment_required`, `*_plan_limit`) on create is not a backend defect when the same-tenant list already has a row — typically an earlier `x-effects` create that filled a free-plan quota. oat reuses that id so children (a `row` after extract created a `table`) can still seed. It does not invent records. Write-path checks on the adopted entity stand down.
83
+
84
+ A documented feature-gate 403 is the exception: if create declares `x-feature-gate` and the body is `vars.type: feature_gate` (and `vars.feature` matches the tag when present), oat records a `COVERAGE_GAP` naming the tag rather than a seed defect. See [`x-feature-gate`](tags.md#x-feature-gate).
85
+
86
+ When create is tagged `x-unique`, a first-variant seed **409** with a nonempty same-tenant list **adopts** that row (`world.seed` `COVERAGE_GAP` naming `x-unique` — the create could not insert). Unique-conflict checks still run against that row. Write-path oracles that need a body oat submitted stay skipped. 409 with an empty list stays `BLOCKED` (`could not seed`). A 409 without the tag is still today's seed failure. The seed 409 itself is not `create.unique-conflict-rejected` passing — that check is the explicit second POST. See [`x-unique`](tags.md#x-unique). Later variants that 409 only shorten the cohort. Generated values for unique body columns differ across variants (a suffix) without weakening `maxLength` / `pattern`; if the document cannot express two distinct values, oat records a gap and skips extra variants.
87
+
88
+ `--seed` / `seed` makes the bodies identical across runs. It does not make server-assigned ids identical.
89
+
90
+ Teardown DELETEs created rows (or the `x-cleanup` route) newest-first. Failures and missing delete routes are printed as leftovers, not as check findings. `keepFixtures: true` skips this.
91
+
92
+ ## Query roles and grammars
93
+
94
+ Checks do not look for a parameter _named_ `filter`. They resolve **roles** from aliases, then write values in the grammar the document demonstrates.
95
+
96
+ | role | aliases (normalised: case, `_` / `-`, `perPage` → `per_page`) |
97
+ | ----------------- | ----------------------------------------------------------------------------------- |
98
+ | filter | `filter`, `where`, `query`, `conditions` |
99
+ | order | `order`, `order_by`, `sort`, `sort_by`, `ordering` |
100
+ | select | `select`, `fields`, `field`, `include_fields`, `projection`, `only` |
101
+ | search | `q`, `search`, `query_text`, `term`, `keyword`, `text` |
102
+ | search mode | `search_mode`, `searchmode`, `search_type`, `mode` (only if a search role exists) |
103
+ | limit (page size) | `limit`, `per_page`, `page_size`, `pagesize`, `count`, `max_results`, `top`, `size` |
104
+ | page | `page`, `page_number`, `pagenum`, `p` |
105
+ | offset | `offset`, `skip`, `start`, `from` |
106
+ | cursor | `cursor`, `after`, `starting_after`, `next`, `page_token`, `continuation` |
107
+
108
+ A bracketed suffix is a _value_ in the name (`fields[articles]`, `filter[status]`), not part of the role. `count` is a page size only when it looks like one (has `maximum` or a default); otherwise it is treated as a total.
109
+
110
+ A bounded integer with a default that matches no alias is still taken as page size. A 1-based integer with no maximum is taken as page number.
111
+
112
+ **Filter grammars** — how oat **writes** a term:
113
+
114
+ | name | example | `and` / `or` |
115
+ | ----------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------- |
116
+ | `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(...)` |
117
+ | `colon` | `filter=status=eq:active` (comma-joined terms; no grouping) | not expressible; those checks skip |
118
+ | `equality` | `?status=active` (one query param per field). Only `eq` is expressible | not expressible |
119
+
120
+ 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.
121
+
122
+ 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.
123
+
124
+ **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.
125
+
126
+ **Select grammars:** `id,name` (csv) or `fields[table]=id,name` (bracketed). If the parameter is already named `fields[articles]`, that name is used verbatim.
127
+
128
+ ### How a grammar is inferred
129
+
130
+ `x-query.grammar` wins when it is `postgrest` | `colon` | `equality`.
131
+
132
+ Otherwise oat concatenates the filter/order/select parameter's `example`, `examples`, and `description`:
133
+
134
+ - Filter: `/postgrest/i` or `field.op.value` / `status.eq.active` → `postgrest`; `status=eq:` → `colon`; else `equality`. A free-text `filter` string that still looks like equality produces an `x-query` gap telling you to declare the grammar.
135
+ - Sort: `name.asc` → dotted; `name:desc` → colon; `name desc` → spaced; leading `-field` → prefixed; else dotted.
136
+ - Select: parameter name or description contains `fields[…]` → bracketed; else csv.
137
+
138
+ 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.
139
+
140
+ 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.
141
+
142
+ 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.
143
+
144
+ ### Declared or skip
145
+
146
+ Every list check consults one **effective capability map** per entity. Precedence, unchanged in spirit:
147
+
148
+ | order | source | what it contributes |
149
+ | ----- | ------------------------------- | ---------------------------------------------------------------------------------------------------- |
150
+ | 1 | `x-query` on the list operation | fields, structured rows, operators, modes, caps, harvest |
151
+ | 2 | `config.entities[name].query` | union of fields; overlay of ops / modes / caps |
152
+ | 3 | `config.query` | global defaults for the same keys |
153
+ | 4 | heuristic | today's scalars / name-regex searchable — **only if that axis was not tagged and not set in config** |
154
+ | 5 | skip | the check does not apply |
155
+
156
+ `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.
157
+
158
+ Missing capability → the check does not apply / unresolved. Never `BACKEND_BUG` for an undeclared op, mode, or nulls option.
159
+
160
+ ## Pagination and envelopes
161
+
162
+ Three page models, all first-class:
163
+
164
+ - **Page number** (`page` + `limit` roles).
165
+ - **Offset** (`offset` + `limit`). Checks that say "page 3" translate to `offset = (page - 1) * size` (size defaults to 20 only for that translation).
166
+ - **Cursor** (`cursor` role + envelope `nextCursor` or a `Link: rel=next` header).
167
+
168
+ `hasMore` is taken from the body (`hasMore`, `has_more`, `hasNextPage`, `more`) **or** from a documented `Link` response header. Under Link pagination, **absence** of `rel="next"` means no more pages.
169
+
170
+ Collection shape is derived from the success JSON schema, not from hardcoded wrapper names:
171
+
172
+ - Response `type: array` → the body is the list (`key: null`).
173
+ - Otherwise the array property whose items are objects, skipping sidecar names `error(s)`, `warning(s)`, `message(s)`, `meta`, `links`. Resource-named envelopes (`{ tables: [...] }`) work.
174
+ - Sibling keys become envelope fields:
175
+
176
+ | role | accepted property names |
177
+ | ---------- | ----------------------------------------------------------- |
178
+ | total | `count`, `total`, `totalCount`, `total_count`, `totalItems` |
179
+ | hasMore | `hasMore`, `has_more`, `hasNextPage`, `more` |
180
+ | nextCursor | `nextCursor`, `next_cursor`, `cursor`, `next`, `endCursor` |
181
+ | page | `page`, `pageNumber`, `page_number`, `offset` |
182
+ | limit | `limit`, `perPage`, `per_page`, `pageSize`, `page_size` |
183
+
184
+ Success schema is the first JSON media type on responses `200`, `201`, `202`, `2XX`, or `default`. Request schema is taken from `requestBody` preferring `multipart/form-data`, then `application/x-www-form-urlencoded`, then JSON. A success response that lists `text/event-stream` is a stream: oat consumes it to the end and does not treat the raw body as a JSON schema defect. Media type is the stream tag — there is no `x-stream`.
@@ -0,0 +1,94 @@
1
+ # oat — Reference defects
2
+
3
+ [← back to the overview](../README.md)
4
+
5
+ ## Reference defects (`oat serve --defects`)
6
+
7
+ Comma-separated. Each is one named lie the demo API can tell. Primary check is what conformance asserts; extras in parentheses are accepted additional symptoms of the same lie.
8
+
9
+ | defect | primary check | the lie |
10
+ | ------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- |
11
+ | `STALE_LIST` | `list.read-after-write` | create succeeds, list does not show the row |
12
+ | `CREATE_DROPS_FIELD` | `create.persists-submitted-fields` | a submitted field is dropped |
13
+ | `STRING_PAYLOAD_MANGLED` | `payload.string-survives` | non-ASCII / surrounding whitespace stripped on write |
14
+ | `RESPONSE_STATUS_UNDECLARED` | `response.status-is-documented` | PATCH returns 201 when the document names 200 |
15
+ | `CREATED_201_AS_200` | `create.status-matches-document` | create returns 200 when the spec says 201 |
16
+ | `RESPONSE_SCHEMA_DRIFT` | `schema.success-response-matches-document` | success body does not match the schema |
17
+ | `ERROR_SCHEMA_DRIFT` | `schema.error-response-matches-document` | error body does not match the schema |
18
+ | `LIMIT_IGNORED` | `pagination.limit-bounds-page-size` | `limit` is accepted and ignored |
19
+ | `LIMIT_EXCEEDS_MAX` | `pagination.limit-respects-documented-max` | documented max is not capped |
20
+ | `HASMORE_ALWAYS_FALSE` | `pagination.has-more-is-accurate` | `hasMore` is always false |
21
+ | `OFF_BY_ONE_PAGE` | `pagination.page-walk-covers-set` | page walk skips or repeats |
22
+ | `PAGE_PAST_END_REPEATS` | `pagination.bounds-handled` | a page past the end repeats the last page |
23
+ | `UNSTABLE_SORT` | `pagination.page-walk-covers-set` | default order is not a total order |
24
+ | `CURSOR_DRIFT` | `pagination.cursor-agrees-with-page` | cursor and page disagree |
25
+ | `FILTER_IGNORED` | `filter.unknown-field-rejected` | unknown filter field is ignored |
26
+ | `UNKNOWN_PARAM_EMPTIES_LIST` | `query.unknown-parameter-consistent` | an unknown query parameter empties the listing |
27
+ | `FILTER_EQ_NOT_APPLIED` | `filter.equality-selects-exactly-one` | equality filter is ignored |
28
+ | `EMPTY_RESULT_RETURNS_ALL` | `filter.zero-match-returns-none` | empty match returns the whole set |
29
+ | `NEQ_DROPS_NULLS` | `filter.negation-partitions-the-set` | `neq` drops nulls so the partition leaks |
30
+ | `FILTER_GROUP_COMBINATOR_SWAPPED` | `filter.and-composes-as-intersection` | `and`/`or` are swapped |
31
+ | `LIKE_UNESCAPED` | `filter.like-metacharacters-escaped` | `%`/`_` are wildcards in values |
32
+ | `NUMERIC_COMPARED_AS_TEXT` | `filter.numeric-comparison-is-numeric` | numbers compared as strings |
33
+ | `ERROR_500_ON_BAD_FILTER` | `error.malformed-filter-not-5xx` | bad filter is 500 |
34
+ | `FILTER_AFTER_PAGINATION` | `query.filter-selects-from-whole-set` | filter applied after the page is cut |
35
+ | `ORDER_IGNORED` | `sort.order-is-applied` | sort param is ignored |
36
+ | `SORT_DESC_DROPS_NULLS` | `sort.reverse-symmetry` | desc drops nulls |
37
+ | `SEARCH_IGNORED` | `search.q-narrows-result` | search param is ignored |
38
+ | `SELECT_IGNORED` | `select.projection-honoured` | select param is ignored |
39
+ | `COUNT_ALWAYS_ZERO` | `count.consistent-with-returned-page` | total is always 0 |
40
+ | `COUNT_IGNORES_FILTER` | `count.matches-filtered-set` | total ignores the filter |
41
+ | `FILTER_DROPPED_WHEN_SORTED` | `query.axes-compose` | sort drops the filter |
42
+ | `FILTER_DROPPED_WHEN_SELECTED` | `query.filter-and-select-compose` | select drops the filter |
43
+ | `FILTER_DROPPED_WHEN_SEARCHED` | `query.search-and-filter-compose` | search drops the filter |
44
+ | `FILTER_DROPPED_WHEN_SORTED_AND_SELECTED` | `query.filter-sort-select-compose` | the triple drops the filter |
45
+ | `FILTER_DROPPED_WHEN_SORTED_AND_SEARCHED` | `query.filter-search-sort-compose` | the triple drops the filter |
46
+ | `FILTER_DROPPED_WHEN_SEARCHED_AND_SELECTED` | `query.filter-search-select-compose` | the triple drops the filter |
47
+ | `SPEC_OVERCLAIMS_FILTERABLE` | `spec.declared-filterable-is-filterable` | `x-query` lists a field that 400s |
48
+ | `SPEC_OVERCLAIMS_SORTABLE` | `spec.declared-sortable-is-sortable` | same for sort |
49
+ | `SPEC_OVERCLAIMS_SELECTABLE` | `spec.declared-selectable-is-selectable` | same for select |
50
+ | `FILTER_IN_FIRST_ONLY` | `filter.in-is-union-of-eq` | `in.(a,b)` matches only the first value |
51
+ | `FILTER_GTE_IS_GT` | `filter.gte-is-gt-or-eq` | `gte` is compiled as `gt` |
52
+ | `FILTER_ILIKE_IS_LIKE` | `filter.ilike-is-case-insensitive` | `ilike` is compiled as `like` |
53
+ | `FILTER_IS_NULL_MATCHES_ALL` | `filter.is-null-selects-nulls` | `is.null` matches every row |
54
+ | `FILTER_ILLEGAL_OP_IGNORED` | `filter.illegal-op-rejected` | an operator outside the allowlist is ignored |
55
+ | `SORT_NUMERIC_AS_TEXT` | `sort.numeric-order-is-numeric` | a numeric field is ordered lexicographically |
56
+ | `SORT_MULTI_KEY_IGNORED` | `sort.multi-key-tiebreak` | the second sort key is ignored |
57
+ | `TIEBREAK_NOT_APPLIED` | `sort.stable-tiebreak` | ties are broken by another field than the declared one |
58
+ | `RATE_LIMIT_STRICTER_THAN_DECLARED` | `spec.declared-rate-limit-is-honoured` | 429 well inside the declared `x-rate-limit` rate |
59
+ | `SELECT_FIELD_MISSING` | `select.requested-fields-present` | a requested select field is dropped |
60
+ | `CROSS_TENANT_READ` | `tenant.item-not-readable-cross-tenant` | item GET is global by id |
61
+ | `CROSS_TENANT_WRITE` | `tenant.item-not-writable-cross-tenant` | update and delete reach another tenant's record |
62
+ | `EXISTENCE_LEAK_VIA_STATUS` | `tenant.denial-does-not-reveal-existence` | 403 vs 404 reveals the other tenant's row |
63
+ | `TENANT_LEAK_VIA_FILTER` | `tenant.filter-does-not-bypass-scope` | filter drops the tenant predicate |
64
+ | `FOREIGN_PARENT_ACCEPTED` | `tenant.parent-not-reachable-from-another-root` | another tenant's parent is accepted under one's own root |
65
+ | `ROLE_MONOTONICITY_BROKEN` | `auth.rank-is-monotonic` | a lower rank can do more |
66
+ | `ROLE_WRITE_INVERTED` | `auth.rank-is-monotonic-on-writes` | a lower rank can update where a higher rank cannot |
67
+ | `INVITE_NEVER_GRANTS` | `auth.invite-grants-then-revokes` | accept does not grant |
68
+ | `REVOKE_IGNORED` | `auth.invite-grants-then-revokes` | revoke leaves the grant |
69
+ | `IMMUTABLE_WRITABLE` | `patch.immutable-field-rejected` | immutable fields accept writes |
70
+ | `SOFT_DELETE_LEAK` | `softdelete.absent-from-default-list` | tombstone stays on the default list |
71
+ | `PARENT_PROJECTION_STALE` | `invalidation.declared-route-changes` | child write does not bump the parent |
72
+ | `EFFECT_NOT_APPLIED` | `effects.declared-effect-occurs` | declared cardinality delta does not happen |
73
+ | `ASYNC_NEVER_COMPLETES` | `async.reaches-terminal-state` | job stays pending |
74
+ | `ASYNC_RECEIPT_MISSING_ID` | `async.receipt-identifies-the-job` | receipt has no id |
75
+ | `PATCH_REPLACES` | `patch.minimality` | PATCH is implemented as replace |
76
+ | `IDEMPOTENCY_IGNORED` | `idempotency.replay-does-not-duplicate` | Idempotency-Key is ignored |
77
+ | `IDEMPOTENT_REPLAY_INSERTS` | `idempotency.replay-does-not-duplicate` | a replay returns the original but inserts another row |
78
+ | `UNIQUE_NOT_ENFORCED` | `create.unique-conflict-rejected` | a duplicate unique-set write is 2xx instead of 409 |
79
+ | `DELETE_MISSING_OK` | `delete.absent-record-returns-404` | DELETE missing returns 200 |
80
+ | `CONCURRENT_WRITE_LOST` | `concurrency.no-lost-update` | full-row write clobbers a parallel PATCH |
81
+ | `ENUM_NOT_VALIDATED` | `validation.enum-enforced` | enum is not enforced |
82
+ | `MAXLENGTH_NOT_VALIDATED` | `validation.max-length-enforced` | maxLength is not enforced |
83
+ | `REQUIRED_NOT_VALIDATED` | `validation.required-enforced` | required is not enforced |
84
+ | `CONTENT_TYPE_NOT_ENFORCED` | `validation.content-type-enforced` | wrong Content-Type is accepted |
85
+ | `LIST_DETAIL_DISAGREE` | `consistency.projections-agree` | list and item show different values |
86
+ | `COLUMN_NAME_MISMATCH` | `create.persists-submitted-fields` | SQL identifier does not match the field (SQL backends) |
87
+ | `COLLATION_INCONSISTENT` | `pagination.cursor-agrees-with-page` | cursor order ≠ page order (SQL) |
88
+
89
+ ```bash
90
+ oat serve --defects STALE_LIST,PATCH_REPLACES
91
+ oat run --config labs/local.config.ts --base-url <url>
92
+ ```
93
+
94
+ `COLUMN_NAME_MISMATCH` is SQL-only in conformance (the in-memory store has no physical column names). `CONCURRENT_WRITE_LOST` runs on the in-memory engine only: the race it times does not depend on the store.
@@ -0,0 +1,211 @@
1
+ # oat — Reports and progress logs
2
+
3
+ [← back to the overview](../README.md)
4
+
5
+ ## Reports
6
+
7
+ Written under `--out` (default `./.oat/runs`). Each invocation creates a UTC timestamp folder and points `latest` at it:
8
+
9
+ ```
10
+ .oat/runs/2026-08-18T12-00-00Z/
11
+ .oat/runs/latest -> 2026-08-18T12-00-00Z
12
+ ```
13
+
14
+ `--out` / `outDir` replace the root, not the leaf — `--out .oat/runs/prod` writes `.oat/runs/prod/<datetime>/`.
15
+
16
+ | file | |
17
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
18
+ | `oat-report.md` | human report: scope, per-operation status, summary, findings with request/response excerpts, coverage, latency p50/p95/max |
19
+ | `oat-report.json` | same data for CI. Summary only — full exchanges are not inlined here |
20
+ | `matrix.html` | visual matrix of entities × checks |
21
+ | `matrix.json` | the same graph (AI-friendly), including a mermaid string |
22
+ | `issue-repro/*.sh` | one executable `curl` script per finding that has evidence. Directory is omitted when the run is clean |
23
+ | `progress.log` | logfmt, one event per line, never truncated |
24
+ | `progress.jsonl` | same events as JSON |
25
+ | `progress.tsv` | same columns, tab-separated. `req_id` is the join key to the exchange journal |
26
+ | `progress.json` | latest snapshot only (overwritten ~1s) |
27
+ | `exchanges.jsonl` | one line per request (`seq`, `requestId`, method, url, status, bytes, …). Greppable |
28
+ | `exchanges/<requestId>.json` | full exchange: status, headers, described bodies. Missing id → `seq-<n>.json`. Duplicate id → `-<seq>` |
29
+ | `blobs/<sha256>` | content-addressed file parts and oversized / binary bodies |
30
+
31
+ 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. **`--no-save-exchanges` does not reduce transcript RAM** — it only skips the disk journal. The in-memory transcript always drops live `FormData` / `Blob` / `ArrayBuffer` / multi-MiB strings after each hop, keeping citeable metadata and content-addressed body descriptors. Reconstruct bytes from `blobs/<sha256>` when a check genuinely needs them.
32
+
33
+ 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 parsed incrementally into `{ event, data }[]` frames — the raw `text/event-stream` concatenation is not retained on the transcript.
34
+
35
+ Redaction is on by default (not opt-in), applied on write:
36
+
37
+ - Headers: `authorization`, `cookie`, `set-cookie`, `proxy-authorization`, and any `x-*-key` / `x-*-secret` / `x-ia-tester-key` (case-insensitive).
38
+ - JSON bodies, any depth: `access_token`, `refresh_token`, `id_token`, `password`, `token`, `secret`, `api_key` → `"<redacted>"`.
39
+
40
+ Every report names its scope. The console prints `scope: full · 141 of 212 operations graded` or `scope: targeted · 2 operations (--ops …)` followed by one line per target. `oat-report.md` has a **Scope** line and an **Operations** table: on a full run, every operation in the document with its status (so operations no check ever grades are visible); on a targeted run, the targets only, followed by **Support operations** with call and non-2xx counts. In `matrix.html` / `matrix.json` a check a targeted run did not ask for is `out-of-scope`, distinct from `skipped` (not applicable).
41
+
42
+ 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/`.
43
+
44
+ ### `oat-report.json`
45
+
46
+ ```json
47
+ {
48
+ "backend": "https://api.example.com",
49
+ "generatedAt": "2026-08-15T12:00:00.000Z",
50
+ "durationMs": 41200,
51
+ "requests": 842,
52
+ "entitiesTested": ["store", "product"],
53
+ "scope": {
54
+ "mode": "targeted",
55
+ "requested": { "ops": ["product.update"], "only": [] },
56
+ "operations": [
57
+ {
58
+ "operationId": "product.update",
59
+ "entity": "product",
60
+ "status": "failed",
61
+ "checks": {
62
+ "held": ["concurrency.no-lost-update"],
63
+ "failed": ["patch.minimality"],
64
+ "suppressed": [],
65
+ "inconclusive": [],
66
+ "gaps": []
67
+ },
68
+ "findings": 1,
69
+ "reason": null
70
+ }
71
+ ],
72
+ "support": [{ "operationId": "product.create", "calls": 9, "non2xx": 0 }],
73
+ "excluded": [],
74
+ "originsSkipped": []
75
+ },
76
+ "checksOutOfScope": [{ "check": "filter.in-is-union-of-eq", "entity": "product" }],
77
+ "checksRun": ["list.read-after-write", "patch.minimality"],
78
+ "checksSkipped": [{ "check": "async.reaches-terminal-state", "entity": "store", "needs": "…" }],
79
+ "checksSuppressed": [{ "check": "query.axes-compose", "entity": "store", "because": "list.read-after-write" }],
80
+ "inconclusive": [{ "check": "filter.and-composes-as-intersection", "entity": "store", "reason": "…" }],
81
+ "summary": { "BACKEND_BUG": 1 },
82
+ "coverage": {
83
+ "neverApplied": ["async.reaches-terminal-state"],
84
+ "partial": [{ "check": "select.projection-honoured", "ran": 1, "skipped": 1 }]
85
+ },
86
+ "latency": {
87
+ "p50": 12,
88
+ "p95": 90,
89
+ "max": 400,
90
+ "slowest": { "method": "GET", "path": "/v1/products" }
91
+ },
92
+ "findings": [
93
+ {
94
+ "check": "patch.minimality",
95
+ "verdict": "BACKEND_BUG",
96
+ "entity": "product",
97
+ "summary": "PATCH { name } also cleared description",
98
+ "detail": "…",
99
+ "evidence": [
100
+ {
101
+ "method": "PATCH",
102
+ "url": "https://api.example.com/v1/products/p1",
103
+ "status": 200,
104
+ "requestBody": { "name": "x" },
105
+ "responseBody": { "name": "x", "description": null }
106
+ }
107
+ ]
108
+ }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ Latency is reported, never asserted. oat has no baseline for "too slow".
114
+
115
+ Gate in CI on process exit code, or on `findings` whose `verdict` is not `COVERAGE_GAP` / `BLOCKED`.
116
+
117
+ ### `matrix.json`
118
+
119
+ ```json
120
+ {
121
+ "kind": "oat.matrix",
122
+ "version": 2,
123
+ "baseUrl": "…",
124
+ "generatedAt": "…",
125
+ "thesis": "…",
126
+ "summary": "…",
127
+ "index": { "entityCount": 5, "failed": ["product"], "parents": ["store"], "crossClaims": 1, "inbound": {} },
128
+ "counts": { "failed": 1, "blocked": 0, "held": 40, "skipped": 14 },
129
+ "entities": [
130
+ {
131
+ "name": "product",
132
+ "identity": "id",
133
+ "readSurface": ["GET /v1/products", "GET /v1/products/{id}"],
134
+ "counts": { "failed": 1, "blocked": 0, "held": 20, "skipped": 5 },
135
+ "roots": ["store_id"],
136
+ "nodes": [
137
+ {
138
+ "id": "product/patch.minimality",
139
+ "group": "product",
140
+ "layer": "axis",
141
+ "status": "failed",
142
+ "verdict": "BACKEND_BUG",
143
+ "summary": "…"
144
+ }
145
+ ]
146
+ }
147
+ ],
148
+ "invalidate": [
149
+ {
150
+ "fromEntity": "product",
151
+ "fromOp": "product.create",
152
+ "toEntity": "store",
153
+ "toRoute": "GET /v1/stores/{id}",
154
+ "cross": true
155
+ }
156
+ ],
157
+ "edges": [{ "from": "product/list.read-after-write", "to": "product/patch.minimality", "kind": "dependsOn" }],
158
+ "mermaid": "flowchart LR\n…"
159
+ }
160
+ ```
161
+
162
+ Cell `status`: `held` (passed), `failed`, `blocked`, `skipped`. Edge `kind`: `dependsOn` (cascade) or `uses` (a composition check built from a single-axis check).
163
+
164
+ ### `issue-repro/<entity>-<check>.sh`
165
+
166
+ Created only when a finding has HTTP evidence. Not created on a clean run.
167
+
168
+ ```bash
169
+ #!/usr/bin/env bash
170
+ # Generated by oat. Set TOKEN to a valid credential before running.
171
+ # product — PATCH { name } also cleared description
172
+ set -u
173
+ BASE="${BASE:-https://api.example.com}"
174
+ TOKEN="${TOKEN:?set TOKEN to a valid credential}"
175
+
176
+ # step 1 — observed 200
177
+ curl -sS -X PATCH "$BASE/v1/products/p1" \
178
+ -H "authorization: Bearer $TOKEN" \
179
+ -H "content-type: application/json" \
180
+ -d '{"name":"x"}'
181
+ ```
182
+
183
+ `authorization`, `cookie`, and `x-api-key` are redacted to `$TOKEN`. Replay needs a live credential; ids in the script are whatever the failing run created (gone if teardown ran). Use `--keep-fixtures` when you want to replay against the same rows.
184
+
185
+ ## Progress logs
186
+
187
+ `progress.log` starts with a glossary. Fields:
188
+
189
+ | key | |
190
+ | -------------------------- | -------------------------------------------------------------------------------- |
191
+ | `ts` | ISO-8601 UTC when the line was written |
192
+ | `status` | `ok`, `stall` (`idle_ms` ≥ 15000 after the last call returned), or `in_flight` |
193
+ | `done` / `total` | entity index / how many entities |
194
+ | `phase` | `load` \| `auth` \| `seed` \| `test` \| `teardown` \| `done` |
195
+ | `entity` / `check` | current entity and check id |
196
+ | `msg` | phase note. In-flight is `status`, not `msg` |
197
+ | `req` / `find` | request count, finding count |
198
+ | `method` / `path` / `http` | last completed call, or the in-flight call when `status=in_flight` (`http=-`) |
199
+ | `last_ms` | duration of the last completed call. `-` while `in_flight` |
200
+ | `idle_ms` | while `in_flight`: ms since this request started. otherwise ms since it returned |
201
+ | `elapsed_ms` | wall clock since start |
202
+
203
+ `--quiet` keeps the files and drops stderr.
204
+
205
+ 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.
206
+
207
+ 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.
208
+
209
+ 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. Live `run` aborts a single `fetch` after `network.requestTimeoutMs` (default **180s**). A hung `text/event-stream` becomes `net.unreachable` (`kind=timeout`), not an unbounded buffer on the transcript. Set `requestTimeoutMs: 0` only if you intend to wait for the socket.
210
+
211
+ Bun and Node both honour that timeout. If oat still runs away with RAM (a bug, or a caller holding the process open), treat the process like any other prefer-kill target for `earlyoom` / systemd — Bun is often **not** in a default `--prefer` list.