@lovrozagar/oat 0.9.0 → 0.10.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 (43) hide show
  1. package/README.md +104 -33
  2. package/dist/cli.js +56 -14
  3. package/dist/config/define-config.d.ts +7 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +1 -0
  8. package/dist/report/console.d.ts +2 -1
  9. package/dist/report/console.d.ts.map +1 -1
  10. package/dist/report/console.js +33 -1
  11. package/dist/report/console.js.map +1 -1
  12. package/dist/report/matrix.d.ts +8 -13
  13. package/dist/report/matrix.d.ts.map +1 -1
  14. package/dist/report/matrix.js +21 -10
  15. package/dist/report/matrix.js.map +1 -1
  16. package/dist/report/render.d.ts +8 -0
  17. package/dist/report/render.d.ts.map +1 -1
  18. package/dist/report/render.js +94 -1
  19. package/dist/report/render.js.map +1 -1
  20. package/dist/runtime/auth.d.ts.map +1 -1
  21. package/dist/runtime/auth.js +2 -1
  22. package/dist/runtime/auth.js.map +1 -1
  23. package/dist/runtime/checks.d.ts +42 -1
  24. package/dist/runtime/checks.d.ts.map +1 -1
  25. package/dist/runtime/checks.js +190 -24
  26. package/dist/runtime/checks.js.map +1 -1
  27. package/dist/runtime/finding.d.ts +14 -1
  28. package/dist/runtime/finding.d.ts.map +1 -1
  29. package/dist/runtime/finding.js +25 -6
  30. package/dist/runtime/finding.js.map +1 -1
  31. package/dist/runtime/run.d.ts +17 -0
  32. package/dist/runtime/run.d.ts.map +1 -1
  33. package/dist/runtime/run.js +126 -66
  34. package/dist/runtime/run.js.map +1 -1
  35. package/dist/runtime/scope.d.ts +163 -0
  36. package/dist/runtime/scope.d.ts.map +1 -0
  37. package/dist/runtime/scope.js +423 -0
  38. package/dist/runtime/scope.js.map +1 -0
  39. package/dist/runtime/world.d.ts +7 -2
  40. package/dist/runtime/world.d.ts.map +1 -1
  41. package/dist/runtime/world.js +12 -6
  42. package/dist/runtime/world.js.map +1 -1
  43. package/package.json +1 -1
@@ -21,6 +21,44 @@ import { driveWait } from "./wait.js";
21
21
  import { fillPath, isPlanLimitResponse } from "./world.js";
22
22
  import { bodyPropertyNames, collisionCreateBody, collisionUpdatePatch, idempotencyHeaderRequired, probeableUniqueSets, reportUniqueSchemaDrift, uniquifyProbeBody, uniqueProbeHeaders, } from "./unique.js";
23
23
  import { bindAfterCreateEffects, bindCreatedScope, bindMissingPathParams, canFillPath, describeEffectHold, effectHolds, identityPathParam, } from "./effects.js";
24
+ const graded = (ctx, op) => ctx.inScope?.(op) ?? true;
25
+ /** The context a multi-operation check uses for one subject: its findings land on that operation. */
26
+ function forOperation(ctx, op) {
27
+ return { ...ctx, findings: ctx.findings.attributed([op.operationId]) };
28
+ }
29
+ const declared = (...operationIds) => operationIds.filter((id) => id !== undefined && id !== "");
30
+ const ownOps = (entity, model, keep) => model.operations.filter((op) => op.entity === entity.name && keep(op)).map((op) => op.operationId);
31
+ /** Subject sets shared by the registry. Each reads the same fields the check's context is built from. */
32
+ export const subjectsOf = {
33
+ async: (entity, model) => ownOps(entity, model, (op) => op.async !== null),
34
+ asyncReceipt: (entity, model) => ownOps(entity, model, (op) => op.async?.idFrom !== undefined),
35
+ create: (entity) => declared(entity.create),
36
+ createAndForeignReads: (entity, model) => {
37
+ const create = model.byOperationId.get(entity.create ?? "");
38
+ if (create === undefined)
39
+ return [];
40
+ const foreign = create.invalidates
41
+ .map((route) => model.byRoute.get(route))
42
+ .filter((op) => op !== undefined && op.entity !== entity.name && op.method.toUpperCase() === "GET")
43
+ .map((op) => op.operationId);
44
+ return [create.operationId, ...foreign];
45
+ },
46
+ createAndList: (entity) => declared(entity.create, entity.list),
47
+ delete: (entity) => declared(entity.delete),
48
+ deleteAndList: (entity) => declared(entity.delete, entity.list),
49
+ effects: (entity, model) => ownOps(entity, model, (op) => op.effects.length > 0),
50
+ invite: (entity) => entity.invite === null ? [] : declared(entity.invite.invite, entity.invite.accept, entity.invite.revoke),
51
+ list: (entity) => declared(entity.list),
52
+ nonCreate: (entity, model) => ownOps(entity, model, (op) => op.action !== "create"),
53
+ read: (entity) => declared(entity.read),
54
+ readAndGated: (entity, model) => [
55
+ ...new Set([...declared(entity.read), ...ownOps(entity, model, (op) => op.featureGate !== null)]),
56
+ ],
57
+ readAndList: (entity) => declared(entity.read, entity.list),
58
+ update: (entity) => declared(entity.update),
59
+ waits: (entity, model) => ownOps(entity, model, (op) => op.wait !== null),
60
+ writeAndRead: (entity) => declared(entity.update ?? entity.create, entity.read),
61
+ };
24
62
  /* ------------------------------------------------------------------- helpers */
25
63
  /**
26
64
  * A documented feature-gate 403 is not a 2xx-check failure and not SECURITY.
@@ -473,6 +511,7 @@ const readAfterWrite = {
473
511
  mutates: true,
474
512
  id: "list.read-after-write",
475
513
  needs: "a create operation and at least one seeded record",
514
+ subjects: subjectsOf.createAndList,
476
515
  async run(ctx) {
477
516
  const target = ctx.records[0];
478
517
  if (target === undefined)
@@ -554,6 +593,7 @@ const unknownFilterRejected = {
554
593
  applicable: (ctx) => conv(ctx).filter !== undefined,
555
594
  id: "filter.unknown-field-rejected",
556
595
  needs: "a way to express a filter — a filter expression parameter, or filterable fields",
596
+ subjects: subjectsOf.list,
557
597
  async run(ctx) {
558
598
  const baseline = await list(ctx, q(ctx, { limit: 100 }));
559
599
  const unknownField = filterTerm(conv(ctx), "oat_no_such_field_xyz", "eq", 1);
@@ -579,6 +619,7 @@ const equalityFilterSelectsOne = {
579
619
  dependsOn: ["list.read-after-write"],
580
620
  id: "filter.equality-selects-exactly-one",
581
621
  needs: "a `filter` parameter that accepts the identity field",
622
+ subjects: subjectsOf.list,
582
623
  async run(ctx) {
583
624
  const target = ctx.records[0];
584
625
  if (target === undefined)
@@ -605,6 +646,7 @@ const zeroMatchFilter = {
605
646
  dependsOn: ["list.read-after-write"],
606
647
  id: "filter.zero-match-returns-none",
607
648
  needs: "a `filter` parameter that accepts the identity field",
649
+ subjects: subjectsOf.list,
608
650
  async run(ctx) {
609
651
  const term = filterTerm(conv(ctx), filterIdentity(ctx), "eq", "oat-nonexistent-value-000");
610
652
  if (term === null)
@@ -631,6 +673,7 @@ const negationPartitions = {
631
673
  ],
632
674
  id: "filter.negation-partitions-the-set",
633
675
  needs: "a `filter` parameter supporting eq and neq",
676
+ subjects: subjectsOf.list,
634
677
  async run(ctx) {
635
678
  const target = ctx.records[0];
636
679
  if (target === undefined)
@@ -704,6 +747,7 @@ const filterAndComposesAsIntersection = {
704
747
  ],
705
748
  id: "filter.and-composes-as-intersection",
706
749
  needs: "two filterable fields and a filter parameter or grammar supporting eq",
750
+ subjects: subjectsOf.list,
707
751
  async run(ctx) {
708
752
  const conventions = conv(ctx);
709
753
  const picked = twoFilterableFields(ctx);
@@ -766,6 +810,7 @@ const filterOrComposesAsUnion = {
766
810
  ],
767
811
  id: "filter.or-composes-as-union",
768
812
  needs: "two filterable fields and an or() combinator (postgrest-shaped filter grammar)",
813
+ subjects: subjectsOf.list,
769
814
  async run(ctx) {
770
815
  const conventions = conv(ctx);
771
816
  const picked = twoFilterableFields(ctx);
@@ -823,6 +868,7 @@ const sortReverseSymmetry = {
823
868
  ],
824
869
  id: "sort.reverse-symmetry",
825
870
  needs: "an `order` parameter supporting asc and desc",
871
+ subjects: subjectsOf.list,
826
872
  async run(ctx) {
827
873
  /* A nullable sort key exercises null ordering, where the interesting bugs live. */
828
874
  const field = nullableField(ctx, ctx.query?.sortable ?? []) ??
@@ -881,6 +927,7 @@ const pageWalkCoversSet = {
881
927
  ],
882
928
  id: "pagination.page-walk-covers-set",
883
929
  needs: "a way to page forward — a page number or a row offset — and at least three records",
930
+ subjects: subjectsOf.list,
884
931
  async run(ctx) {
885
932
  const limit = ctx.query?.maxLimit ?? 100;
886
933
  /* Walk under a low-cardinality sort. Distinct keys admit exactly one valid order, so an
@@ -934,6 +981,7 @@ const cursorAgreesWithPage = {
934
981
  ],
935
982
  id: "pagination.cursor-agrees-with-page",
936
983
  needs: "both `cursor` and `page` parameters",
984
+ subjects: subjectsOf.list,
937
985
  async run(ctx) {
938
986
  /* Walk under an explicit text sort. Cursor pagination is almost always used with one, and
939
987
  * a boundary resolved under a different collation than the listing can only diverge when
@@ -985,6 +1033,7 @@ const countIsConsistentWithPage = {
985
1033
  dependsOn: ["list.read-after-write"],
986
1034
  id: "count.consistent-with-returned-page",
987
1035
  needs: "a total-count field in the list envelope",
1036
+ subjects: subjectsOf.list,
988
1037
  async run(ctx) {
989
1038
  const result = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100 }));
990
1039
  if (result.exchange.status >= 400)
@@ -1011,6 +1060,7 @@ const countMatchesWalk = {
1011
1060
  dependsOn: ["list.read-after-write", "filter.equality-selects-exactly-one"],
1012
1061
  id: "count.matches-filtered-set",
1013
1062
  needs: "a total-count field and a `filter` parameter",
1063
+ subjects: subjectsOf.list,
1014
1064
  async run(ctx) {
1015
1065
  if (!identityIsFilterable(ctx))
1016
1066
  return;
@@ -1037,6 +1087,7 @@ const selectProjection = {
1037
1087
  applicable: (ctx) => conv(ctx).select !== undefined && ctx.records.length > 0,
1038
1088
  id: "select.projection-honoured",
1039
1089
  needs: "a `select` sparse-fieldset parameter",
1090
+ subjects: subjectsOf.list,
1040
1091
  async run(ctx) {
1041
1092
  const requested = [ctx.identity];
1042
1093
  const extra = ctx.query?.selectable.find((f) => f !== ctx.identity);
@@ -1067,6 +1118,7 @@ const patchMinimality = {
1067
1118
  mutates: true,
1068
1119
  id: "patch.minimality",
1069
1120
  needs: "an update operation and an item route",
1121
+ subjects: subjectsOf.update,
1070
1122
  async run(ctx) {
1071
1123
  const target = ctx.records.find((r) => Object.values(r).some((v) => typeof v === "string"));
1072
1124
  if (target === undefined || ctx.updateOp === undefined || ctx.readOp === undefined)
@@ -1183,6 +1235,7 @@ const immutableRejected = {
1183
1235
  mutates: true,
1184
1236
  id: "patch.immutable-field-rejected",
1185
1237
  needs: "fields declared immutable via x-immutable",
1238
+ subjects: subjectsOf.update,
1186
1239
  async run(ctx) {
1187
1240
  /*
1188
1241
  * Deliberately not `records[0]`.
@@ -1230,6 +1283,7 @@ const likeEscaping = {
1230
1283
  dependsOn: ["list.read-after-write"],
1231
1284
  id: "filter.like-metacharacters-escaped",
1232
1285
  needs: "a `filter` parameter supporting a like operator",
1286
+ subjects: subjectsOf.list,
1233
1287
  async run(ctx) {
1234
1288
  const field = firstFilterable(ctx, (name) => (ctx.query?.searchable ?? []).includes(name));
1235
1289
  if (field === null) {
@@ -1259,6 +1313,7 @@ const createStatusMatchesSpec = {
1259
1313
  applicable: (ctx) => ctx.createOp !== undefined,
1260
1314
  id: "create.status-matches-document",
1261
1315
  needs: "a create operation",
1316
+ subjects: subjectsOf.create,
1262
1317
  async run(ctx) {
1263
1318
  const createOp = ctx.createOp;
1264
1319
  if (createOp === undefined)
@@ -1301,6 +1356,7 @@ const deleteMissingIs404 = {
1301
1356
  mutates: true,
1302
1357
  id: "delete.absent-record-returns-404",
1303
1358
  needs: "a delete operation",
1359
+ subjects: subjectsOf.delete,
1304
1360
  async run(ctx) {
1305
1361
  if (ctx.deleteOp === undefined)
1306
1362
  return;
@@ -1326,6 +1382,7 @@ const softDeleteHidden = {
1326
1382
  dependsOn: ["list.read-after-write"],
1327
1383
  id: "softdelete.absent-from-default-list",
1328
1384
  needs: "a delete operation and x-soft-delete",
1385
+ subjects: subjectsOf.deleteAndList,
1329
1386
  async run(ctx) {
1330
1387
  const target = ctx.records.at(-1);
1331
1388
  if (target === undefined || ctx.deleteOp === undefined)
@@ -1367,6 +1424,7 @@ const denialDoesNotRevealExistence = {
1367
1424
  ],
1368
1425
  id: "tenant.denial-does-not-reveal-existence",
1369
1426
  needs: "a second principal in a different tenant, and a tenant tagged or inferred from the path",
1427
+ subjects: subjectsOf.read,
1370
1428
  async run(ctx) {
1371
1429
  const target = ctx.records[0];
1372
1430
  if (target === undefined || ctx.readOp === undefined || ctx.altAuth === undefined)
@@ -1411,6 +1469,7 @@ const idempotentReplay = {
1411
1469
  id: "idempotency.replay-does-not-duplicate",
1412
1470
  mutates: true,
1413
1471
  needs: "a create operation declaring an idempotency-key header",
1472
+ subjects: subjectsOf.create,
1414
1473
  async run(ctx) {
1415
1474
  const createOp = ctx.createOp;
1416
1475
  const header = createOp?.idempotencyHeader;
@@ -1466,13 +1525,20 @@ const declaredInvalidationHappens = {
1466
1525
  id: "invalidation.declared-route-changes",
1467
1526
  mutates: true,
1468
1527
  needs: "a create operation whose x-invalidate names another entity's read route",
1528
+ subjects: subjectsOf.createAndForeignReads,
1469
1529
  async run(ctx) {
1470
1530
  const createOp = ctx.createOp;
1471
1531
  if (createOp === undefined)
1472
1532
  return;
1533
+ /* A graded create is judged through every route it declares; a graded route alone is
1534
+ * judged only itself, with the create as support. */
1535
+ const createGraded = graded(ctx, createOp);
1473
1536
  const foreign = createOp.invalidates
1474
1537
  .map((route) => ctx.model.byRoute.get(route))
1475
- .filter((op) => op !== undefined && op.entity !== ctx.entityName && op.method.toUpperCase() === "GET");
1538
+ .filter((op) => op !== undefined &&
1539
+ op.entity !== ctx.entityName &&
1540
+ op.method.toUpperCase() === "GET" &&
1541
+ (createGraded || graded(ctx, op)));
1476
1542
  if (foreign.length === 0)
1477
1543
  return;
1478
1544
  const schema = requestSchemaOf(ctx, createOp);
@@ -1519,7 +1585,12 @@ const declaredInvalidationHappens = {
1519
1585
  continue;
1520
1586
  if (JSON.stringify(after.responseBody) !== snapshot)
1521
1587
  continue;
1522
- ctx.findings.backend(this.id, ctx.entityName, "a route the document says is invalidated by this write did not change", `creating a ${ctx.entityName} declares x-invalidate on "${probe.op.route}", but that ` +
1588
+ ctx.findings
1589
+ .attributed([
1590
+ ...(createGraded ? [createOp.operationId] : []),
1591
+ ...(graded(ctx, probe.op) ? [probe.op.operationId] : []),
1592
+ ])
1593
+ .backend(this.id, ctx.entityName, "a route the document says is invalidated by this write did not change", `creating a ${ctx.entityName} declares x-invalidate on "${probe.op.route}", but that ` +
1523
1594
  "route returned a byte-identical body before and after the write. Either it serves " +
1524
1595
  "a value derived from this entity and that value is stale — a denormalised counter " +
1525
1596
  "or a cached projection nobody refreshed — or the declaration is wrong and every " +
@@ -1560,6 +1631,7 @@ const projectionsAgree = {
1560
1631
  ],
1561
1632
  id: "consistency.projections-agree",
1562
1633
  needs: "an item route and a record with a comparable field",
1634
+ subjects: subjectsOf.readAndList,
1563
1635
  async run(ctx) {
1564
1636
  const target = ctx.records[0];
1565
1637
  if (target === undefined || ctx.readOp === undefined)
@@ -1672,6 +1744,7 @@ const queryAxesCompose = {
1672
1744
  ],
1673
1745
  id: "query.axes-compose",
1674
1746
  needs: "a filterable field, a sortable field, and more than two records",
1747
+ subjects: subjectsOf.list,
1675
1748
  async run(ctx) {
1676
1749
  const conventions = conv(ctx);
1677
1750
  const limit = ctx.query?.maxLimit ?? 100;
@@ -1764,6 +1837,7 @@ const filterAndPagingCompose = {
1764
1837
  ],
1765
1838
  id: "query.filter-selects-from-whole-set",
1766
1839
  needs: "a filterable field and more than two records",
1840
+ subjects: subjectsOf.list,
1767
1841
  async run(ctx) {
1768
1842
  const conventions = conv(ctx);
1769
1843
  /* A small page deliberately: the bug only shows once the window excludes matching rows,
@@ -1856,6 +1930,7 @@ const filterAndSelectCompose = {
1856
1930
  ],
1857
1931
  id: "query.filter-and-select-compose",
1858
1932
  needs: "a filterable field, a select parameter, and more than two records",
1933
+ subjects: subjectsOf.list,
1859
1934
  async run(ctx) {
1860
1935
  const conventions = conv(ctx);
1861
1936
  const limit = ctx.query?.maxLimit ?? 100;
@@ -1944,6 +2019,7 @@ const searchAndFilterCompose = {
1944
2019
  ],
1945
2020
  id: "query.search-and-filter-compose",
1946
2021
  needs: "a filterable field, a free-text search parameter, and more than two records",
2022
+ subjects: subjectsOf.list,
1947
2023
  async run(ctx) {
1948
2024
  const conventions = conv(ctx);
1949
2025
  const limit = ctx.query?.maxLimit ?? 100;
@@ -2058,6 +2134,7 @@ const filterSortSelectCompose = {
2058
2134
  ],
2059
2135
  id: "query.filter-sort-select-compose",
2060
2136
  needs: "a filterable field, a sortable field, a select parameter, and more than two records",
2137
+ subjects: subjectsOf.list,
2061
2138
  async run(ctx) {
2062
2139
  const conventions = conv(ctx);
2063
2140
  const limit = ctx.query?.maxLimit ?? 100;
@@ -2126,6 +2203,7 @@ const filterSearchSortCompose = {
2126
2203
  ],
2127
2204
  id: "query.filter-search-sort-compose",
2128
2205
  needs: "a filterable field, a search parameter, a sortable field, and more than two records",
2206
+ subjects: subjectsOf.list,
2129
2207
  async run(ctx) {
2130
2208
  const conventions = conv(ctx);
2131
2209
  const limit = ctx.query?.maxLimit ?? 100;
@@ -2185,6 +2263,7 @@ const filterSearchSelectCompose = {
2185
2263
  ],
2186
2264
  id: "query.filter-search-select-compose",
2187
2265
  needs: "a filterable field, a search parameter, a select parameter, and more than two records",
2266
+ subjects: subjectsOf.list,
2188
2267
  async run(ctx) {
2189
2268
  const conventions = conv(ctx);
2190
2269
  const limit = ctx.query?.maxLimit ?? 100;
@@ -2357,6 +2436,7 @@ const declaredFilterableWorks = {
2357
2436
  ],
2358
2437
  id: "spec.declared-filterable-is-filterable",
2359
2438
  needs: "x-query naming filterable fields",
2439
+ subjects: subjectsOf.list,
2360
2440
  async run(ctx) {
2361
2441
  const conventions = conv(ctx);
2362
2442
  const rejected = [];
@@ -2415,6 +2495,7 @@ const declaredSortableWorks = {
2415
2495
  ],
2416
2496
  id: "spec.declared-sortable-is-sortable",
2417
2497
  needs: "x-query naming sortable fields",
2498
+ subjects: subjectsOf.list,
2418
2499
  async run(ctx) {
2419
2500
  const conventions = conv(ctx);
2420
2501
  const rejected = [];
@@ -2464,6 +2545,7 @@ const declaredSelectableWorks = {
2464
2545
  ],
2465
2546
  id: "spec.declared-selectable-is-selectable",
2466
2547
  needs: "x-query naming selectable fields",
2548
+ subjects: subjectsOf.list,
2467
2549
  async run(ctx) {
2468
2550
  const conventions = conv(ctx);
2469
2551
  const rejected = [];
@@ -2497,6 +2579,7 @@ const crossTenantItemRead = {
2497
2579
  applicable: (ctx) => ctx.altAuth !== undefined && ctx.readOp !== undefined && ctx.records.length > 0 && tenantBoundary(ctx.readOp),
2498
2580
  id: "tenant.item-not-readable-cross-tenant",
2499
2581
  needs: "a second principal in a different tenant, and a tenant tagged or inferred from the path",
2582
+ subjects: subjectsOf.read,
2500
2583
  async run(ctx) {
2501
2584
  const target = ctx.records[0];
2502
2585
  if (target === undefined || ctx.readOp === undefined || ctx.altAuth === undefined)
@@ -2546,6 +2629,7 @@ const crossTenantFilterBypass = {
2546
2629
  dependsOn: ["query.filter-selects-from-whole-set"],
2547
2630
  id: "tenant.filter-does-not-bypass-scope",
2548
2631
  needs: "a second principal, a filter, and a tenant tagged or inferred from the path",
2632
+ subjects: subjectsOf.list,
2549
2633
  async run(ctx) {
2550
2634
  const target = ctx.records[0];
2551
2635
  if (target === undefined || ctx.altAuth === undefined || ctx.altScope === undefined)
@@ -2613,6 +2697,7 @@ const rankIsMonotonic = {
2613
2697
  dependsOn: ["list.read-after-write"],
2614
2698
  id: "auth.rank-is-monotonic",
2615
2699
  needs: "two same-tenant principals at different ranks, and an item route",
2700
+ subjects: subjectsOf.read,
2616
2701
  async run(ctx) {
2617
2702
  const target = ctx.records[0];
2618
2703
  const primary = ctx.actors[0];
@@ -2678,6 +2763,7 @@ const inviteGrantsThenRevokes = {
2678
2763
  dependsOn: ["list.read-after-write", "tenant.item-not-readable-cross-tenant"],
2679
2764
  id: "auth.invite-grants-then-revokes",
2680
2765
  needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item or list route",
2766
+ subjects: subjectsOf.invite,
2681
2767
  async run(ctx) {
2682
2768
  const spec = ctx.invite;
2683
2769
  const owner = ctx.actors[0];
@@ -2812,6 +2898,7 @@ const malformedFilterNot5xx = {
2812
2898
  applicable: (ctx) => filterable(ctx),
2813
2899
  id: "error.malformed-filter-not-5xx",
2814
2900
  needs: "a way to express a filter — a filter expression parameter, or filterable fields",
2901
+ subjects: subjectsOf.list,
2815
2902
  async run(ctx) {
2816
2903
  const result = await list(ctx, q(ctx, { filter: "((((", limit: 10 }));
2817
2904
  if (result.exchange.status < 500)
@@ -2825,6 +2912,7 @@ const limitBoundsPageSize = {
2825
2912
  dependsOn: ["list.read-after-write"],
2826
2913
  id: "pagination.limit-bounds-page-size",
2827
2914
  needs: "a page-size query parameter named `limit`",
2915
+ subjects: subjectsOf.list,
2828
2916
  async run(ctx) {
2829
2917
  const result = await list(ctx, q(ctx, { limit: 2 }));
2830
2918
  if (result.exchange.status >= 400)
@@ -2844,6 +2932,7 @@ const limitRespectsMax = {
2844
2932
  dependsOn: ["pagination.limit-bounds-page-size"],
2845
2933
  id: "pagination.limit-respects-documented-max",
2846
2934
  needs: "a declared maxLimit, and more records than it",
2935
+ subjects: subjectsOf.list,
2847
2936
  async run(ctx) {
2848
2937
  const max = ctx.query?.maxLimit;
2849
2938
  if (max === undefined)
@@ -2862,6 +2951,7 @@ const hasMoreIsAccurate = {
2862
2951
  dependsOn: ["pagination.limit-bounds-page-size"],
2863
2952
  id: "pagination.has-more-is-accurate",
2864
2953
  needs: "a way to page forward, and a more-pages signal in the body or a Link header",
2954
+ subjects: subjectsOf.list,
2865
2955
  async run(ctx) {
2866
2956
  const first = await list(ctx, q(ctx, { limit: 1, page: 1 }));
2867
2957
  if (first.exchange.status >= 400 || first.items.length === 0)
@@ -2885,6 +2975,7 @@ const orderChangesResult = {
2885
2975
  dependsOn: ["pagination.limit-bounds-page-size"],
2886
2976
  id: "sort.order-is-applied",
2887
2977
  needs: "an `order` parameter and a sortable field",
2978
+ subjects: subjectsOf.list,
2888
2979
  async run(ctx) {
2889
2980
  const field = ctx.query?.sortable.find((f) => f !== ctx.identity) ?? ctx.query?.sortable[0];
2890
2981
  if (field === undefined)
@@ -2932,6 +3023,7 @@ const searchNarrowsResult = {
2932
3023
  dependsOn: ["list.read-after-write"],
2933
3024
  id: "search.q-narrows-result",
2934
3025
  needs: "a free-text `q` parameter and declared searchable fields",
3026
+ subjects: subjectsOf.list,
2935
3027
  async run(ctx) {
2936
3028
  const field = ctx.query?.searchable[0];
2937
3029
  if (field === undefined)
@@ -2955,6 +3047,7 @@ const createPersistsFields = {
2955
3047
  dependsOn: ["list.read-after-write"],
2956
3048
  id: "create.persists-submitted-fields",
2957
3049
  needs: "a create operation that echoes the record back",
3050
+ subjects: subjectsOf.create,
2958
3051
  async run(ctx) {
2959
3052
  const createOp = ctx.createOp;
2960
3053
  if (createOp === undefined)
@@ -2988,6 +3081,7 @@ const enumValidated = {
2988
3081
  mutates: true,
2989
3082
  id: "validation.enum-enforced",
2990
3083
  needs: "a request schema with an enum field",
3084
+ subjects: subjectsOf.create,
2991
3085
  async run(ctx) {
2992
3086
  const createOp = ctx.createOp;
2993
3087
  if (createOp === undefined)
@@ -3018,6 +3112,7 @@ const maxLengthValidated = {
3018
3112
  mutates: true,
3019
3113
  id: "validation.max-length-enforced",
3020
3114
  needs: "a request schema with a maxLength constraint",
3115
+ subjects: subjectsOf.create,
3021
3116
  async run(ctx) {
3022
3117
  const createOp = ctx.createOp;
3023
3118
  if (createOp === undefined)
@@ -3047,6 +3142,7 @@ const requiredValidated = {
3047
3142
  mutates: true,
3048
3143
  id: "validation.required-enforced",
3049
3144
  needs: "a request schema with a required field",
3145
+ subjects: subjectsOf.create,
3050
3146
  async run(ctx) {
3051
3147
  const createOp = ctx.createOp;
3052
3148
  if (createOp === undefined)
@@ -3077,6 +3173,7 @@ const contentTypeEnforced = {
3077
3173
  mutates: true,
3078
3174
  id: "validation.content-type-enforced",
3079
3175
  needs: "a documented 415 response",
3176
+ subjects: subjectsOf.create,
3080
3177
  async run(ctx) {
3081
3178
  const createOp = ctx.createOp;
3082
3179
  if (createOp === undefined)
@@ -3102,6 +3199,7 @@ const errorSchemaHonoured = {
3102
3199
  applicable: (ctx) => ctx.validator !== undefined && ctx.readOp !== undefined,
3103
3200
  id: "schema.error-response-matches-document",
3104
3201
  needs: "a documented error schema on the item route",
3202
+ subjects: subjectsOf.readAndGated,
3105
3203
  async run(ctx) {
3106
3204
  const readOp = ctx.readOp;
3107
3205
  const validator = ctx.validator;
@@ -3110,21 +3208,16 @@ const errorSchemaHonoured = {
3110
3208
  const raw = ctx.model.rawOperations.get(readOp.operationId);
3111
3209
  if (raw === undefined)
3112
3210
  return;
3113
- const params = { ...ctx.scope, ...itemParamFor(ctx, "oat-definitely-missing-id") };
3114
- const exchange = await ctx.client.get(fillPath(readOp.path, params), { headers: ctx.auth() });
3115
- if (exchange.status >= 400 && validator.documents(raw, exchange.status)) {
3116
- const result = validator.validate(readOp.operationId, raw, exchange.status, exchange.responseBody);
3117
- if (!result.ok) {
3118
- ctx.findings.spec(this.id, ctx.entityName, `${exchange.status} error body does not match its documented schema`, `${readOp.operationId} returned ${exchange.status} with a body that fails the schema the ` +
3119
- `document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
3120
- "from the spec will not understand this response.", [exchange]);
3121
- }
3211
+ const judged = [];
3212
+ if (graded(ctx, readOp)) {
3213
+ await probeMissingItemError(ctx, readOp, raw, validator, this.id);
3214
+ judged.push(readOp.operationId);
3122
3215
  }
3123
3216
  /* A documented feature-gate 403 is coverage for the 2xx check, not for the error schema.
3124
3217
  * Walk this entity's already-observed gate denials so an undeclared body is still drift. */
3125
3218
  const seen = new Set();
3126
3219
  for (const op of ctx.model.operations) {
3127
- if (op.entity !== ctx.entityName || op.featureGate === null)
3220
+ if (op.entity !== ctx.entityName || op.featureGate === null || !graded(ctx, op))
3128
3221
  continue;
3129
3222
  const opRaw = ctx.model.rawOperations.get(op.operationId);
3130
3223
  if (opRaw === undefined)
@@ -3136,11 +3229,26 @@ const errorSchemaHonoured = {
3136
3229
  if (seen.has(key))
3137
3230
  continue;
3138
3231
  seen.add(key);
3139
- reportFeatureGateSchemaDrift(ctx.findings, validator, op, opRaw, prior, ctx.entityName);
3232
+ judged.push(op.operationId);
3233
+ reportFeatureGateSchemaDrift(ctx.findings.attributed([op.operationId]), validator, op, opRaw, prior, ctx.entityName);
3140
3234
  }
3141
3235
  }
3142
3236
  },
3143
3237
  };
3238
+ async function probeMissingItemError(ctx, readOp, raw, validator, check) {
3239
+ const params = { ...ctx.scope, ...itemParamFor(ctx, "oat-definitely-missing-id") };
3240
+ const exchange = await ctx.client.get(fillPath(readOp.path, params), { headers: ctx.auth() });
3241
+ if (exchange.status < 400 || !validator.documents(raw, exchange.status))
3242
+ return;
3243
+ const result = validator.validate(readOp.operationId, raw, exchange.status, exchange.responseBody);
3244
+ if (result.ok)
3245
+ return;
3246
+ ctx.findings
3247
+ .attributed([readOp.operationId])
3248
+ .spec(check, ctx.entityName, `${exchange.status} error body does not match its documented schema`, `${readOp.operationId} returned ${exchange.status} with a body that fails the schema the ` +
3249
+ `document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
3250
+ "from the spec will not understand this response.", [exchange]);
3251
+ }
3144
3252
  const successSchemaHonoured = {
3145
3253
  applicable: (ctx) => ctx.validator !== undefined && ctx.createOp !== undefined,
3146
3254
  /* The body is validated against the schema declared *for the status that came back*. When the
@@ -3149,6 +3257,7 @@ const successSchemaHonoured = {
3149
3257
  dependsOn: ["create.status-matches-document"],
3150
3258
  id: "schema.success-response-matches-document",
3151
3259
  needs: "a documented success schema on create",
3260
+ subjects: subjectsOf.create,
3152
3261
  async run(ctx) {
3153
3262
  const createOp = ctx.createOp;
3154
3263
  const validator = ctx.validator;
@@ -3325,6 +3434,7 @@ const numericComparisonIsNumeric = {
3325
3434
  dependsOn: ["list.read-after-write", "filter.unknown-field-rejected"],
3326
3435
  id: "filter.numeric-comparison-is-numeric",
3327
3436
  needs: "a `filter` parameter and a numeric field",
3437
+ subjects: subjectsOf.list,
3328
3438
  async run(ctx) {
3329
3439
  const field = numericFilterField(ctx);
3330
3440
  if (field === null)
@@ -3409,6 +3519,7 @@ const noLostUpdate = {
3409
3519
  id: "concurrency.no-lost-update",
3410
3520
  needs: "an update operation and two writable string fields",
3411
3521
  mutates: true,
3522
+ subjects: subjectsOf.update,
3412
3523
  async run(ctx) {
3413
3524
  const target = ctx.records[0];
3414
3525
  if (target === undefined || ctx.updateOp === undefined || ctx.readOp === undefined)
@@ -3522,8 +3633,10 @@ const declaredEffectsOccur = {
3522
3633
  mutates: true,
3523
3634
  id: "effects.declared-effect-occurs",
3524
3635
  needs: "an operation declaring x-effects",
3525
- async run(ctx) {
3526
- for (const op of ctx.effectOps) {
3636
+ subjects: subjectsOf.effects,
3637
+ async run(all) {
3638
+ for (const op of all.effectOps) {
3639
+ const ctx = forOperation(all, op);
3527
3640
  await forEachInvocation(op.operationId, ctx.uploads, async (uploads, slot) => {
3528
3641
  const fixture = slot?.filename;
3529
3642
  const effects = op.effects;
@@ -3639,8 +3752,10 @@ const sideEffectArrives = {
3639
3752
  mutates: true,
3640
3753
  id: "effects.side-effect-arrives",
3641
3754
  needs: "an operation declaring x-wait",
3642
- async run(ctx) {
3643
- for (const op of ctx.waitOps) {
3755
+ subjects: subjectsOf.waits,
3756
+ async run(all) {
3757
+ for (const op of all.waitOps) {
3758
+ const ctx = forOperation(all, op);
3644
3759
  const spec = op.wait;
3645
3760
  if (spec === null)
3646
3761
  continue;
@@ -3723,8 +3838,10 @@ const asyncReachesTerminalState = {
3723
3838
  mutates: true,
3724
3839
  id: "async.reaches-terminal-state",
3725
3840
  needs: "an operation declaring x-async",
3726
- async run(ctx) {
3727
- for (const op of ctx.asyncOps) {
3841
+ subjects: subjectsOf.async,
3842
+ async run(all) {
3843
+ for (const op of all.asyncOps) {
3844
+ const ctx = forOperation(all, op);
3728
3845
  const spec = op.async;
3729
3846
  if (spec === null)
3730
3847
  continue;
@@ -3779,8 +3896,10 @@ const asyncReceiptIsResolvable = {
3779
3896
  applicable: (ctx) => ctx.asyncOps.some((op) => op.async?.idFrom !== undefined),
3780
3897
  id: "async.receipt-identifies-the-job",
3781
3898
  needs: "x-async with an idFrom pointer",
3782
- async run(ctx) {
3783
- for (const op of ctx.asyncOps) {
3899
+ subjects: subjectsOf.asyncReceipt,
3900
+ async run(all) {
3901
+ for (const op of all.asyncOps) {
3902
+ const ctx = forOperation(all, op);
3784
3903
  const spec = op.async;
3785
3904
  if (spec?.idFrom === undefined)
3786
3905
  continue;
@@ -3859,6 +3978,7 @@ const stringPayloadSurvives = {
3859
3978
  id: "payload.string-survives",
3860
3979
  mutates: true,
3861
3980
  needs: "an update or create+delete, an item route, and a writable unconstrained string",
3981
+ subjects: subjectsOf.writeAndRead,
3862
3982
  async run(ctx) {
3863
3983
  const writeOp = ctx.updateOp ?? ctx.createOp;
3864
3984
  const readOp = ctx.readOp;
@@ -4040,9 +4160,11 @@ const documentedStatusHonoured = {
4040
4160
  applicable: (ctx) => ctx.model.operations.some((op) => op.entity === ctx.entityName && op.action !== "create"),
4041
4161
  id: "response.status-is-documented",
4042
4162
  needs: "a modeled non-create operation on this entity",
4163
+ subjects: subjectsOf.nonCreate,
4043
4164
  async run(ctx) {
4044
4165
  const createId = ctx.createOp?.operationId;
4045
4166
  const byOp = new Map();
4167
+ const observed = new Set();
4046
4168
  for (const exchange of ctx.client.transcript) {
4047
4169
  let pathname;
4048
4170
  try {
@@ -4054,21 +4176,25 @@ const documentedStatusHonoured = {
4054
4176
  const op = resolveEntityOperation(ctx, exchange.method, pathname);
4055
4177
  if (op === null)
4056
4178
  continue;
4057
- if (op.operationId === createId || op.action === "create")
4179
+ if (!graded(ctx, op))
4058
4180
  continue;
4059
- if (isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
4181
+ if (op.operationId === createId || op.action === "create")
4060
4182
  continue;
4061
4183
  if (exchange.status === 429)
4062
4184
  continue;
4063
4185
  const raw = ctx.model.rawOperations.get(op.operationId);
4064
4186
  if (!declaresConcreteStatuses(raw))
4065
4187
  continue;
4188
+ observed.add(op.operationId);
4189
+ if (isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
4190
+ continue;
4066
4191
  if (statusIsDeclared(raw, exchange.status))
4067
4192
  continue;
4068
4193
  const seen = byOp.get(op.operationId) ?? [];
4069
4194
  seen.push(exchange);
4070
4195
  byOp.set(op.operationId, seen);
4071
4196
  }
4197
+ ctx.judged?.([...observed]);
4072
4198
  if (byOp.size === 0)
4073
4199
  return;
4074
4200
  const lines = [];
@@ -4085,7 +4211,9 @@ const documentedStatusHonoured = {
4085
4211
  if (first !== undefined && evidence.length < 6)
4086
4212
  evidence.push(first);
4087
4213
  }
4088
- ctx.findings.spec(this.id, ctx.entityName, "an operation returned a status the document does not declare", `${lines.join(". ")}. Clients generated from this document will not recognise the response.`, evidence);
4214
+ ctx.findings
4215
+ .attributed([...byOp.keys()])
4216
+ .spec(this.id, ctx.entityName, "an operation returned a status the document does not declare", `${lines.join(". ")}. Clients generated from this document will not recognise the response.`, evidence);
4089
4217
  },
4090
4218
  };
4091
4219
  function pickFieldForOp(ctx, op, minDistinct = 2) {
@@ -4120,6 +4248,7 @@ const filterInIsUnionOfEq = {
4120
4248
  dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
4121
4249
  id: "filter.in-is-union-of-eq",
4122
4250
  needs: "a field that allows `in` and at least two distinct values",
4251
+ subjects: subjectsOf.list,
4123
4252
  async run(ctx) {
4124
4253
  const picked = pickFieldForOp(ctx, "in", 2);
4125
4254
  if (picked === null)
@@ -4156,6 +4285,7 @@ const filterNinComplementsIn = {
4156
4285
  dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
4157
4286
  id: "filter.nin-complements-in",
4158
4287
  needs: "a field that allows both `in` and `nin`",
4288
+ subjects: subjectsOf.list,
4159
4289
  async run(ctx) {
4160
4290
  const picked = pickFieldForOp(ctx, "in", 1);
4161
4291
  if (picked === null || !canUseOp(ctx, picked.field, "nin"))
@@ -4201,6 +4331,7 @@ const filterGteIsGtOrEq = {
4201
4331
  dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4202
4332
  id: "filter.gte-is-gt-or-eq",
4203
4333
  needs: "an ordered field that allows `gte` and `gt`",
4334
+ subjects: subjectsOf.list,
4204
4335
  async run(ctx) {
4205
4336
  const picked = pickOrderedField(ctx);
4206
4337
  if (picked === null || !canUseOp(ctx, picked.field, "gte"))
@@ -4216,6 +4347,7 @@ const filterLteIsLtOrEq = {
4216
4347
  dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4217
4348
  id: "filter.lte-is-lt-or-eq",
4218
4349
  needs: "an ordered field that allows `lte` and `lt`",
4350
+ subjects: subjectsOf.list,
4219
4351
  async run(ctx) {
4220
4352
  const picked = pickOrderedField(ctx);
4221
4353
  if (picked === null || !canUseOp(ctx, picked.field, "lte"))
@@ -4254,6 +4386,7 @@ const filterOrderedTriplePartitions = {
4254
4386
  dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4255
4387
  id: "filter.ordered-triple-partitions",
4256
4388
  needs: "an ordered field that allows `lt`, `eq`, and `gt`",
4389
+ subjects: subjectsOf.list,
4257
4390
  async run(ctx) {
4258
4391
  const picked = pickOrderedField(ctx);
4259
4392
  if (picked === null)
@@ -4308,6 +4441,7 @@ const filterIlikeIsCaseInsensitive = {
4308
4441
  dependsOn: [...FOUNDATIONS, "filter.like-metacharacters-escaped"],
4309
4442
  id: "filter.ilike-is-case-insensitive",
4310
4443
  needs: "a field that allows both `ilike` and `like`, and a string with a letter",
4444
+ subjects: subjectsOf.list,
4311
4445
  async run(ctx) {
4312
4446
  const field = fieldsAllowing(ctx, "ilike").find((item) => canUseOp(ctx, item, "like"));
4313
4447
  if (field === undefined)
@@ -4356,6 +4490,7 @@ const filterIsNullSelectsNulls = {
4356
4490
  dependsOn: [...FOUNDATIONS],
4357
4491
  id: "filter.is-null-selects-nulls",
4358
4492
  needs: "a field that allows `is` and a cohort that contains a null",
4493
+ subjects: subjectsOf.list,
4359
4494
  async run(ctx) {
4360
4495
  const field = fieldsAllowing(ctx, "is").find((item) => mixedNullField(ctx, item.field));
4361
4496
  if (field === undefined)
@@ -4393,6 +4528,7 @@ const filterContainsMembership = {
4393
4528
  dependsOn: [...FOUNDATIONS],
4394
4529
  id: "filter.contains-membership",
4395
4530
  needs: "an array field that allows `contains` and a known element",
4531
+ subjects: subjectsOf.list,
4396
4532
  async run(ctx) {
4397
4533
  const field = resolvedCaps(ctx).filterable.find((item) => canUseOp(ctx, item, "contains") &&
4398
4534
  ctx.records.some((row) => Array.isArray(row[item.field]) && row[item.field].length > 0));
@@ -4427,6 +4563,7 @@ const filterNestedAndOrDistributes = {
4427
4563
  dependsOn: [...FOUNDATIONS, "filter.and-composes-as-intersection", "filter.or-composes-as-union"],
4428
4564
  id: "filter.nested-and-or-distributes",
4429
4565
  needs: "a postgrest-shaped grammar and two filterable fields",
4566
+ subjects: subjectsOf.list,
4430
4567
  async run(ctx) {
4431
4568
  const picked = twoFilterableFields(ctx);
4432
4569
  if (picked === null) {
@@ -4474,6 +4611,7 @@ const filterAliasMatchesCanonical = {
4474
4611
  dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
4475
4612
  id: "filter.alias-matches-canonical",
4476
4613
  needs: "a declared filter operator alias",
4614
+ subjects: subjectsOf.list,
4477
4615
  async run(ctx) {
4478
4616
  const caps = resolvedCaps(ctx);
4479
4617
  const conventions = conv(ctx);
@@ -4516,6 +4654,7 @@ const filterIllegalOpRejected = {
4516
4654
  dependsOn: ["list.read-after-write", "filter.unknown-field-rejected", "error.malformed-filter-not-5xx"],
4517
4655
  id: "filter.illegal-op-rejected",
4518
4656
  needs: "a field with a closed operator list",
4657
+ subjects: subjectsOf.list,
4519
4658
  async run(ctx) {
4520
4659
  const field = resolvedCaps(ctx).filterable.find((item) => firstIllegalOp(ctx, item) !== undefined);
4521
4660
  const op = field === undefined ? undefined : firstIllegalOp(ctx, field);
@@ -4544,6 +4683,7 @@ const filterEmptyIn = {
4544
4683
  dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
4545
4684
  id: "filter.empty-in",
4546
4685
  needs: "`emptyIn` declared and a field that allows `in`",
4686
+ subjects: subjectsOf.list,
4547
4687
  async run(ctx) {
4548
4688
  const picked = pickFieldForOp(ctx, "in", 1);
4549
4689
  const policy = resolvedCaps(ctx).emptyIn;
@@ -4576,6 +4716,7 @@ const filterInOverLimitRejected = {
4576
4716
  dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
4577
4717
  id: "filter.in-over-limit-rejected",
4578
4718
  needs: "`maxInValues` declared and a field that allows `in`",
4719
+ subjects: subjectsOf.list,
4579
4720
  async run(ctx) {
4580
4721
  const max = resolvedCaps(ctx).maxInValues;
4581
4722
  const picked = pickFieldForOp(ctx, "in", 1);
@@ -4598,6 +4739,7 @@ const filterConditionCapRejected = {
4598
4739
  dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
4599
4740
  id: "filter.condition-cap-rejected",
4600
4741
  needs: "`maxFilterConditions` declared and a grammar that can group eq terms",
4742
+ subjects: subjectsOf.list,
4601
4743
  async run(ctx) {
4602
4744
  const max = resolvedCaps(ctx).maxFilterConditions;
4603
4745
  const parameter = conv(ctx).filter;
@@ -4632,6 +4774,7 @@ const declaredFilterableOpsAccepted = {
4632
4774
  dependsOn: ["list.read-after-write", "spec.declared-filterable-is-filterable", "error.malformed-filter-not-5xx"],
4633
4775
  id: "spec.declared-filterable-ops-accepted",
4634
4776
  needs: "a closed operator list on at least one declared field",
4777
+ subjects: subjectsOf.list,
4635
4778
  async run(ctx) {
4636
4779
  const caps = resolvedCaps(ctx);
4637
4780
  const rejected = [];
@@ -4661,6 +4804,7 @@ const declaredFilterableIllegalOpRejected = {
4661
4804
  dependsOn: ["filter.illegal-op-rejected", "spec.declared-filterable-is-filterable"],
4662
4805
  id: "spec.declared-filterable-illegal-op-rejected",
4663
4806
  needs: "a closed operator list on a declared field",
4807
+ subjects: subjectsOf.list,
4664
4808
  async run(ctx) {
4665
4809
  await filterIllegalOpRejected.run(ctx);
4666
4810
  },
@@ -4670,6 +4814,7 @@ const sortUnknownFieldRejected = {
4670
4814
  dependsOn: ["error.malformed-filter-not-5xx"],
4671
4815
  id: "sort.unknown-field-rejected",
4672
4816
  needs: "an order parameter",
4817
+ subjects: subjectsOf.list,
4673
4818
  async run(ctx) {
4674
4819
  const result = await list(ctx, q(ctx, { limit: 5, order: sortTerm(conv(ctx), "oat_no_such_sort_xyz", "asc") }));
4675
4820
  if (result.exchange.status >= 500) {
@@ -4691,6 +4836,7 @@ const sortNumericOrderIsNumeric = {
4691
4836
  dependsOn: ["sort.order-is-applied"],
4692
4837
  id: "sort.numeric-order-is-numeric",
4693
4838
  needs: "a numeric sortable field whose lexical order disagrees with numeric order",
4839
+ subjects: subjectsOf.list,
4694
4840
  async run(ctx) {
4695
4841
  const field = resolvedCaps(ctx).sortable.find((item) => numericLexicalDisagrees(ctx, item.field));
4696
4842
  if (field === undefined)
@@ -4713,6 +4859,7 @@ const sortNullsFirstLast = {
4713
4859
  dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
4714
4860
  id: "sort.nulls-first-last",
4715
4861
  needs: "a declared nulls token, a dotted sort grammar, and a null in the cohort",
4862
+ subjects: subjectsOf.list,
4716
4863
  async run(ctx) {
4717
4864
  const field = resolvedCaps(ctx).sortable.find((item) => (fieldAllowsNulls(item, resolvedCaps(ctx), "first") || fieldAllowsNulls(item, resolvedCaps(ctx), "last")) &&
4718
4865
  ctx.records.some((row) => row[item.field] === null || row[item.field] === undefined));
@@ -4774,6 +4921,7 @@ const sortMultiKeyTiebreak = {
4774
4921
  dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
4775
4922
  id: "sort.multi-key-tiebreak",
4776
4923
  needs: "two sortable fields and rows that tie on the first and differ on the second",
4924
+ subjects: subjectsOf.list,
4777
4925
  async run(ctx) {
4778
4926
  const pair = pickMultiKeyPair(ctx);
4779
4927
  if (pair === null) {
@@ -4798,6 +4946,7 @@ const sortDefaultOrderApplied = {
4798
4946
  dependsOn: ["sort.order-is-applied", "pagination.page-walk-covers-set"],
4799
4947
  id: "sort.default-order-applied",
4800
4948
  needs: "a declared defaultOrder and a complete walk",
4949
+ subjects: subjectsOf.list,
4801
4950
  async run(ctx) {
4802
4951
  const declared = resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder;
4803
4952
  if (declared === undefined)
@@ -4821,6 +4970,7 @@ const sortStableTiebreak = {
4821
4970
  dependsOn: ["sort.order-is-applied"],
4822
4971
  id: "sort.stable-tiebreak",
4823
4972
  needs: "a declared stableTiebreak and a tie on the primary key",
4973
+ subjects: subjectsOf.list,
4824
4974
  async run(ctx) {
4825
4975
  const tiebreak = resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak;
4826
4976
  const primary = ctx.query?.sortable.find((name) => name !== tiebreak) ?? ctx.query?.sortable[0];
@@ -4843,6 +4993,7 @@ const declaredSortableNullsAccepted = {
4843
4993
  dependsOn: ["sort.order-is-applied"],
4844
4994
  id: "spec.declared-sortable-nulls-accepted",
4845
4995
  needs: "a declared nulls token on a sortable field",
4996
+ subjects: subjectsOf.list,
4846
4997
  async run(ctx) {
4847
4998
  const rejected = [];
4848
4999
  for (const field of resolvedCaps(ctx).sortable) {
@@ -4868,6 +5019,7 @@ const searchTokensAnd = {
4868
5019
  dependsOn: ["search.q-narrows-result"],
4869
5020
  id: "search.tokens-and",
4870
5021
  needs: "searchable fields and two tokens that split the cohort",
5022
+ subjects: subjectsOf.list,
4871
5023
  async run(ctx) {
4872
5024
  const field = ctx.query?.searchable[0];
4873
5025
  if (field === undefined)
@@ -4911,6 +5063,7 @@ const searchCaseInsensitive = {
4911
5063
  dependsOn: ["search.q-narrows-result"],
4912
5064
  id: "search.case-insensitive",
4913
5065
  needs: "a searchable string with a letter",
5066
+ subjects: subjectsOf.list,
4914
5067
  async run(ctx) {
4915
5068
  let sample;
4916
5069
  for (const field of ctx.query?.searchable ?? []) {
@@ -4947,6 +5100,7 @@ const searchEmptyQ = {
4947
5100
  dependsOn: ["search.q-narrows-result"],
4948
5101
  id: "search.empty-q",
4949
5102
  needs: "`searchEmpty` declared",
5103
+ subjects: subjectsOf.list,
4950
5104
  async run(ctx) {
4951
5105
  const policy = resolvedCaps(ctx).searchEmpty;
4952
5106
  if (policy === undefined)
@@ -4978,6 +5132,7 @@ const searchUndeclaredFieldNotRequired = {
4978
5132
  dependsOn: ["search.q-narrows-result"],
4979
5133
  id: "search.undeclared-field-not-required",
4980
5134
  needs: "a searchable field and a non-searchable string",
5135
+ subjects: subjectsOf.list,
4981
5136
  async run() {
4982
5137
  /* Extra recall is not a defect. This check exists so undeclared-field hits are not
4983
5138
  * reported as SEARCH_IGNORED by other checks. */
@@ -4995,6 +5150,7 @@ const searchModeAccepted = {
4995
5150
  conv(ctx).search !== undefined,
4996
5151
  id: "search.mode-accepted",
4997
5152
  needs: "declared searchModes and a search-mode parameter",
5153
+ subjects: subjectsOf.list,
4998
5154
  async run(ctx) {
4999
5155
  const rejected = [];
5000
5156
  for (const mode of resolvedCaps(ctx).searchModes ?? []) {
@@ -5014,6 +5170,7 @@ const searchModesDiffer = {
5014
5170
  dependsOn: ["search.mode-accepted"],
5015
5171
  id: "search.modes-differ",
5016
5172
  needs: "at least two declared searchModes and a mode parameter",
5173
+ subjects: subjectsOf.list,
5017
5174
  async run(ctx) {
5018
5175
  const modes = resolvedCaps(ctx).searchModes ?? [];
5019
5176
  const a = modes[0];
@@ -5042,6 +5199,7 @@ const selectRequestedFieldsPresent = {
5042
5199
  dependsOn: ["select.projection-honoured"],
5043
5200
  id: "select.requested-fields-present",
5044
5201
  needs: "a select parameter and at least one selectable field",
5202
+ subjects: subjectsOf.list,
5045
5203
  async run(ctx) {
5046
5204
  const requested = [
5047
5205
  ctx.identity,
@@ -5070,6 +5228,7 @@ const selectUnknownFieldRejected = {
5070
5228
  dependsOn: ["select.projection-honoured", "error.malformed-filter-not-5xx"],
5071
5229
  id: "select.unknown-field-rejected",
5072
5230
  needs: "`select.unknown` declared",
5231
+ subjects: subjectsOf.list,
5073
5232
  async run(ctx) {
5074
5233
  const policy = resolvedCaps(ctx).select?.unknown;
5075
5234
  if (policy === undefined)
@@ -5101,6 +5260,7 @@ const selectNestedHonoured = {
5101
5260
  dependsOn: ["select.projection-honoured"],
5102
5261
  id: "select.nested-honoured",
5103
5262
  needs: "select.nested and a named relation",
5263
+ subjects: subjectsOf.list,
5104
5264
  async run(ctx) {
5105
5265
  const relation = resolvedCaps(ctx).select?.relations?.[0];
5106
5266
  if (relation === undefined || relation.fields[0] === undefined)
@@ -5133,6 +5293,7 @@ const querySortAndSelectCompose = {
5133
5293
  dependsOn: ["sort.order-is-applied", "select.projection-honoured", "select.requested-fields-present"],
5134
5294
  id: "query.sort-and-select-compose",
5135
5295
  needs: "sortable and selectable fields",
5296
+ subjects: subjectsOf.list,
5136
5297
  async run(ctx) {
5137
5298
  const field = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
5138
5299
  const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
@@ -5167,6 +5328,7 @@ const querySearchAndSelectCompose = {
5167
5328
  dependsOn: ["search.q-narrows-result", "select.projection-honoured"],
5168
5329
  id: "query.search-and-select-compose",
5169
5330
  needs: "searchable and selectable fields",
5331
+ subjects: subjectsOf.list,
5170
5332
  async run(ctx) {
5171
5333
  const field = ctx.query?.searchable[0];
5172
5334
  if (field === undefined)
@@ -5201,6 +5363,7 @@ const querySearchAndSortCompose = {
5201
5363
  dependsOn: ["search.q-narrows-result", "sort.order-is-applied"],
5202
5364
  id: "query.search-and-sort-compose",
5203
5365
  needs: "searchable and sortable fields",
5366
+ subjects: subjectsOf.list,
5204
5367
  async run(ctx) {
5205
5368
  const searchField = ctx.query?.searchable[0];
5206
5369
  const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
@@ -5248,6 +5411,7 @@ const queryFilterSearchSortSelectCompose = {
5248
5411
  ],
5249
5412
  id: "query.filter-search-sort-select-compose",
5250
5413
  needs: "all four list axes declared and at least three records",
5414
+ subjects: subjectsOf.list,
5251
5415
  async run(ctx) {
5252
5416
  const picked = overlappingFilterAndSearch(ctx);
5253
5417
  if (picked === null) {
@@ -5319,6 +5483,7 @@ const uniqueConflictCreate = {
5319
5483
  id: "create.unique-conflict-rejected",
5320
5484
  mutates: true,
5321
5485
  needs: "x-unique on create with at least one probeable column set, and a known row",
5486
+ subjects: subjectsOf.create,
5322
5487
  async run(ctx) {
5323
5488
  const createOp = ctx.uniqueCreateOp;
5324
5489
  if (createOp === undefined)
@@ -5387,6 +5552,7 @@ const uniqueConflictUpdate = {
5387
5552
  id: "update.unique-conflict-rejected",
5388
5553
  mutates: true,
5389
5554
  needs: "x-unique, an update operation with a probeable set, and two known rows",
5555
+ subjects: subjectsOf.update,
5390
5556
  async run(ctx) {
5391
5557
  const updateOp = ctx.uniqueUpdateOp;
5392
5558
  if (updateOp === undefined)