@lovrozagar/oat 0.10.0 → 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 (278) hide show
  1. package/README.md +25 -2006
  2. package/dist/cli.js +283 -59
  3. package/dist/config/define-config.d.ts +8 -0
  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 +8 -3
  67. package/dist/index.js +5 -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.map +1 -1
  121. package/dist/report/console.js +23 -9
  122. package/dist/report/console.js.map +1 -1
  123. package/dist/report/matrix.d.ts +2 -0
  124. package/dist/report/matrix.d.ts.map +1 -1
  125. package/dist/report/matrix.js +63 -10
  126. package/dist/report/matrix.js.map +1 -1
  127. package/dist/report/render.d.ts +10 -1
  128. package/dist/report/render.d.ts.map +1 -1
  129. package/dist/report/render.js +75 -13
  130. package/dist/report/render.js.map +1 -1
  131. package/dist/runtime/async.d.ts +16 -3
  132. package/dist/runtime/async.d.ts.map +1 -1
  133. package/dist/runtime/async.js +24 -19
  134. package/dist/runtime/async.js.map +1 -1
  135. package/dist/runtime/auth.d.ts +2 -9
  136. package/dist/runtime/auth.d.ts.map +1 -1
  137. package/dist/runtime/auth.js +38 -62
  138. package/dist/runtime/auth.js.map +1 -1
  139. package/dist/runtime/body.d.ts +15 -3
  140. package/dist/runtime/body.d.ts.map +1 -1
  141. package/dist/runtime/body.js +61 -12
  142. package/dist/runtime/body.js.map +1 -1
  143. package/dist/runtime/check-costs.d.ts +15 -0
  144. package/dist/runtime/check-costs.d.ts.map +1 -0
  145. package/dist/runtime/check-costs.js +114 -0
  146. package/dist/runtime/check-costs.js.map +1 -0
  147. package/dist/runtime/check-ids.d.ts +15 -0
  148. package/dist/runtime/check-ids.d.ts.map +1 -0
  149. package/dist/runtime/check-ids.js +140 -0
  150. package/dist/runtime/check-ids.js.map +1 -0
  151. package/dist/runtime/checks.d.ts +83 -20
  152. package/dist/runtime/checks.d.ts.map +1 -1
  153. package/dist/runtime/checks.js +3285 -1947
  154. package/dist/runtime/checks.js.map +1 -1
  155. package/dist/runtime/client.d.ts +112 -0
  156. package/dist/runtime/client.d.ts.map +1 -1
  157. package/dist/runtime/client.js +271 -39
  158. package/dist/runtime/client.js.map +1 -1
  159. package/dist/runtime/cookies.d.ts +16 -2
  160. package/dist/runtime/cookies.d.ts.map +1 -1
  161. package/dist/runtime/cookies.js +83 -22
  162. package/dist/runtime/cookies.js.map +1 -1
  163. package/dist/runtime/exchanges.d.ts +4 -5
  164. package/dist/runtime/exchanges.d.ts.map +1 -1
  165. package/dist/runtime/exchanges.js +33 -55
  166. package/dist/runtime/exchanges.js.map +1 -1
  167. package/dist/runtime/exit.d.ts +40 -0
  168. package/dist/runtime/exit.d.ts.map +1 -0
  169. package/dist/runtime/exit.js +48 -0
  170. package/dist/runtime/exit.js.map +1 -0
  171. package/dist/runtime/feature-gate.d.ts +0 -9
  172. package/dist/runtime/feature-gate.d.ts.map +1 -1
  173. package/dist/runtime/feature-gate.js +0 -21
  174. package/dist/runtime/feature-gate.js.map +1 -1
  175. package/dist/runtime/finding.d.ts +49 -8
  176. package/dist/runtime/finding.d.ts.map +1 -1
  177. package/dist/runtime/finding.js +51 -8
  178. package/dist/runtime/finding.js.map +1 -1
  179. package/dist/runtime/fixture.d.ts +35 -28
  180. package/dist/runtime/fixture.d.ts.map +1 -1
  181. package/dist/runtime/fixture.js +92 -731
  182. package/dist/runtime/fixture.js.map +1 -1
  183. package/dist/runtime/generate.d.ts +63 -0
  184. package/dist/runtime/generate.d.ts.map +1 -0
  185. package/dist/runtime/generate.js +635 -0
  186. package/dist/runtime/generate.js.map +1 -0
  187. package/dist/runtime/headers.d.ts +6 -0
  188. package/dist/runtime/headers.d.ts.map +1 -0
  189. package/dist/runtime/headers.js +16 -0
  190. package/dist/runtime/headers.js.map +1 -0
  191. package/dist/runtime/path.d.ts +7 -0
  192. package/dist/runtime/path.d.ts.map +1 -0
  193. package/dist/runtime/path.js +44 -0
  194. package/dist/runtime/path.js.map +1 -0
  195. package/dist/runtime/payloads.d.ts +2 -0
  196. package/dist/runtime/payloads.d.ts.map +1 -1
  197. package/dist/runtime/payloads.js +2 -1
  198. package/dist/runtime/payloads.js.map +1 -1
  199. package/dist/runtime/rate-limit.d.ts +6 -4
  200. package/dist/runtime/rate-limit.d.ts.map +1 -1
  201. package/dist/runtime/rate-limit.js +22 -33
  202. package/dist/runtime/rate-limit.js.map +1 -1
  203. package/dist/runtime/redact.d.ts +24 -0
  204. package/dist/runtime/redact.d.ts.map +1 -0
  205. package/dist/runtime/redact.js +98 -0
  206. package/dist/runtime/redact.js.map +1 -0
  207. package/dist/runtime/run.d.ts +34 -2
  208. package/dist/runtime/run.d.ts.map +1 -1
  209. package/dist/runtime/run.js +346 -138
  210. package/dist/runtime/run.js.map +1 -1
  211. package/dist/runtime/runs.d.ts +5 -0
  212. package/dist/runtime/runs.d.ts.map +1 -1
  213. package/dist/runtime/runs.js +7 -1
  214. package/dist/runtime/runs.js.map +1 -1
  215. package/dist/runtime/teardown.d.ts +17 -4
  216. package/dist/runtime/teardown.d.ts.map +1 -1
  217. package/dist/runtime/teardown.js +49 -16
  218. package/dist/runtime/teardown.js.map +1 -1
  219. package/dist/runtime/transcript.d.ts +34 -2
  220. package/dist/runtime/transcript.d.ts.map +1 -1
  221. package/dist/runtime/transcript.js +119 -24
  222. package/dist/runtime/transcript.js.map +1 -1
  223. package/dist/runtime/unique.d.ts +2 -11
  224. package/dist/runtime/unique.d.ts.map +1 -1
  225. package/dist/runtime/unique.js +27 -53
  226. package/dist/runtime/unique.js.map +1 -1
  227. package/dist/runtime/validate.d.ts +13 -2
  228. package/dist/runtime/validate.d.ts.map +1 -1
  229. package/dist/runtime/validate.js +51 -31
  230. package/dist/runtime/validate.js.map +1 -1
  231. package/dist/runtime/wait.d.ts +2 -1
  232. package/dist/runtime/wait.d.ts.map +1 -1
  233. package/dist/runtime/wait.js +2 -36
  234. package/dist/runtime/wait.js.map +1 -1
  235. package/dist/runtime/world.d.ts +24 -1
  236. package/dist/runtime/world.d.ts.map +1 -1
  237. package/dist/runtime/world.js +101 -26
  238. package/dist/runtime/world.js.map +1 -1
  239. package/dist/spec/collection.d.ts +8 -2
  240. package/dist/spec/collection.d.ts.map +1 -1
  241. package/dist/spec/collection.js +47 -7
  242. package/dist/spec/collection.js.map +1 -1
  243. package/dist/spec/extensions.d.ts +5 -1
  244. package/dist/spec/extensions.d.ts.map +1 -1
  245. package/dist/spec/extensions.js +9 -2
  246. package/dist/spec/extensions.js.map +1 -1
  247. package/dist/spec/graph.d.ts +46 -0
  248. package/dist/spec/graph.d.ts.map +1 -1
  249. package/dist/spec/graph.js +175 -11
  250. package/dist/spec/graph.js.map +1 -1
  251. package/dist/spec/load.d.ts +25 -5
  252. package/dist/spec/load.d.ts.map +1 -1
  253. package/dist/spec/load.js +160 -59
  254. package/dist/spec/load.js.map +1 -1
  255. package/dist/spec/query-capabilities.d.ts +4 -0
  256. package/dist/spec/query-capabilities.d.ts.map +1 -1
  257. package/dist/spec/query-capabilities.js +6 -0
  258. package/dist/spec/query-capabilities.js.map +1 -1
  259. package/dist/spec/schema.d.ts +20 -0
  260. package/dist/spec/schema.d.ts.map +1 -0
  261. package/dist/spec/schema.js +118 -0
  262. package/dist/spec/schema.js.map +1 -0
  263. package/dist/spec/tag-schema.d.ts +12 -0
  264. package/dist/spec/tag-schema.d.ts.map +1 -0
  265. package/dist/spec/tag-schema.js +143 -0
  266. package/dist/spec/tag-schema.js.map +1 -0
  267. package/dist/spec/types.js +17 -1
  268. package/dist/spec/types.js.map +1 -1
  269. package/docs/api.md +59 -0
  270. package/docs/assumptions.md +99 -0
  271. package/docs/checks.md +183 -0
  272. package/docs/commands.md +214 -0
  273. package/docs/configuration.md +650 -0
  274. package/docs/model.md +184 -0
  275. package/docs/reference-defects.md +94 -0
  276. package/docs/reports.md +211 -0
  277. package/docs/tags.md +400 -0
  278. package/package.json +3 -1
@@ -5,7 +5,9 @@
5
5
  * everything downstream is reported BLOCKED against that single cause, rather than re-failing
6
6
  * per case — which is what made prior attempts produce dozens of failures from one broken fixture.
7
7
  */
8
- import { buildModel } from "../spec/graph.js";
8
+ import { randomBytes } from "node:crypto";
9
+ import { isSecretHeaderName, registerSecret, registerSecretHeader } from "./redact.js";
10
+ import { buildModel, operationResolver } from "../spec/graph.js";
9
11
  import { dereference, loadSpec } from "../spec/load.js";
10
12
  import { createPrincipal } from "./auth.js";
11
13
  import { createExchangeJournal } from "./exchanges.js";
@@ -13,9 +15,8 @@ import { DEFAULT_NETWORK_RETRIES, DEFAULT_NETWORK_WAIT_MS, NetworkError, createN
13
15
  import { resolveBackoff } from "./poll.js";
14
16
  import { persistedToPrincipal, snapshotPrincipal } from "./principals.js";
15
17
  import { CHECKS } from "./checks.js";
16
- import { Client } from "./client.js";
17
- import { FindingCollector } from "./finding.js";
18
- import { reportFeatureGateSchemaDrift } from "./feature-gate.js";
18
+ import { AuthRefreshError, Client, } from "./client.js";
19
+ import { FindingCollector, isRootCause } from "./finding.js";
19
20
  import { formatUniqueSets } from "../spec/extensions.js";
20
21
  import { excludedByProfile, resolveProfile } from "./profile.js";
21
22
  import { authStepOperationIds, buildScopeReport, createIsAuthProvisioned, GradeLedger, resolveTargetScope, staticSubjects, } from "./scope.js";
@@ -25,19 +26,6 @@ import { Ledger } from "./teardown.js";
25
26
  import { SchemaValidator } from "./validate.js";
26
27
  import { isOverflowError, overflowFrom } from "./fixture.js";
27
28
  import { SeedError, fillPath, listExisting, probeCreateFixtures, resolvePathScope, seedCohort, } from "./world.js";
28
- function readPointer(body, pointer) {
29
- const path = pointer
30
- .replace(/^\$\.?/, "")
31
- .split(".")
32
- .filter(Boolean);
33
- let node = body;
34
- for (const segment of path) {
35
- if (node === null || typeof node !== "object")
36
- return undefined;
37
- node = node[segment];
38
- }
39
- return node;
40
- }
41
29
  function sameTenant(a, b) {
42
30
  const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
43
31
  if (keys.length === 0)
@@ -81,37 +69,6 @@ async function teardownPrincipals(principals, hooks, findings) {
81
69
  }
82
70
  }
83
71
  }
84
- /**
85
- * A 429 is a finding only when the request that drew it was demonstrably under a rate the
86
- * *document* declared — `rateLimitSource === "tag"` and the bucket had a free token, meaning oat
87
- * did not have to wait for one. A 429 against a config-supplied rate is the operator's own guess
88
- * about the environment, not a claim the API made, so it is paced around and never reported; a
89
- * 429 that only arrived after oat's own bucket made the request wait means oat's rate model was
90
- * too generous, which is oat's fault, not the backend's.
91
- *
92
- * Grouped by category rather than reported per request: the point is "the declared rate for X is
93
- * wrong", once, not a flood of identical findings for every request that category serves.
94
- */
95
- function reportRateLimitViolations(client, findings) {
96
- const byCategory = new Map();
97
- for (const exchange of client.transcript) {
98
- if (exchange.status !== 429)
99
- continue;
100
- if (exchange.rateLimitSource !== "tag" || exchange.rateLimitHadRoom !== true)
101
- continue;
102
- const category = exchange.rateLimitCategory ?? "unknown";
103
- const list = byCategory.get(category) ?? [];
104
- list.push(exchange);
105
- byCategory.set(category, list);
106
- }
107
- for (const [category, exchanges] of byCategory) {
108
- findings.spec("spec.declared-rate-limit-is-honoured", category, `the declared "${category}" rate limit is not honoured`, `${exchanges.length} request(s) to the "${category}" category returned 429 despite oat ` +
109
- "pacing them within the rate x-rate-limit declares — each had a free token in its own " +
110
- "bucket at the moment it was sent, so this is not oat outrunning its own model. Either " +
111
- `the declared rate is wrong, or the backend enforces a stricter one than "${category}" ` +
112
- "documents.", exchanges.slice(0, 3));
113
- }
114
- }
115
72
  /**
116
73
  * Resolves a principal to something that can produce headers on demand.
117
74
  *
@@ -124,6 +81,13 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
124
81
  /* A principal without a flow authenticates by static header — a long-lived API key needs no
125
82
  * acquisition at all, and forcing one would be ceremony. */
126
83
  if (principal.auth === undefined) {
84
+ /* A static key is a credential like any other: never written down in clear. */
85
+ for (const [name, value] of Object.entries(principal.headers ?? {})) {
86
+ if (!isSecretHeaderName(name) && !/key|token|secret|auth/i.test(name))
87
+ continue;
88
+ registerSecretHeader(name);
89
+ registerSecret(value);
90
+ }
127
91
  return {
128
92
  headers: () => principal.headers ?? {},
129
93
  id: principal.id,
@@ -166,7 +130,7 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
166
130
  };
167
131
  }
168
132
  /** Reads whatever the list endpoint already returns, for degraded read-only coverage. */
169
- async function readExisting(listOp, client, headers, roots) {
133
+ async function readExisting(listOp, model, client, headers, roots) {
170
134
  /* Seed with every known root, not only the list route's own parameters. Sibling routes for
171
135
  * the same entity are often scoped differently — a global list beside a tenant-scoped item
172
136
  * route — and a scope built from the list alone leaves those unresolvable. */
@@ -175,7 +139,7 @@ async function readExisting(listOp, client, headers, roots) {
175
139
  if (scope[param] === undefined)
176
140
  return { records: [], scope };
177
141
  }
178
- const records = await listExisting(listOp, client, headers, scope);
142
+ const records = await listExisting(listOp, model, client, headers, scope);
179
143
  return { records, scope };
180
144
  }
181
145
  export async function run(options) {
@@ -190,7 +154,7 @@ export async function run(options) {
190
154
  let currentCheck;
191
155
  let currentEntityIndex;
192
156
  let entityTotal;
193
- const defectCount = () => findings.findings.filter((f) => f.verdict !== "BLOCKED" && f.verdict !== "COVERAGE_GAP").length;
157
+ const defectCount = () => findings.findings.filter(isRootCause).length;
194
158
  const oldestInflight = () => {
195
159
  let oldest;
196
160
  for (const probe of inflight) {
@@ -294,9 +258,11 @@ export async function run(options) {
294
258
  remember(exchange);
295
259
  if (journal !== null) {
296
260
  try {
261
+ /* The exchange names its own check and entity: a shared "current check" was wrong
262
+ * whenever checks ran concurrently, and journaled a whole batch under the last one. */
297
263
  await journal.record(exchange, {
298
- ...(currentCheck === undefined ? {} : { check: currentCheck }),
299
- ...(currentEntity === undefined ? {} : { entity: currentEntity }),
264
+ ...(exchange.check === undefined ? {} : { check: exchange.check }),
265
+ ...(exchange.subject === undefined ? {} : { entity: exchange.subject }),
300
266
  phase: currentPhase,
301
267
  });
302
268
  }
@@ -354,10 +320,15 @@ export async function run(options) {
354
320
  client = new Client(options.baseUrl, options.globalHeaders ?? {}, options.maxInFlight ?? 4, onExchange, rateLimiter, httpHooks, networkClient);
355
321
  if (hooks.resolveHeaders !== undefined)
356
322
  client.setResolveHeaders(hooks.resolveHeaders);
357
- const validator = new SchemaValidator();
323
+ client.setOperationResolver(operationResolver(model));
324
+ const validator = new SchemaValidator(model.defs);
358
325
  const ledger = new Ledger();
359
326
  const seed = options.seed ?? 1;
327
+ /* Fresh per run: the seed reproduces a run, the nonce keeps two runs' records apart. */
328
+ const nonce = options.nonce ?? randomBytes(4).toString("hex");
360
329
  const outOfBand = resolveBackoff(options.outOfBand);
330
+ /* Write paths that have had the whole payload catalog, shared by every entity in the run. */
331
+ const payloadPaths = new Map();
361
332
  if (options.principals[0] === undefined)
362
333
  throw new Error("oat: at least one principal is required");
363
334
  const originClients = await loadOriginClients(options.origins ?? [], hooks, options.maxInFlight ?? 4, options.globalHeaders ?? {}, onExchange, httpHooks, networkClient);
@@ -398,18 +369,21 @@ export async function run(options) {
398
369
  const resolved = [];
399
370
  try {
400
371
  for (const principal of options.principals) {
401
- resolved.push(await resolvePrincipal(principal, model, client, hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
372
+ resolved.push(await resolvePrincipal(principal, model, client.view({ purpose: "auth" }), hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
402
373
  }
403
374
  }
404
375
  catch (error) {
405
376
  if (!isNetworkError(error))
406
377
  throw error;
407
378
  noteNetwork(error, "auth");
379
+ await journal?.flush().catch(() => undefined);
408
380
  return {
409
381
  checksOutOfScope: [],
410
382
  checksRun: [],
411
383
  checksSkipped: [],
412
384
  checksSuppressed: [],
385
+ checkNotes: [],
386
+ checkTimings: [],
413
387
  client,
414
388
  created: 0,
415
389
  entitiesTested: [],
@@ -430,14 +404,51 @@ export async function run(options) {
430
404
  phase: "auth",
431
405
  requests: client.transcript.length,
432
406
  });
407
+ /* Every exchange names the principal whose credential it carried. */
408
+ client.setPrincipalResolver((sent) => resolved.find((principal) => {
409
+ const own = principal.headers();
410
+ return Object.keys(own).length > 0 && Object.entries(own).every(([key, value]) => sent[key] === value);
411
+ })?.id);
433
412
  const alpha = resolved[0];
434
- /* Isolation peer: first principal whose roots are a different tenant — not "whoever is
435
- * second in the array". A same-tenant viewer sitting at index 1 must not steal that slot. */
436
- const peer = resolved.slice(1).find((candidate) => !sameTenant(alpha.roots, candidate.roots));
413
+ const ownerOf = (principal) => ({ headers: principal.headers, id: principal.id });
414
+ const alphaOwner = ownerOf(alpha);
415
+ /* A record a principal created and could not remove itself — a member cannot delete — may be
416
+ * removed by a stronger principal of the same tenant, and by nobody else. */
417
+ const fallbackDeleters = (owner) => {
418
+ const creator = resolved.find((principal) => principal.id === owner.id);
419
+ if (creator === undefined)
420
+ return [];
421
+ return resolved
422
+ .filter((principal) => principal.id !== creator.id && sameTenant(principal.roots, creator.roots))
423
+ .sort((a, b) => b.rank - a.rank)
424
+ .map(ownerOf);
425
+ };
426
+ const recorder = (owner) => (entity, id, values) => {
427
+ ledger.record(entity, id, values, owner);
428
+ };
429
+ /**
430
+ * Proactive refreshes, before each entity and before the credentials are persisted. A failure
431
+ * is reported — the backend would not renew a session — and the run carries on to teardown,
432
+ * where an exception here used to end it with every record still in place.
433
+ */
434
+ const refreshAll = async (where) => {
435
+ for (const principal of resolved) {
436
+ try {
437
+ await principal.runtime?.refreshIfStale();
438
+ }
439
+ catch (error) {
440
+ findings.blocked("auth.refresh", where, `refreshing the credential of "${principal.id}" failed`, error instanceof Error ? error.message : String(error));
441
+ return false;
442
+ }
443
+ }
444
+ return true;
445
+ };
437
446
  const entitiesTested = [];
438
447
  const checksRun = new Set();
439
448
  const checksSkipped = [];
440
449
  const checksSuppressed = [];
450
+ const checkTimings = [];
451
+ const checkNotes = [];
441
452
  const checksOutOfScope = [];
442
453
  const profileExcludes = (operationId) => {
443
454
  const op = model.byOperationId.get(operationId);
@@ -465,6 +476,17 @@ export async function run(options) {
465
476
  const createOp = model.byOperationId.get(entity.create ?? "");
466
477
  if (listOp === undefined)
467
478
  return;
479
+ /* Cohort variants the backend refused. A check that stands down for want of cohort data
480
+ * says which variants never arrived, so the gap points at the seed rather than the API. */
481
+ let lostVariants = [];
482
+ /* This entity's own scratch parents, shared by its records and its principals' scopes but by
483
+ * no other entity: entities whose parent chains are disjoint run side by side, and one that
484
+ * shares a parent record with another could see that entity's writes through it. */
485
+ const ancestors = new Map();
486
+ const withLostVariants = (needs) => lostVariants.length === 0 || !/cohort|record/.test(needs)
487
+ ? needs
488
+ : `${needs} (the cohort is missing its ${lostVariants.map((v) => `"${v}"`).join(", ")} ` +
489
+ `variant${lostVariants.length === 1 ? "" : "s"}, which failed to seed)`;
468
490
  if (networkGate.exhausted)
469
491
  return;
470
492
  /* Targets this entity's checks can grade. Seeding findings land on them: under --ops a seed
@@ -487,6 +509,8 @@ export async function run(options) {
487
509
  currentEntity = entity.name;
488
510
  currentCheck = undefined;
489
511
  currentPhase = "seed";
512
+ /* Everything this entity's world-building sends is seeding, attributed to the entity. */
513
+ const seeding = client.view({ purpose: "seed", subject: entity.name });
490
514
  tick({
491
515
  entity: entity.name,
492
516
  entityIndex: currentEntityIndex,
@@ -495,8 +519,8 @@ export async function run(options) {
495
519
  phase: "seed",
496
520
  requests: client.transcript.length,
497
521
  });
498
- for (const principal of resolved)
499
- await principal.runtime?.refreshIfStale();
522
+ if (!(await refreshAll(entity.name)))
523
+ return;
500
524
  const rootValues = { ...options.roots, ...alpha.roots };
501
525
  let scope;
502
526
  let records;
@@ -506,7 +530,7 @@ export async function run(options) {
506
530
  /* Same fallback a failed create takes below: read-only coverage against whatever
507
531
  * already exists beats no coverage, and it is exactly the state most likely to hide a
508
532
  * read-path bug. The gap finding excludeOp already reported names the reason. */
509
- const existing = await readExisting(listOp, client, alpha.headers(), {
533
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
510
534
  ...options.roots,
511
535
  ...alpha.roots,
512
536
  });
@@ -522,7 +546,7 @@ export async function run(options) {
522
546
  else if (createOp === undefined || authProvisioned) {
523
547
  /* Invite is not a fixture create; register / x-fresh-principal already ran in auth.
524
548
  * The invite check is the only thing that POSTs a grant, and it uses inviteAs. */
525
- const existing = await readExisting(listOp, client, alpha.headers(), {
549
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
526
550
  ...options.roots,
527
551
  ...alpha.roots,
528
552
  });
@@ -532,8 +556,12 @@ export async function run(options) {
532
556
  }
533
557
  else {
534
558
  try {
535
- scope = await resolvePathScope(createOp, model, client, {
559
+ scope = await resolvePathScope(createOp, model, seeding, {
536
560
  authHeaders: alpha.headers,
561
+ ancestors,
562
+ onCreate: recorder(alphaOwner),
563
+ principal: alpha.id,
564
+ nonce,
537
565
  roots: rootValues,
538
566
  seed,
539
567
  uploads,
@@ -543,9 +571,13 @@ export async function run(options) {
543
571
  * a tenant-scoped item route — and a scope built from create alone leaves those
544
572
  * unresolvable, which shows up as a wall of "could not complete" gaps. */
545
573
  scope.values = { ...rootValues, ...scope.values };
546
- const cohort = await seedCohort(createOp, model, client, {
574
+ const cohort = await seedCohort(createOp, model, seeding, {
547
575
  authHeaders: alpha.headers,
548
576
  ...(options.cohortSize === undefined ? {} : { cohortSize: options.cohortSize }),
577
+ ancestors,
578
+ onCreate: recorder(alphaOwner),
579
+ principal: alpha.id,
580
+ nonce,
549
581
  roots: rootValues,
550
582
  seed,
551
583
  uploads,
@@ -553,6 +585,19 @@ export async function run(options) {
553
585
  if (cohort.uniqueGap !== undefined) {
554
586
  entityFindings.gap("world.seed", entity.name, cohort.uniqueGap, cohort.uniqueGap);
555
587
  }
588
+ lostVariants = (cohort.failedVariants ?? []).map((failed) => failed.variant);
589
+ for (const failed of cohort.failedVariants ?? []) {
590
+ const said = JSON.stringify(failed.exchange.responseBody).slice(0, 300);
591
+ if (failed.exchange.status >= 500) {
592
+ entityFindings.backend("create.does-not-error", entity.name, `creating the "${failed.variant}" variant of a "${entity.name}" fails with a server error`, `${createOp.operationId} returned ${failed.exchange.status} for a body generated from the ` +
593
+ `documented schema (${said}). The other variants were created, so this input is what ` +
594
+ "breaks the handler. Checks that need this variant's data run without it.", [failed.exchange]);
595
+ }
596
+ else {
597
+ entityFindings.gap("world.seed", entity.name, `the "${failed.variant}" variant of "${entity.name}" was refused with ${failed.exchange.status}`, `${createOp.operationId} refused a body the document permits (${said}). Checks that ` +
598
+ "rely on this variant's values run without them.");
599
+ }
600
+ }
556
601
  if (cohort.adopted === true) {
557
602
  /* Plan limit after an effect already created the row: keep the id so children
558
603
  * (row after extract→table) can seed, but do not assert write-path oracles
@@ -560,17 +605,10 @@ export async function run(options) {
560
605
  entityFindings.gap("world.seed", entity.name, `seeding "${entity.name}" hit a plan limit; using an existing same-tenant record`, `${createOp.operationId} returned a plan-limit refusal and the list already ` +
561
606
  "had a record — likely an earlier x-effects create. Write-path checks stand " +
562
607
  "down rather than treat payment_required as a backend defect.");
608
+ /* Adopted, not created: what oat did create was recorded as it happened, and
609
+ * this record is somebody else's to keep. */
563
610
  records = cohort.records;
564
611
  degraded = true;
565
- for (const ancestor of scope.created) {
566
- ledger.record(ancestor.entity, ancestor.id, scope.values);
567
- }
568
- for (const record of records) {
569
- const id = record[entity.identity ?? "id"];
570
- if (typeof id === "string" || typeof id === "number") {
571
- ledger.record(entity.name, String(id), scope.values);
572
- }
573
- }
574
612
  }
575
613
  else if (cohort.uniqueAdopted === true) {
576
614
  /* Unique 409 on the first variant with a same-tenant list: adopt like 402,
@@ -584,24 +622,14 @@ export async function run(options) {
584
622
  records = cohort.records;
585
623
  degraded = true;
586
624
  uniqueAdopted = true;
587
- for (const ancestor of scope.created) {
588
- ledger.record(ancestor.entity, ancestor.id, scope.values);
589
- }
590
- for (const record of records) {
591
- const id = record[entity.identity ?? "id"];
592
- if (typeof id === "string" || typeof id === "number") {
593
- ledger.record(entity.name, String(id), scope.values);
594
- }
595
- }
596
625
  }
597
626
  else if (cohort.featureGate !== null) {
598
627
  /* Same degradation a profile-excluded create takes: the tag said this
599
628
  * principal cannot create the row, so a correct 403 is coverage, not a
600
- * seed defect. The 403 body still has to match the documented schema. */
601
- reportFeatureGateSchemaDrift(entityFindings, validator, createOp, model.rawOperations.get(createOp.operationId), cohort.featureGate.exchange, entity.name);
629
+ * seed defect. schema.error-response-matches-document still judges its body. */
602
630
  entityFindings.gap("world.seed", entity.name, `seeding "${entity.name}" is gated by ${cohort.featureGate.detail}`, `${cohort.featureGate.detail}. Checks that need a row oat created stand down ` +
603
631
  "rather than treat the documented 403 as a defect.");
604
- const existing = await readExisting(listOp, client, alpha.headers(), {
632
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
605
633
  ...options.roots,
606
634
  ...alpha.roots,
607
635
  });
@@ -614,15 +642,9 @@ export async function run(options) {
614
642
  degraded = true;
615
643
  }
616
644
  else {
645
+ /* Every ancestor and cohort record was ledgered as it was created, in creation
646
+ * order — the unwind reverses it, so children go before their parents. */
617
647
  records = cohort.records;
618
- /* Ancestors first, then the cohort — the unwind reverses this, so children are
619
- * always removed before the parents they hang from. */
620
- for (const ancestor of scope.created) {
621
- ledger.record(ancestor.entity, ancestor.id, scope.values);
622
- }
623
- for (const record of records) {
624
- ledger.record(entity.name, String(record[entity.identity ?? "id"]), scope.values);
625
- }
626
648
  }
627
649
  }
628
650
  catch (error) {
@@ -633,7 +655,7 @@ export async function run(options) {
633
655
  if (isOverflowError(error)) {
634
656
  const overflow = overflowFrom(error, createOp.operationId);
635
657
  entityFindings.gap("world.seed", entity.name, overflow.message, overflow.message);
636
- const existing = await readExisting(listOp, client, alpha.headers(), {
658
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
637
659
  ...options.roots,
638
660
  ...alpha.roots,
639
661
  });
@@ -652,7 +674,10 @@ export async function run(options) {
652
674
  const failedOp = (error instanceof SeedError ? error.operationId : undefined) ?? createOp.operationId;
653
675
  const evidence = error instanceof SeedError && error.exchange !== undefined
654
676
  ? [error.exchange]
655
- : client.transcript.filter((e) => e.status >= 500).slice(-1);
677
+ : await Promise.all(client.transcript
678
+ .filter((e) => e.status >= 500)
679
+ .slice(-1)
680
+ .map((e) => client.hydrate(e)));
656
681
  /* Under --ops a create nobody targeted is support. Its failure leaves the targets
657
682
  * ungraded — blocked, with the evidence — but it is not what this run was asked to
658
683
  * judge, so it is not reported as a defect of its own. */
@@ -670,7 +695,7 @@ export async function run(options) {
670
695
  /* Fall back to whatever already exists. A backend whose create is broken can still
671
696
  * have a working list, and read-only coverage beats no coverage — this is exactly
672
697
  * the state in which a read-path bug is most likely to be sitting undiscovered. */
673
- const existing = await readExisting(listOp, client, alpha.headers(), {
698
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
674
699
  ...options.roots,
675
700
  ...alpha.roots,
676
701
  });
@@ -688,8 +713,13 @@ export async function run(options) {
688
713
  const actorOf = async (principal, seedOffset) => {
689
714
  const roots = { ...options.roots, ...principal.roots };
690
715
  try {
691
- const next = await resolvePathScope(listOp, model, client, {
716
+ const next = await resolvePathScope(listOp, model, seeding, {
692
717
  authHeaders: principal.headers,
718
+ /* Created as this principal, so removed as this principal. */
719
+ ancestors,
720
+ onCreate: recorder(ownerOf(principal)),
721
+ principal: principal.id,
722
+ nonce,
693
723
  roots,
694
724
  seed: seed + seedOffset,
695
725
  uploads: worldUploads(seedOffset),
@@ -745,9 +775,22 @@ export async function run(options) {
745
775
  * reporting that it had cleaned up everything it made. Recording centrally rather than at
746
776
  * each call site means a new check cannot forget: the wrapper sees every request.
747
777
  */
748
- const trackingClient = createOp === undefined || degraded
749
- ? client
750
- : new Proxy(client, {
778
+ let createPath;
779
+ try {
780
+ createPath = createOp === undefined ? undefined : fillPath(createOp.path, scope.values);
781
+ }
782
+ catch {
783
+ createPath = undefined;
784
+ }
785
+ /* Whoever sent the request owns what it created: matched on the credential it carried. */
786
+ const ownerByHeaders = (sent) => {
787
+ const rendered = JSON.stringify(typeof sent === "function" ? sent() : (sent ?? {}));
788
+ const match = resolved.find((principal) => JSON.stringify(principal.headers()) === rendered);
789
+ return match === undefined ? alphaOwner : ownerOf(match);
790
+ };
791
+ const tracked = (base) => createOp === undefined || degraded
792
+ ? base
793
+ : new Proxy(base, {
751
794
  get(target, property, receiver) {
752
795
  if (property !== "request")
753
796
  return Reflect.get(target, property, receiver);
@@ -755,15 +798,18 @@ export async function run(options) {
755
798
  const exchange = await target.request(method, path, options ?? {});
756
799
  if (method.toUpperCase() !== "POST" || exchange.status >= 300)
757
800
  return exchange;
801
+ /* Only this entity's own create makes one of its records. Any other 2xx
802
+ * POST carrying an `id` — an invite, an action — names something else, and
803
+ * on an integer-id API deleting by that number removes an unrelated row. */
804
+ if (path !== createPath)
805
+ return exchange;
758
806
  const body = exchange.responseBody;
759
807
  if (body === null || typeof body !== "object")
760
808
  return exchange;
761
809
  const id = body[entity.identity ?? "id"];
762
810
  if (typeof id !== "string" && typeof id !== "number")
763
811
  return exchange;
764
- /* Recording the same id twice is harmless — the unwind tolerates a 404
765
- * on an already-removed record — and missing one is not. */
766
- ledger.record(entity.name, String(id), scope.values);
812
+ ledger.record(entity.name, String(id), scope.values, ownerByHeaders(options?.headers));
767
813
  return exchange;
768
814
  };
769
815
  },
@@ -814,9 +860,11 @@ export async function run(options) {
814
860
  inScope: (op) => runScope.inScope(op.operationId),
815
861
  hooks,
816
862
  outOfBand,
863
+ payloads: { policy: options.payloads ?? "per-write-path", ran: payloadPaths },
817
864
  auth: alpha.headers,
865
+ recordCreated: recorder(alphaOwner),
818
866
  ...(alpha.runtime === undefined ? {} : { refreshIfStale: alpha.runtime.refreshIfStale }),
819
- client: trackingClient,
867
+ client: tracked(client.view({ purpose: "assertion", subject: entity.name })),
820
868
  collectionKey: listOp.collection?.key ?? null,
821
869
  /* In degraded mode oat did not write these records, so it has no oracle for them —
822
870
  * every write-path check must sit out rather than assert against data it did not
@@ -840,6 +888,7 @@ export async function run(options) {
840
888
  softDelete: model.operations.find((op) => op.entity === entity.name && op.softDelete !== null)?.softDelete ??
841
889
  listOp.softDelete,
842
890
  seed,
891
+ nonce,
843
892
  updateOp: degraded || updateExcluded ? undefined : updateOpModel,
844
893
  uploads,
845
894
  validator,
@@ -858,12 +907,20 @@ export async function run(options) {
858
907
  entitiesTested.push(entity.name);
859
908
  /* Subjects each check grades on this entity: in scope, and not excluded by the profile. */
860
909
  const gradedBy = new Map();
910
+ /* What each check's plan gathered, and how each check ended, on this entity. */
911
+ const plans = new Map();
912
+ const outcomes = new Map();
861
913
  const runOne = async (check) => {
862
914
  const graded = gradedBy.get(check.id) ?? [];
863
915
  let judged;
916
+ /* Its own client: every exchange names this check, even while a batch runs concurrently.
917
+ * A check that does not mutate gets one that refuses to write. */
918
+ const stamp = { check: check.id, purpose: "assertion", subject: entity.name };
919
+ const attributed = graded.length === 0 ? findings : findings.attributed(graded);
864
920
  const view = {
865
921
  ...ctx,
866
- ...(graded.length === 0 ? {} : { findings: findings.attributed(graded) }),
922
+ client: tracked(check.mutates === true ? client.view(stamp) : client.readOnlyView(stamp)),
923
+ findings: attributed.ownedBy(check.id),
867
924
  judged: (operationIds) => {
868
925
  judged ??= new Set();
869
926
  for (const id of operationIds)
@@ -881,15 +938,45 @@ export async function run(options) {
881
938
  phase: "test",
882
939
  requests: client.transcript.length,
883
940
  });
941
+ const start = findings.findings.length;
942
+ const began = performance.now();
943
+ let outcome;
884
944
  try {
885
- await check.run(view);
945
+ outcome = await check.run(view, plans.get(check.id));
886
946
  }
887
947
  catch (error) {
888
948
  if (isNetworkError(error)) {
889
949
  noteNetwork(error, entity.name);
950
+ outcomes.set(check.id, { kind: "unresolved", reason: "the network failed" });
890
951
  return;
891
952
  }
892
- view.findings.gap(check.id, entity.name, `check "${check.id}" could not complete`, error instanceof Error ? error.message : String(error));
953
+ if (error instanceof AuthRefreshError) {
954
+ outcomes.set(check.id, { kind: "unresolved", reason: "a credential could not be refreshed" });
955
+ attributed.blocked("auth.refresh", entity.name, `refreshing the credential${error.principal === undefined ? "" : ` of "${error.principal}"`} failed`, `${error.message}. ${check.id} could not complete.`);
956
+ return;
957
+ }
958
+ outcome = view.findings.gap(check.id, entity.name, `check "${check.id}" could not complete`, error instanceof Error ? error.message : String(error));
959
+ }
960
+ checkTimings.push({ check: check.id, entity: entity.name, ms: Math.round(performance.now() - began) });
961
+ /* A check that reported along the way and then fell through to "asserted" did not
962
+ * assert anything: what it reported is the outcome. */
963
+ if (outcome.kind === "asserted") {
964
+ const reported = findings.findings
965
+ .slice(start)
966
+ .find((f) => f.check === check.id && f.entity === entity.name && isRootCause(f));
967
+ if (reported !== undefined)
968
+ outcome = { kind: "finding", verdict: reported.verdict };
969
+ }
970
+ outcomes.set(check.id, outcome);
971
+ if (outcome.kind === "asserted" && outcome.note !== undefined) {
972
+ checkNotes.push({ check: check.id, entity: entity.name, note: outcome.note });
973
+ }
974
+ if (outcome.kind === "stood-down") {
975
+ /* Not a pass: the property was never tested here, and the report says why. */
976
+ const needs = withLostVariants(outcome.reason);
977
+ checksSkipped.push({ check: check.id, entity: entity.name, needs });
978
+ grades.skipped(graded, needs);
979
+ return;
893
980
  }
894
981
  grades.graded(judged === undefined ? graded : graded.filter((id) => judged?.has(id) === true), check.id);
895
982
  };
@@ -911,7 +998,13 @@ export async function run(options) {
911
998
  const suppressedBy = new Map();
912
999
  const suppressed = (check) => {
913
1000
  for (const dependency of check.dependsOn ?? []) {
914
- const failed = findings.findings.some((f) => f.check === dependency && f.entity === entity.name);
1001
+ /* Only a failed premise suppresses. A dependency that stood down, could not reach a
1002
+ * verdict, or was blocked says nothing about whether its premise holds. */
1003
+ const outcome = outcomes.get(dependency);
1004
+ const failed = outcome === undefined
1005
+ ? /* Not run as a check here, but reported against — during seeding, say. */
1006
+ findings.findings.some((f) => f.check === dependency && f.entity === entity.name && isRootCause(f))
1007
+ : outcome.kind === "finding";
915
1008
  const inherited = suppressedBy.get(dependency);
916
1009
  if (!failed && inherited === undefined)
917
1010
  continue;
@@ -940,6 +1033,8 @@ export async function run(options) {
940
1033
  await Promise.all(pending.map(runOne));
941
1034
  };
942
1035
  for (const check of CHECKS) {
1036
+ if (options.signal?.aborted === true)
1037
+ break;
943
1038
  const graded = check.subjects(entity, model).filter((id) => runScope.inScope(id) && !profileExcludes(id));
944
1039
  /* Out of scope is not a skip: the check could have run, this run was not asked to. */
945
1040
  if (runScope.mode === "targeted" && graded.length === 0) {
@@ -947,15 +1042,19 @@ export async function run(options) {
947
1042
  continue;
948
1043
  }
949
1044
  gradedBy.set(check.id, graded);
950
- if (!check.applicable(ctx)) {
1045
+ const planned = check.plan(ctx);
1046
+ if (!planned.ok) {
951
1047
  /* Recorded, not dropped: on an API shaped unlike the fixture this is most of the
952
1048
  * suite, and a silent skip reads exactly like a clean result. */
953
- const needs = check.needs ?? "an unstated precondition";
1049
+ const needs = withLostVariants(planned.needs ?? check.needs ?? "an unstated precondition");
954
1050
  checksSkipped.push({ check: check.id, entity: entity.name, needs });
955
1051
  grades.skipped(graded, needs);
956
1052
  continue;
957
1053
  }
958
- if (check.mutates === true) {
1054
+ plans.set(check.id, planned.value);
1055
+ /* A writer runs alone; so does a check that judges the transcript, once everything
1056
+ * queued before it has finished sending. */
1057
+ if (check.mutates === true || check.judgesTranscript === true) {
959
1058
  await flush();
960
1059
  if (suppressed(check))
961
1060
  continue;
@@ -972,22 +1071,55 @@ export async function run(options) {
972
1071
  await flush();
973
1072
  };
974
1073
  /*
975
- * Entities run in series. Checks inside an entity stay sequential because cascade
976
- * suppression consults findings already reported for it — concurrent checks would let a
977
- * root cause and its consequences race and both be reported. Nested graphs also couple
978
- * entities: a child create in flight while a parent page-walk runs invents pagination
979
- * findings, so there are no entity lanes.
1074
+ * Checks inside an entity stay ordered because cascade suppression consults findings already
1075
+ * reported for it — concurrent checks would let a root cause and its consequences race and
1076
+ * both be reported. Nested graphs couple entities too: a child create in flight while a
1077
+ * parent page-walk runs invents pagination findings. So entities that can observe each
1078
+ * other share a lane and run in series, and only lanes run side by side.
980
1079
  */
981
1080
  const queue = runScope.entities;
982
1081
  entityTotal = queue.length;
983
- for (const [index, entity] of queue.entries()) {
984
- if (networkGate.exhausted) {
985
- const left = queue.length - index;
986
- findings.blocked("net.unreachable", "run", `skipped ${left} remaining entit${left === 1 ? "y" : "ies"}`, "network down");
987
- break;
1082
+ let interrupted = false;
1083
+ let started = 0;
1084
+ let stopNoted = false;
1085
+ /* Entities that share nothing run side by side; see `entityLanes`. */
1086
+ const runLane = async (lane) => {
1087
+ for (const entity of lane) {
1088
+ /* Stopped from outside — Ctrl-C, a deadline. Nothing more is tested, and everything
1089
+ * made so far is still removed below. */
1090
+ if (options.signal?.aborted === true || networkGate.exhausted) {
1091
+ if (stopNoted)
1092
+ return;
1093
+ stopNoted = true;
1094
+ const left = queue.length - started;
1095
+ if (options.signal?.aborted === true) {
1096
+ interrupted = true;
1097
+ findings.gap("run.interrupted", "run", `stopped before ${left} remaining entit${left === 1 ? "y" : "ies"}`, "the run was interrupted; what was tested is reported, and what oat created is removed");
1098
+ }
1099
+ else {
1100
+ findings.blocked("net.unreachable", "run", `skipped ${left} remaining entit${left === 1 ? "y" : "ies"}`, "network down");
1101
+ }
1102
+ return;
1103
+ }
1104
+ started += 1;
1105
+ currentEntityIndex = started;
1106
+ try {
1107
+ await testEntity(entity);
1108
+ }
1109
+ catch (error) {
1110
+ /* An error oat did not expect must not skip teardown: it is reported, and the run
1111
+ * carries on to remove what it made and write its report. */
1112
+ findings.blocked("run.error", entity.name, `testing "${entity.name}" stopped on an unexpected error`, errorText(error));
1113
+ }
988
1114
  }
989
- currentEntityIndex = index + 1;
990
- await testEntity(entity);
1115
+ };
1116
+ try {
1117
+ await Promise.all(entityLanes(model, queue).map(runLane));
1118
+ }
1119
+ catch (error) {
1120
+ /* Each entity's errors are caught where it runs; this is the scheduling around them. What
1121
+ * was created is still removed below, and the report still written. */
1122
+ findings.blocked("run.error", "run", "testing stopped on an unexpected error", errorText(error));
991
1123
  }
992
1124
  /* Unwind after every check has run, never per case: a check may legitimately depend on records
993
1125
  * another one created, and tearing down early turns that into a phantom defect. */
@@ -998,25 +1130,32 @@ export async function run(options) {
998
1130
  phase: "teardown",
999
1131
  requests: client.transcript.length,
1000
1132
  });
1001
- const teardown = options.keepFixtures === true || ledger.size === 0
1002
- ? null
1003
- : await ledger.unwind(model, client, alpha.headers, (done, total, item) => {
1004
- if (done % 25 !== 0 && done !== total)
1005
- return;
1006
- tick({
1007
- entity: item.entity,
1008
- message: `${done}/${total} ${item.entity} ${item.id}`,
1009
- phase: "teardown",
1010
- requests: client.transcript.length,
1133
+ let teardown = null;
1134
+ if (options.keepFixtures !== true && ledger.size > 0) {
1135
+ try {
1136
+ teardown = await ledger.unwind(model, client.view({ purpose: "teardown" }), fallbackDeleters, (done, total, item) => {
1137
+ if (done % 25 !== 0 && done !== total)
1138
+ return;
1139
+ tick({
1140
+ entity: item.entity,
1141
+ message: `${done}/${total} ${item.entity} ${item.id}`,
1142
+ phase: "teardown",
1143
+ requests: client.transcript.length,
1144
+ });
1011
1145
  });
1012
- });
1146
+ }
1147
+ catch (error) {
1148
+ findings.blocked("world.teardown", "run", `teardown stopped on an unexpected error with ${ledger.size} record(s) ledgered`, errorText(error));
1149
+ }
1150
+ }
1013
1151
  if (teardown !== null && teardown.unsupported.length > 0) {
1014
1152
  findings.gap("world.teardown", teardown.unsupported.join(", "), "records created during the run could not be removed", `no delete operation is reachable for ${teardown.unsupported.join(", ")}, so this run left ` +
1015
1153
  "fixtures behind. Declare x-cleanup to name the route that removes them.");
1016
1154
  }
1017
- reportRateLimitViolations(client, findings);
1018
- for (const principal of resolved)
1019
- await principal.runtime?.refreshIfStale();
1155
+ await refreshAll("run");
1156
+ /* Every check has judged the transcript; bodies moved out of memory are no longer needed. */
1157
+ await client.dispose();
1158
+ await journal?.flush().catch(() => undefined);
1020
1159
  const persisted = resolved.map((principal) => snapshotPrincipal({
1021
1160
  headers: principal.headers,
1022
1161
  id: principal.id,
@@ -1027,7 +1166,12 @@ export async function run(options) {
1027
1166
  }));
1028
1167
  const coverage = scopeReport();
1029
1168
  if ((options.origins ?? []).length > 0 && options.skipPrincipalTeardown !== true) {
1030
- await runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage);
1169
+ try {
1170
+ await runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage);
1171
+ }
1172
+ catch (error) {
1173
+ findings.blocked("run.error", "origins", "the secondary origins stopped on an unexpected error", errorText(error));
1174
+ }
1031
1175
  }
1032
1176
  if (options.skipPrincipalTeardown !== true) {
1033
1177
  await teardownPrincipals(resolved, hooks, findings);
@@ -1038,6 +1182,8 @@ export async function run(options) {
1038
1182
  requests: client.transcript.length,
1039
1183
  });
1040
1184
  return {
1185
+ checkNotes,
1186
+ checkTimings,
1041
1187
  checksOutOfScope,
1042
1188
  checksRun: [...checksRun].sort(),
1043
1189
  checksSkipped,
@@ -1053,6 +1199,7 @@ export async function run(options) {
1053
1199
  profileExclusions,
1054
1200
  scope: coverage,
1055
1201
  teardown,
1202
+ ...(interrupted ? { interrupted } : {}),
1056
1203
  ...(journal === null ? {} : { exchanges: { count: journal.count } }),
1057
1204
  ...(networkOutcome === undefined ? {} : { network: networkOutcome }),
1058
1205
  };
@@ -1074,6 +1221,9 @@ async function loadOriginClients(origins, hooks, maxInFlight, globalHeaders, onE
1074
1221
  }
1075
1222
  return map;
1076
1223
  }
1224
+ function errorText(error) {
1225
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
1226
+ }
1077
1227
  async function runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage) {
1078
1228
  for (const origin of options.origins ?? []) {
1079
1229
  /* Targets were resolved against every origin up front; an origin none of them name has
@@ -1138,4 +1288,62 @@ async function runSecondaryOrigins(options, persisted, findings, checksRun, chec
1138
1288
  }
1139
1289
  }
1140
1290
  }
1291
+ /**
1292
+ * Entities grouped into lanes that can be tested side by side.
1293
+ *
1294
+ * Two entities share a lane when one can observe the other's writes: one's collection lives
1295
+ * under the other's (a child, created inside a parent oat also makes), an operation of one
1296
+ * invalidates a route of the other, or one's effects, waits or async polls land on the other.
1297
+ * Inside a lane entities keep their order and run one after another; lanes share nothing, so
1298
+ * nothing one does can turn up as a finding in another.
1299
+ */
1300
+ export function entityLanes(model, entities) {
1301
+ const names = entities.map((entity) => entity.name);
1302
+ const parent = new Map(names.map((name) => [name, name]));
1303
+ const find = (name) => {
1304
+ let root = name;
1305
+ while (parent.get(root) !== root)
1306
+ root = parent.get(root) ?? root;
1307
+ return root;
1308
+ };
1309
+ const join = (a, b) => {
1310
+ if (a === null || a === undefined || b === null || b === undefined)
1311
+ return;
1312
+ if (!parent.has(a) || !parent.has(b))
1313
+ return;
1314
+ parent.set(find(a), find(b));
1315
+ };
1316
+ const collection = (entity) => {
1317
+ const op = model.byOperationId.get(entity.list ?? entity.create ?? "");
1318
+ return op?.path.replace(/\/+$/, "");
1319
+ };
1320
+ for (const a of entities) {
1321
+ const pathA = collection(a);
1322
+ for (const b of entities) {
1323
+ const pathB = collection(b);
1324
+ if (a === b || pathA === undefined || pathB === undefined)
1325
+ continue;
1326
+ if (pathB.startsWith(`${pathA}/`))
1327
+ join(a.name, b.name);
1328
+ }
1329
+ }
1330
+ for (const op of model.operations) {
1331
+ for (const route of op.invalidates)
1332
+ join(op.entity, model.byRoute.get(route)?.entity);
1333
+ for (const effect of op.effects)
1334
+ join(op.entity, effect.entity);
1335
+ if (op.wait !== null)
1336
+ join(op.entity, model.byOperationId.get(op.wait.operationId)?.entity);
1337
+ if (op.async !== null) {
1338
+ const poll = model.byOperationId.get(op.async.poll) ?? model.byRoute.get(op.async.poll);
1339
+ join(op.entity, poll?.entity);
1340
+ }
1341
+ }
1342
+ const lanes = new Map();
1343
+ for (const entity of entities) {
1344
+ const root = find(entity.name);
1345
+ lanes.set(root, [...(lanes.get(root) ?? []), entity]);
1346
+ }
1347
+ return [...lanes.values()];
1348
+ }
1141
1349
  //# sourceMappingURL=run.js.map