@lovrozagar/oat 0.10.0 → 0.12.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 +8 -9
  136. package/dist/runtime/auth.d.ts.map +1 -1
  137. package/dist/runtime/auth.js +60 -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 +88 -20
  152. package/dist/runtime/checks.d.ts.map +1 -1
  153. package/dist/runtime/checks.js +3288 -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 +374 -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 +11 -1
  244. package/dist/spec/extensions.d.ts.map +1 -1
  245. package/dist/spec/extensions.js +10 -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 +144 -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 +403 -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,18 +26,27 @@ 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;
29
+ /**
30
+ * Static principals have no auth runtime. Copy the configured headers so adopting a credential
31
+ * does not mutate the config object, and keep a `Bearer ` prefix when that is how the header
32
+ * was already written.
33
+ */
34
+ function holdHeaders(initial) {
35
+ let current = initial === undefined ? {} : { ...initial };
36
+ return {
37
+ adoptCredential: (token) => {
38
+ if (token === "")
39
+ return;
40
+ const key = Object.keys(current).find((name) => name.toLowerCase() === "authorization") ?? "authorization";
41
+ const previous = current[key];
42
+ const value = previous === undefined || /^bearer\s+/i.test(previous) ? `Bearer ${token}` : token;
43
+ current = { ...current, [key]: value };
44
+ registerSecretHeader(key);
45
+ registerSecret(token);
46
+ registerSecret(value);
47
+ },
48
+ headers: () => current,
49
+ };
40
50
  }
41
51
  function sameTenant(a, b) {
42
52
  const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
@@ -81,37 +91,6 @@ async function teardownPrincipals(principals, hooks, findings) {
81
91
  }
82
92
  }
83
93
  }
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
94
  /**
116
95
  * Resolves a principal to something that can produce headers on demand.
117
96
  *
@@ -124,8 +103,17 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
124
103
  /* A principal without a flow authenticates by static header — a long-lived API key needs no
125
104
  * acquisition at all, and forcing one would be ceremony. */
126
105
  if (principal.auth === undefined) {
106
+ /* A static key is a credential like any other: never written down in clear. */
107
+ for (const [name, value] of Object.entries(principal.headers ?? {})) {
108
+ if (!isSecretHeaderName(name) && !/key|token|secret|auth/i.test(name))
109
+ continue;
110
+ registerSecretHeader(name);
111
+ registerSecret(value);
112
+ }
113
+ const held = holdHeaders(principal.headers);
127
114
  return {
128
- headers: () => principal.headers ?? {},
115
+ adoptCredential: held.adoptCredential,
116
+ headers: held.headers,
129
117
  id: principal.id,
130
118
  inviteAs: principal.inviteAs,
131
119
  rank: principal.rank ?? 0,
@@ -156,6 +144,7 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
156
144
  refreshIfStale: runtime.refreshIfStale,
157
145
  });
158
146
  return {
147
+ adoptCredential: (token) => runtime.adoptCredential(token),
159
148
  headers,
160
149
  id: principal.id,
161
150
  inviteAs: principal.inviteAs,
@@ -166,7 +155,7 @@ async function resolvePrincipal(principal, model, client, hooks, outOfBand, orig
166
155
  };
167
156
  }
168
157
  /** Reads whatever the list endpoint already returns, for degraded read-only coverage. */
169
- async function readExisting(listOp, client, headers, roots) {
158
+ async function readExisting(listOp, model, client, headers, roots) {
170
159
  /* Seed with every known root, not only the list route's own parameters. Sibling routes for
171
160
  * the same entity are often scoped differently — a global list beside a tenant-scoped item
172
161
  * route — and a scope built from the list alone leaves those unresolvable. */
@@ -175,7 +164,7 @@ async function readExisting(listOp, client, headers, roots) {
175
164
  if (scope[param] === undefined)
176
165
  return { records: [], scope };
177
166
  }
178
- const records = await listExisting(listOp, client, headers, scope);
167
+ const records = await listExisting(listOp, model, client, headers, scope);
179
168
  return { records, scope };
180
169
  }
181
170
  export async function run(options) {
@@ -190,7 +179,7 @@ export async function run(options) {
190
179
  let currentCheck;
191
180
  let currentEntityIndex;
192
181
  let entityTotal;
193
- const defectCount = () => findings.findings.filter((f) => f.verdict !== "BLOCKED" && f.verdict !== "COVERAGE_GAP").length;
182
+ const defectCount = () => findings.findings.filter(isRootCause).length;
194
183
  const oldestInflight = () => {
195
184
  let oldest;
196
185
  for (const probe of inflight) {
@@ -294,9 +283,11 @@ export async function run(options) {
294
283
  remember(exchange);
295
284
  if (journal !== null) {
296
285
  try {
286
+ /* The exchange names its own check and entity: a shared "current check" was wrong
287
+ * whenever checks ran concurrently, and journaled a whole batch under the last one. */
297
288
  await journal.record(exchange, {
298
- ...(currentCheck === undefined ? {} : { check: currentCheck }),
299
- ...(currentEntity === undefined ? {} : { entity: currentEntity }),
289
+ ...(exchange.check === undefined ? {} : { check: exchange.check }),
290
+ ...(exchange.subject === undefined ? {} : { entity: exchange.subject }),
300
291
  phase: currentPhase,
301
292
  });
302
293
  }
@@ -354,10 +345,15 @@ export async function run(options) {
354
345
  client = new Client(options.baseUrl, options.globalHeaders ?? {}, options.maxInFlight ?? 4, onExchange, rateLimiter, httpHooks, networkClient);
355
346
  if (hooks.resolveHeaders !== undefined)
356
347
  client.setResolveHeaders(hooks.resolveHeaders);
357
- const validator = new SchemaValidator();
348
+ client.setOperationResolver(operationResolver(model));
349
+ const validator = new SchemaValidator(model.defs);
358
350
  const ledger = new Ledger();
359
351
  const seed = options.seed ?? 1;
352
+ /* Fresh per run: the seed reproduces a run, the nonce keeps two runs' records apart. */
353
+ const nonce = options.nonce ?? randomBytes(4).toString("hex");
360
354
  const outOfBand = resolveBackoff(options.outOfBand);
355
+ /* Write paths that have had the whole payload catalog, shared by every entity in the run. */
356
+ const payloadPaths = new Map();
361
357
  if (options.principals[0] === undefined)
362
358
  throw new Error("oat: at least one principal is required");
363
359
  const originClients = await loadOriginClients(options.origins ?? [], hooks, options.maxInFlight ?? 4, options.globalHeaders ?? {}, onExchange, httpHooks, networkClient);
@@ -398,18 +394,21 @@ export async function run(options) {
398
394
  const resolved = [];
399
395
  try {
400
396
  for (const principal of options.principals) {
401
- resolved.push(await resolvePrincipal(principal, model, client, hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
397
+ resolved.push(await resolvePrincipal(principal, model, client.view({ purpose: "auth" }), hooks, outOfBand, originClients.size === 0 ? undefined : originClients));
402
398
  }
403
399
  }
404
400
  catch (error) {
405
401
  if (!isNetworkError(error))
406
402
  throw error;
407
403
  noteNetwork(error, "auth");
404
+ await journal?.flush().catch(() => undefined);
408
405
  return {
409
406
  checksOutOfScope: [],
410
407
  checksRun: [],
411
408
  checksSkipped: [],
412
409
  checksSuppressed: [],
410
+ checkNotes: [],
411
+ checkTimings: [],
413
412
  client,
414
413
  created: 0,
415
414
  entitiesTested: [],
@@ -430,14 +429,51 @@ export async function run(options) {
430
429
  phase: "auth",
431
430
  requests: client.transcript.length,
432
431
  });
432
+ /* Every exchange names the principal whose credential it carried. */
433
+ client.setPrincipalResolver((sent) => resolved.find((principal) => {
434
+ const own = principal.headers();
435
+ return Object.keys(own).length > 0 && Object.entries(own).every(([key, value]) => sent[key] === value);
436
+ })?.id);
433
437
  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));
438
+ const ownerOf = (principal) => ({ headers: principal.headers, id: principal.id });
439
+ const alphaOwner = ownerOf(alpha);
440
+ /* A record a principal created and could not remove itself — a member cannot delete — may be
441
+ * removed by a stronger principal of the same tenant, and by nobody else. */
442
+ const fallbackDeleters = (owner) => {
443
+ const creator = resolved.find((principal) => principal.id === owner.id);
444
+ if (creator === undefined)
445
+ return [];
446
+ return resolved
447
+ .filter((principal) => principal.id !== creator.id && sameTenant(principal.roots, creator.roots))
448
+ .sort((a, b) => b.rank - a.rank)
449
+ .map(ownerOf);
450
+ };
451
+ const recorder = (owner) => (entity, id, values) => {
452
+ ledger.record(entity, id, values, owner);
453
+ };
454
+ /**
455
+ * Proactive refreshes, before each entity and before the credentials are persisted. A failure
456
+ * is reported — the backend would not renew a session — and the run carries on to teardown,
457
+ * where an exception here used to end it with every record still in place.
458
+ */
459
+ const refreshAll = async (where) => {
460
+ for (const principal of resolved) {
461
+ try {
462
+ await principal.runtime?.refreshIfStale();
463
+ }
464
+ catch (error) {
465
+ findings.blocked("auth.refresh", where, `refreshing the credential of "${principal.id}" failed`, error instanceof Error ? error.message : String(error));
466
+ return false;
467
+ }
468
+ }
469
+ return true;
470
+ };
437
471
  const entitiesTested = [];
438
472
  const checksRun = new Set();
439
473
  const checksSkipped = [];
440
474
  const checksSuppressed = [];
475
+ const checkTimings = [];
476
+ const checkNotes = [];
441
477
  const checksOutOfScope = [];
442
478
  const profileExcludes = (operationId) => {
443
479
  const op = model.byOperationId.get(operationId);
@@ -465,6 +501,17 @@ export async function run(options) {
465
501
  const createOp = model.byOperationId.get(entity.create ?? "");
466
502
  if (listOp === undefined)
467
503
  return;
504
+ /* Cohort variants the backend refused. A check that stands down for want of cohort data
505
+ * says which variants never arrived, so the gap points at the seed rather than the API. */
506
+ let lostVariants = [];
507
+ /* This entity's own scratch parents, shared by its records and its principals' scopes but by
508
+ * no other entity: entities whose parent chains are disjoint run side by side, and one that
509
+ * shares a parent record with another could see that entity's writes through it. */
510
+ const ancestors = new Map();
511
+ const withLostVariants = (needs) => lostVariants.length === 0 || !/cohort|record/.test(needs)
512
+ ? needs
513
+ : `${needs} (the cohort is missing its ${lostVariants.map((v) => `"${v}"`).join(", ")} ` +
514
+ `variant${lostVariants.length === 1 ? "" : "s"}, which failed to seed)`;
468
515
  if (networkGate.exhausted)
469
516
  return;
470
517
  /* Targets this entity's checks can grade. Seeding findings land on them: under --ops a seed
@@ -487,6 +534,8 @@ export async function run(options) {
487
534
  currentEntity = entity.name;
488
535
  currentCheck = undefined;
489
536
  currentPhase = "seed";
537
+ /* Everything this entity's world-building sends is seeding, attributed to the entity. */
538
+ const seeding = client.view({ purpose: "seed", subject: entity.name });
490
539
  tick({
491
540
  entity: entity.name,
492
541
  entityIndex: currentEntityIndex,
@@ -495,8 +544,8 @@ export async function run(options) {
495
544
  phase: "seed",
496
545
  requests: client.transcript.length,
497
546
  });
498
- for (const principal of resolved)
499
- await principal.runtime?.refreshIfStale();
547
+ if (!(await refreshAll(entity.name)))
548
+ return;
500
549
  const rootValues = { ...options.roots, ...alpha.roots };
501
550
  let scope;
502
551
  let records;
@@ -506,7 +555,7 @@ export async function run(options) {
506
555
  /* Same fallback a failed create takes below: read-only coverage against whatever
507
556
  * already exists beats no coverage, and it is exactly the state most likely to hide a
508
557
  * read-path bug. The gap finding excludeOp already reported names the reason. */
509
- const existing = await readExisting(listOp, client, alpha.headers(), {
558
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
510
559
  ...options.roots,
511
560
  ...alpha.roots,
512
561
  });
@@ -522,7 +571,7 @@ export async function run(options) {
522
571
  else if (createOp === undefined || authProvisioned) {
523
572
  /* Invite is not a fixture create; register / x-fresh-principal already ran in auth.
524
573
  * The invite check is the only thing that POSTs a grant, and it uses inviteAs. */
525
- const existing = await readExisting(listOp, client, alpha.headers(), {
574
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
526
575
  ...options.roots,
527
576
  ...alpha.roots,
528
577
  });
@@ -532,8 +581,12 @@ export async function run(options) {
532
581
  }
533
582
  else {
534
583
  try {
535
- scope = await resolvePathScope(createOp, model, client, {
584
+ scope = await resolvePathScope(createOp, model, seeding, {
536
585
  authHeaders: alpha.headers,
586
+ ancestors,
587
+ onCreate: recorder(alphaOwner),
588
+ principal: alpha.id,
589
+ nonce,
537
590
  roots: rootValues,
538
591
  seed,
539
592
  uploads,
@@ -543,9 +596,13 @@ export async function run(options) {
543
596
  * a tenant-scoped item route — and a scope built from create alone leaves those
544
597
  * unresolvable, which shows up as a wall of "could not complete" gaps. */
545
598
  scope.values = { ...rootValues, ...scope.values };
546
- const cohort = await seedCohort(createOp, model, client, {
599
+ const cohort = await seedCohort(createOp, model, seeding, {
547
600
  authHeaders: alpha.headers,
548
601
  ...(options.cohortSize === undefined ? {} : { cohortSize: options.cohortSize }),
602
+ ancestors,
603
+ onCreate: recorder(alphaOwner),
604
+ principal: alpha.id,
605
+ nonce,
549
606
  roots: rootValues,
550
607
  seed,
551
608
  uploads,
@@ -553,6 +610,19 @@ export async function run(options) {
553
610
  if (cohort.uniqueGap !== undefined) {
554
611
  entityFindings.gap("world.seed", entity.name, cohort.uniqueGap, cohort.uniqueGap);
555
612
  }
613
+ lostVariants = (cohort.failedVariants ?? []).map((failed) => failed.variant);
614
+ for (const failed of cohort.failedVariants ?? []) {
615
+ const said = JSON.stringify(failed.exchange.responseBody).slice(0, 300);
616
+ if (failed.exchange.status >= 500) {
617
+ 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 ` +
618
+ `documented schema (${said}). The other variants were created, so this input is what ` +
619
+ "breaks the handler. Checks that need this variant's data run without it.", [failed.exchange]);
620
+ }
621
+ else {
622
+ 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 ` +
623
+ "rely on this variant's values run without them.");
624
+ }
625
+ }
556
626
  if (cohort.adopted === true) {
557
627
  /* Plan limit after an effect already created the row: keep the id so children
558
628
  * (row after extract→table) can seed, but do not assert write-path oracles
@@ -560,17 +630,10 @@ export async function run(options) {
560
630
  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
631
  "had a record — likely an earlier x-effects create. Write-path checks stand " +
562
632
  "down rather than treat payment_required as a backend defect.");
633
+ /* Adopted, not created: what oat did create was recorded as it happened, and
634
+ * this record is somebody else's to keep. */
563
635
  records = cohort.records;
564
636
  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
637
  }
575
638
  else if (cohort.uniqueAdopted === true) {
576
639
  /* Unique 409 on the first variant with a same-tenant list: adopt like 402,
@@ -584,24 +647,14 @@ export async function run(options) {
584
647
  records = cohort.records;
585
648
  degraded = true;
586
649
  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
650
  }
597
651
  else if (cohort.featureGate !== null) {
598
652
  /* Same degradation a profile-excluded create takes: the tag said this
599
653
  * 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);
654
+ * seed defect. schema.error-response-matches-document still judges its body. */
602
655
  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
656
  "rather than treat the documented 403 as a defect.");
604
- const existing = await readExisting(listOp, client, alpha.headers(), {
657
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
605
658
  ...options.roots,
606
659
  ...alpha.roots,
607
660
  });
@@ -614,15 +667,9 @@ export async function run(options) {
614
667
  degraded = true;
615
668
  }
616
669
  else {
670
+ /* Every ancestor and cohort record was ledgered as it was created, in creation
671
+ * order — the unwind reverses it, so children go before their parents. */
617
672
  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
673
  }
627
674
  }
628
675
  catch (error) {
@@ -633,7 +680,7 @@ export async function run(options) {
633
680
  if (isOverflowError(error)) {
634
681
  const overflow = overflowFrom(error, createOp.operationId);
635
682
  entityFindings.gap("world.seed", entity.name, overflow.message, overflow.message);
636
- const existing = await readExisting(listOp, client, alpha.headers(), {
683
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
637
684
  ...options.roots,
638
685
  ...alpha.roots,
639
686
  });
@@ -652,7 +699,10 @@ export async function run(options) {
652
699
  const failedOp = (error instanceof SeedError ? error.operationId : undefined) ?? createOp.operationId;
653
700
  const evidence = error instanceof SeedError && error.exchange !== undefined
654
701
  ? [error.exchange]
655
- : client.transcript.filter((e) => e.status >= 500).slice(-1);
702
+ : await Promise.all(client.transcript
703
+ .filter((e) => e.status >= 500)
704
+ .slice(-1)
705
+ .map((e) => client.hydrate(e)));
656
706
  /* Under --ops a create nobody targeted is support. Its failure leaves the targets
657
707
  * ungraded — blocked, with the evidence — but it is not what this run was asked to
658
708
  * judge, so it is not reported as a defect of its own. */
@@ -670,7 +720,7 @@ export async function run(options) {
670
720
  /* Fall back to whatever already exists. A backend whose create is broken can still
671
721
  * have a working list, and read-only coverage beats no coverage — this is exactly
672
722
  * the state in which a read-path bug is most likely to be sitting undiscovered. */
673
- const existing = await readExisting(listOp, client, alpha.headers(), {
723
+ const existing = await readExisting(listOp, model, seeding, alpha.headers(), {
674
724
  ...options.roots,
675
725
  ...alpha.roots,
676
726
  });
@@ -688,13 +738,19 @@ export async function run(options) {
688
738
  const actorOf = async (principal, seedOffset) => {
689
739
  const roots = { ...options.roots, ...principal.roots };
690
740
  try {
691
- const next = await resolvePathScope(listOp, model, client, {
741
+ const next = await resolvePathScope(listOp, model, seeding, {
692
742
  authHeaders: principal.headers,
743
+ /* Created as this principal, so removed as this principal. */
744
+ ancestors,
745
+ onCreate: recorder(ownerOf(principal)),
746
+ principal: principal.id,
747
+ nonce,
693
748
  roots,
694
749
  seed: seed + seedOffset,
695
750
  uploads: worldUploads(seedOffset),
696
751
  });
697
752
  return {
753
+ adoptCredential: principal.adoptCredential,
698
754
  headers: principal.headers,
699
755
  id: principal.id,
700
756
  inviteAs: principal.inviteAs,
@@ -706,6 +762,7 @@ export async function run(options) {
706
762
  }
707
763
  catch {
708
764
  return {
765
+ adoptCredential: principal.adoptCredential,
709
766
  headers: principal.headers,
710
767
  id: principal.id,
711
768
  inviteAs: principal.inviteAs,
@@ -718,6 +775,7 @@ export async function run(options) {
718
775
  };
719
776
  const actors = [
720
777
  {
778
+ adoptCredential: alpha.adoptCredential,
721
779
  headers: alpha.headers,
722
780
  id: alpha.id,
723
781
  inviteAs: alpha.inviteAs,
@@ -745,9 +803,22 @@ export async function run(options) {
745
803
  * reporting that it had cleaned up everything it made. Recording centrally rather than at
746
804
  * each call site means a new check cannot forget: the wrapper sees every request.
747
805
  */
748
- const trackingClient = createOp === undefined || degraded
749
- ? client
750
- : new Proxy(client, {
806
+ let createPath;
807
+ try {
808
+ createPath = createOp === undefined ? undefined : fillPath(createOp.path, scope.values);
809
+ }
810
+ catch {
811
+ createPath = undefined;
812
+ }
813
+ /* Whoever sent the request owns what it created: matched on the credential it carried. */
814
+ const ownerByHeaders = (sent) => {
815
+ const rendered = JSON.stringify(typeof sent === "function" ? sent() : (sent ?? {}));
816
+ const match = resolved.find((principal) => JSON.stringify(principal.headers()) === rendered);
817
+ return match === undefined ? alphaOwner : ownerOf(match);
818
+ };
819
+ const tracked = (base) => createOp === undefined || degraded
820
+ ? base
821
+ : new Proxy(base, {
751
822
  get(target, property, receiver) {
752
823
  if (property !== "request")
753
824
  return Reflect.get(target, property, receiver);
@@ -755,15 +826,18 @@ export async function run(options) {
755
826
  const exchange = await target.request(method, path, options ?? {});
756
827
  if (method.toUpperCase() !== "POST" || exchange.status >= 300)
757
828
  return exchange;
829
+ /* Only this entity's own create makes one of its records. Any other 2xx
830
+ * POST carrying an `id` — an invite, an action — names something else, and
831
+ * on an integer-id API deleting by that number removes an unrelated row. */
832
+ if (path !== createPath)
833
+ return exchange;
758
834
  const body = exchange.responseBody;
759
835
  if (body === null || typeof body !== "object")
760
836
  return exchange;
761
837
  const id = body[entity.identity ?? "id"];
762
838
  if (typeof id !== "string" && typeof id !== "number")
763
839
  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);
840
+ ledger.record(entity.name, String(id), scope.values, ownerByHeaders(options?.headers));
767
841
  return exchange;
768
842
  };
769
843
  },
@@ -814,9 +888,11 @@ export async function run(options) {
814
888
  inScope: (op) => runScope.inScope(op.operationId),
815
889
  hooks,
816
890
  outOfBand,
891
+ payloads: { policy: options.payloads ?? "per-write-path", ran: payloadPaths },
817
892
  auth: alpha.headers,
893
+ recordCreated: recorder(alphaOwner),
818
894
  ...(alpha.runtime === undefined ? {} : { refreshIfStale: alpha.runtime.refreshIfStale }),
819
- client: trackingClient,
895
+ client: tracked(client.view({ purpose: "assertion", subject: entity.name })),
820
896
  collectionKey: listOp.collection?.key ?? null,
821
897
  /* In degraded mode oat did not write these records, so it has no oracle for them —
822
898
  * every write-path check must sit out rather than assert against data it did not
@@ -840,6 +916,7 @@ export async function run(options) {
840
916
  softDelete: model.operations.find((op) => op.entity === entity.name && op.softDelete !== null)?.softDelete ??
841
917
  listOp.softDelete,
842
918
  seed,
919
+ nonce,
843
920
  updateOp: degraded || updateExcluded ? undefined : updateOpModel,
844
921
  uploads,
845
922
  validator,
@@ -858,12 +935,20 @@ export async function run(options) {
858
935
  entitiesTested.push(entity.name);
859
936
  /* Subjects each check grades on this entity: in scope, and not excluded by the profile. */
860
937
  const gradedBy = new Map();
938
+ /* What each check's plan gathered, and how each check ended, on this entity. */
939
+ const plans = new Map();
940
+ const outcomes = new Map();
861
941
  const runOne = async (check) => {
862
942
  const graded = gradedBy.get(check.id) ?? [];
863
943
  let judged;
944
+ /* Its own client: every exchange names this check, even while a batch runs concurrently.
945
+ * A check that does not mutate gets one that refuses to write. */
946
+ const stamp = { check: check.id, purpose: "assertion", subject: entity.name };
947
+ const attributed = graded.length === 0 ? findings : findings.attributed(graded);
864
948
  const view = {
865
949
  ...ctx,
866
- ...(graded.length === 0 ? {} : { findings: findings.attributed(graded) }),
950
+ client: tracked(check.mutates === true ? client.view(stamp) : client.readOnlyView(stamp)),
951
+ findings: attributed.ownedBy(check.id),
867
952
  judged: (operationIds) => {
868
953
  judged ??= new Set();
869
954
  for (const id of operationIds)
@@ -881,15 +966,45 @@ export async function run(options) {
881
966
  phase: "test",
882
967
  requests: client.transcript.length,
883
968
  });
969
+ const start = findings.findings.length;
970
+ const began = performance.now();
971
+ let outcome;
884
972
  try {
885
- await check.run(view);
973
+ outcome = await check.run(view, plans.get(check.id));
886
974
  }
887
975
  catch (error) {
888
976
  if (isNetworkError(error)) {
889
977
  noteNetwork(error, entity.name);
978
+ outcomes.set(check.id, { kind: "unresolved", reason: "the network failed" });
979
+ return;
980
+ }
981
+ if (error instanceof AuthRefreshError) {
982
+ outcomes.set(check.id, { kind: "unresolved", reason: "a credential could not be refreshed" });
983
+ attributed.blocked("auth.refresh", entity.name, `refreshing the credential${error.principal === undefined ? "" : ` of "${error.principal}"`} failed`, `${error.message}. ${check.id} could not complete.`);
890
984
  return;
891
985
  }
892
- view.findings.gap(check.id, entity.name, `check "${check.id}" could not complete`, error instanceof Error ? error.message : String(error));
986
+ outcome = view.findings.gap(check.id, entity.name, `check "${check.id}" could not complete`, error instanceof Error ? error.message : String(error));
987
+ }
988
+ checkTimings.push({ check: check.id, entity: entity.name, ms: Math.round(performance.now() - began) });
989
+ /* A check that reported along the way and then fell through to "asserted" did not
990
+ * assert anything: what it reported is the outcome. */
991
+ if (outcome.kind === "asserted") {
992
+ const reported = findings.findings
993
+ .slice(start)
994
+ .find((f) => f.check === check.id && f.entity === entity.name && isRootCause(f));
995
+ if (reported !== undefined)
996
+ outcome = { kind: "finding", verdict: reported.verdict };
997
+ }
998
+ outcomes.set(check.id, outcome);
999
+ if (outcome.kind === "asserted" && outcome.note !== undefined) {
1000
+ checkNotes.push({ check: check.id, entity: entity.name, note: outcome.note });
1001
+ }
1002
+ if (outcome.kind === "stood-down") {
1003
+ /* Not a pass: the property was never tested here, and the report says why. */
1004
+ const needs = withLostVariants(outcome.reason);
1005
+ checksSkipped.push({ check: check.id, entity: entity.name, needs });
1006
+ grades.skipped(graded, needs);
1007
+ return;
893
1008
  }
894
1009
  grades.graded(judged === undefined ? graded : graded.filter((id) => judged?.has(id) === true), check.id);
895
1010
  };
@@ -911,7 +1026,13 @@ export async function run(options) {
911
1026
  const suppressedBy = new Map();
912
1027
  const suppressed = (check) => {
913
1028
  for (const dependency of check.dependsOn ?? []) {
914
- const failed = findings.findings.some((f) => f.check === dependency && f.entity === entity.name);
1029
+ /* Only a failed premise suppresses. A dependency that stood down, could not reach a
1030
+ * verdict, or was blocked says nothing about whether its premise holds. */
1031
+ const outcome = outcomes.get(dependency);
1032
+ const failed = outcome === undefined
1033
+ ? /* Not run as a check here, but reported against — during seeding, say. */
1034
+ findings.findings.some((f) => f.check === dependency && f.entity === entity.name && isRootCause(f))
1035
+ : outcome.kind === "finding";
915
1036
  const inherited = suppressedBy.get(dependency);
916
1037
  if (!failed && inherited === undefined)
917
1038
  continue;
@@ -940,6 +1061,8 @@ export async function run(options) {
940
1061
  await Promise.all(pending.map(runOne));
941
1062
  };
942
1063
  for (const check of CHECKS) {
1064
+ if (options.signal?.aborted === true)
1065
+ break;
943
1066
  const graded = check.subjects(entity, model).filter((id) => runScope.inScope(id) && !profileExcludes(id));
944
1067
  /* Out of scope is not a skip: the check could have run, this run was not asked to. */
945
1068
  if (runScope.mode === "targeted" && graded.length === 0) {
@@ -947,15 +1070,19 @@ export async function run(options) {
947
1070
  continue;
948
1071
  }
949
1072
  gradedBy.set(check.id, graded);
950
- if (!check.applicable(ctx)) {
1073
+ const planned = check.plan(ctx);
1074
+ if (!planned.ok) {
951
1075
  /* Recorded, not dropped: on an API shaped unlike the fixture this is most of the
952
1076
  * suite, and a silent skip reads exactly like a clean result. */
953
- const needs = check.needs ?? "an unstated precondition";
1077
+ const needs = withLostVariants(planned.needs ?? check.needs ?? "an unstated precondition");
954
1078
  checksSkipped.push({ check: check.id, entity: entity.name, needs });
955
1079
  grades.skipped(graded, needs);
956
1080
  continue;
957
1081
  }
958
- if (check.mutates === true) {
1082
+ plans.set(check.id, planned.value);
1083
+ /* A writer runs alone; so does a check that judges the transcript, once everything
1084
+ * queued before it has finished sending. */
1085
+ if (check.mutates === true || check.judgesTranscript === true) {
959
1086
  await flush();
960
1087
  if (suppressed(check))
961
1088
  continue;
@@ -972,22 +1099,55 @@ export async function run(options) {
972
1099
  await flush();
973
1100
  };
974
1101
  /*
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.
1102
+ * Checks inside an entity stay ordered because cascade suppression consults findings already
1103
+ * reported for it — concurrent checks would let a root cause and its consequences race and
1104
+ * both be reported. Nested graphs couple entities too: a child create in flight while a
1105
+ * parent page-walk runs invents pagination findings. So entities that can observe each
1106
+ * other share a lane and run in series, and only lanes run side by side.
980
1107
  */
981
1108
  const queue = runScope.entities;
982
1109
  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;
1110
+ let interrupted = false;
1111
+ let started = 0;
1112
+ let stopNoted = false;
1113
+ /* Entities that share nothing run side by side; see `entityLanes`. */
1114
+ const runLane = async (lane) => {
1115
+ for (const entity of lane) {
1116
+ /* Stopped from outside — Ctrl-C, a deadline. Nothing more is tested, and everything
1117
+ * made so far is still removed below. */
1118
+ if (options.signal?.aborted === true || networkGate.exhausted) {
1119
+ if (stopNoted)
1120
+ return;
1121
+ stopNoted = true;
1122
+ const left = queue.length - started;
1123
+ if (options.signal?.aborted === true) {
1124
+ interrupted = true;
1125
+ 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");
1126
+ }
1127
+ else {
1128
+ findings.blocked("net.unreachable", "run", `skipped ${left} remaining entit${left === 1 ? "y" : "ies"}`, "network down");
1129
+ }
1130
+ return;
1131
+ }
1132
+ started += 1;
1133
+ currentEntityIndex = started;
1134
+ try {
1135
+ await testEntity(entity);
1136
+ }
1137
+ catch (error) {
1138
+ /* An error oat did not expect must not skip teardown: it is reported, and the run
1139
+ * carries on to remove what it made and write its report. */
1140
+ findings.blocked("run.error", entity.name, `testing "${entity.name}" stopped on an unexpected error`, errorText(error));
1141
+ }
988
1142
  }
989
- currentEntityIndex = index + 1;
990
- await testEntity(entity);
1143
+ };
1144
+ try {
1145
+ await Promise.all(entityLanes(model, queue).map(runLane));
1146
+ }
1147
+ catch (error) {
1148
+ /* Each entity's errors are caught where it runs; this is the scheduling around them. What
1149
+ * was created is still removed below, and the report still written. */
1150
+ findings.blocked("run.error", "run", "testing stopped on an unexpected error", errorText(error));
991
1151
  }
992
1152
  /* Unwind after every check has run, never per case: a check may legitimately depend on records
993
1153
  * another one created, and tearing down early turns that into a phantom defect. */
@@ -998,25 +1158,32 @@ export async function run(options) {
998
1158
  phase: "teardown",
999
1159
  requests: client.transcript.length,
1000
1160
  });
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,
1161
+ let teardown = null;
1162
+ if (options.keepFixtures !== true && ledger.size > 0) {
1163
+ try {
1164
+ teardown = await ledger.unwind(model, client.view({ purpose: "teardown" }), fallbackDeleters, (done, total, item) => {
1165
+ if (done % 25 !== 0 && done !== total)
1166
+ return;
1167
+ tick({
1168
+ entity: item.entity,
1169
+ message: `${done}/${total} ${item.entity} ${item.id}`,
1170
+ phase: "teardown",
1171
+ requests: client.transcript.length,
1172
+ });
1011
1173
  });
1012
- });
1174
+ }
1175
+ catch (error) {
1176
+ findings.blocked("world.teardown", "run", `teardown stopped on an unexpected error with ${ledger.size} record(s) ledgered`, errorText(error));
1177
+ }
1178
+ }
1013
1179
  if (teardown !== null && teardown.unsupported.length > 0) {
1014
1180
  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
1181
  "fixtures behind. Declare x-cleanup to name the route that removes them.");
1016
1182
  }
1017
- reportRateLimitViolations(client, findings);
1018
- for (const principal of resolved)
1019
- await principal.runtime?.refreshIfStale();
1183
+ await refreshAll("run");
1184
+ /* Every check has judged the transcript; bodies moved out of memory are no longer needed. */
1185
+ await client.dispose();
1186
+ await journal?.flush().catch(() => undefined);
1020
1187
  const persisted = resolved.map((principal) => snapshotPrincipal({
1021
1188
  headers: principal.headers,
1022
1189
  id: principal.id,
@@ -1027,7 +1194,12 @@ export async function run(options) {
1027
1194
  }));
1028
1195
  const coverage = scopeReport();
1029
1196
  if ((options.origins ?? []).length > 0 && options.skipPrincipalTeardown !== true) {
1030
- await runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage);
1197
+ try {
1198
+ await runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage);
1199
+ }
1200
+ catch (error) {
1201
+ findings.blocked("run.error", "origins", "the secondary origins stopped on an unexpected error", errorText(error));
1202
+ }
1031
1203
  }
1032
1204
  if (options.skipPrincipalTeardown !== true) {
1033
1205
  await teardownPrincipals(resolved, hooks, findings);
@@ -1038,6 +1210,8 @@ export async function run(options) {
1038
1210
  requests: client.transcript.length,
1039
1211
  });
1040
1212
  return {
1213
+ checkNotes,
1214
+ checkTimings,
1041
1215
  checksOutOfScope,
1042
1216
  checksRun: [...checksRun].sort(),
1043
1217
  checksSkipped,
@@ -1053,6 +1227,7 @@ export async function run(options) {
1053
1227
  profileExclusions,
1054
1228
  scope: coverage,
1055
1229
  teardown,
1230
+ ...(interrupted ? { interrupted } : {}),
1056
1231
  ...(journal === null ? {} : { exchanges: { count: journal.count } }),
1057
1232
  ...(networkOutcome === undefined ? {} : { network: networkOutcome }),
1058
1233
  };
@@ -1074,6 +1249,9 @@ async function loadOriginClients(origins, hooks, maxInFlight, globalHeaders, onE
1074
1249
  }
1075
1250
  return map;
1076
1251
  }
1252
+ function errorText(error) {
1253
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
1254
+ }
1077
1255
  async function runSecondaryOrigins(options, persisted, findings, checksRun, checksSkipped, checksSuppressed, checksOutOfScope, entitiesTested, runScope, coverage) {
1078
1256
  for (const origin of options.origins ?? []) {
1079
1257
  /* Targets were resolved against every origin up front; an origin none of them name has
@@ -1138,4 +1316,62 @@ async function runSecondaryOrigins(options, persisted, findings, checksRun, chec
1138
1316
  }
1139
1317
  }
1140
1318
  }
1319
+ /**
1320
+ * Entities grouped into lanes that can be tested side by side.
1321
+ *
1322
+ * Two entities share a lane when one can observe the other's writes: one's collection lives
1323
+ * under the other's (a child, created inside a parent oat also makes), an operation of one
1324
+ * invalidates a route of the other, or one's effects, waits or async polls land on the other.
1325
+ * Inside a lane entities keep their order and run one after another; lanes share nothing, so
1326
+ * nothing one does can turn up as a finding in another.
1327
+ */
1328
+ export function entityLanes(model, entities) {
1329
+ const names = entities.map((entity) => entity.name);
1330
+ const parent = new Map(names.map((name) => [name, name]));
1331
+ const find = (name) => {
1332
+ let root = name;
1333
+ while (parent.get(root) !== root)
1334
+ root = parent.get(root) ?? root;
1335
+ return root;
1336
+ };
1337
+ const join = (a, b) => {
1338
+ if (a === null || a === undefined || b === null || b === undefined)
1339
+ return;
1340
+ if (!parent.has(a) || !parent.has(b))
1341
+ return;
1342
+ parent.set(find(a), find(b));
1343
+ };
1344
+ const collection = (entity) => {
1345
+ const op = model.byOperationId.get(entity.list ?? entity.create ?? "");
1346
+ return op?.path.replace(/\/+$/, "");
1347
+ };
1348
+ for (const a of entities) {
1349
+ const pathA = collection(a);
1350
+ for (const b of entities) {
1351
+ const pathB = collection(b);
1352
+ if (a === b || pathA === undefined || pathB === undefined)
1353
+ continue;
1354
+ if (pathB.startsWith(`${pathA}/`))
1355
+ join(a.name, b.name);
1356
+ }
1357
+ }
1358
+ for (const op of model.operations) {
1359
+ for (const route of op.invalidates)
1360
+ join(op.entity, model.byRoute.get(route)?.entity);
1361
+ for (const effect of op.effects)
1362
+ join(op.entity, effect.entity);
1363
+ if (op.wait !== null)
1364
+ join(op.entity, model.byOperationId.get(op.wait.operationId)?.entity);
1365
+ if (op.async !== null) {
1366
+ const poll = model.byOperationId.get(op.async.poll) ?? model.byRoute.get(op.async.poll);
1367
+ join(op.entity, poll?.entity);
1368
+ }
1369
+ }
1370
+ const lanes = new Map();
1371
+ for (const entity of entities) {
1372
+ const root = find(entity.name);
1373
+ lanes.set(root, [...(lanes.get(root) ?? []), entity]);
1374
+ }
1375
+ return [...lanes.values()];
1376
+ }
1141
1377
  //# sourceMappingURL=run.js.map