@lovrozagar/oat 0.12.22 → 0.14.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 (100) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +1 -0
  3. package/dist/config/define-config.d.ts +82 -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/load.d.ts.map +1 -1
  7. package/dist/config/load.js +6 -1
  8. package/dist/config/load.js.map +1 -1
  9. package/dist/config/validate.d.ts.map +1 -1
  10. package/dist/config/validate.js +1 -0
  11. package/dist/config/validate.js.map +1 -1
  12. package/dist/conformance/suite.d.ts.map +1 -1
  13. package/dist/conformance/suite.js +3 -0
  14. package/dist/conformance/suite.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/reference/defects.d.ts +7 -0
  17. package/dist/reference/defects.d.ts.map +1 -1
  18. package/dist/reference/defects.js +7 -0
  19. package/dist/reference/defects.js.map +1 -1
  20. package/dist/reference/http.js +1 -1
  21. package/dist/reference/http.js.map +1 -1
  22. package/dist/report/render.js +3 -3
  23. package/dist/report/render.js.map +1 -1
  24. package/dist/runtime/auth.d.ts +14 -0
  25. package/dist/runtime/auth.d.ts.map +1 -1
  26. package/dist/runtime/auth.js +16 -5
  27. package/dist/runtime/auth.js.map +1 -1
  28. package/dist/runtime/check-costs.d.ts.map +1 -1
  29. package/dist/runtime/check-costs.js +1 -0
  30. package/dist/runtime/check-costs.js.map +1 -1
  31. package/dist/runtime/check-ids.d.ts +1 -1
  32. package/dist/runtime/check-ids.d.ts.map +1 -1
  33. package/dist/runtime/check-ids.js +1 -0
  34. package/dist/runtime/check-ids.js.map +1 -1
  35. package/dist/runtime/checks.d.ts.map +1 -1
  36. package/dist/runtime/checks.js +214 -48
  37. package/dist/runtime/checks.js.map +1 -1
  38. package/dist/runtime/client.d.ts +2 -0
  39. package/dist/runtime/client.d.ts.map +1 -1
  40. package/dist/runtime/client.js +6 -1
  41. package/dist/runtime/client.js.map +1 -1
  42. package/dist/runtime/contract.d.ts +52 -0
  43. package/dist/runtime/contract.d.ts.map +1 -0
  44. package/dist/runtime/contract.js +261 -0
  45. package/dist/runtime/contract.js.map +1 -0
  46. package/dist/runtime/disposable.d.ts +22 -15
  47. package/dist/runtime/disposable.d.ts.map +1 -1
  48. package/dist/runtime/disposable.js +250 -1360
  49. package/dist/runtime/disposable.js.map +1 -1
  50. package/dist/runtime/effects.d.ts +1 -1
  51. package/dist/runtime/effects.d.ts.map +1 -1
  52. package/dist/runtime/evidence.d.ts +21 -0
  53. package/dist/runtime/evidence.d.ts.map +1 -0
  54. package/dist/runtime/evidence.js +168 -0
  55. package/dist/runtime/evidence.js.map +1 -0
  56. package/dist/runtime/path.d.ts +6 -0
  57. package/dist/runtime/path.d.ts.map +1 -1
  58. package/dist/runtime/path.js +46 -0
  59. package/dist/runtime/path.js.map +1 -1
  60. package/dist/runtime/run.d.ts +3 -1
  61. package/dist/runtime/run.d.ts.map +1 -1
  62. package/dist/runtime/run.js +103 -14
  63. package/dist/runtime/run.js.map +1 -1
  64. package/dist/runtime/scenario.d.ts +39 -0
  65. package/dist/runtime/scenario.d.ts.map +1 -0
  66. package/dist/runtime/scenario.js +337 -0
  67. package/dist/runtime/scenario.js.map +1 -0
  68. package/dist/runtime/scope.d.ts +28 -0
  69. package/dist/runtime/scope.d.ts.map +1 -1
  70. package/dist/runtime/scope.js +31 -4
  71. package/dist/runtime/scope.js.map +1 -1
  72. package/dist/runtime/self.d.ts.map +1 -1
  73. package/dist/runtime/self.js +12 -0
  74. package/dist/runtime/self.js.map +1 -1
  75. package/dist/runtime/teardown.d.ts +3 -0
  76. package/dist/runtime/teardown.d.ts.map +1 -1
  77. package/dist/runtime/teardown.js +7 -1
  78. package/dist/runtime/teardown.js.map +1 -1
  79. package/dist/runtime/transcript.d.ts.map +1 -1
  80. package/dist/runtime/transcript.js +9 -5
  81. package/dist/runtime/transcript.js.map +1 -1
  82. package/dist/spec/collection.d.ts +2 -1
  83. package/dist/spec/collection.d.ts.map +1 -1
  84. package/dist/spec/collection.js +13 -2
  85. package/dist/spec/collection.js.map +1 -1
  86. package/dist/spec/extensions.d.ts +6 -0
  87. package/dist/spec/extensions.d.ts.map +1 -1
  88. package/dist/spec/extensions.js +2 -0
  89. package/dist/spec/extensions.js.map +1 -1
  90. package/dist/spec/graph.d.ts +8 -0
  91. package/dist/spec/graph.d.ts.map +1 -1
  92. package/dist/spec/graph.js +44 -2
  93. package/dist/spec/graph.js.map +1 -1
  94. package/dist/spec/tag-schema.d.ts.map +1 -1
  95. package/dist/spec/tag-schema.js +1 -0
  96. package/dist/spec/tag-schema.js.map +1 -1
  97. package/docs/configuration.md +43 -1
  98. package/docs/reports.md +19 -1
  99. package/docs/tags.md +7 -2
  100. package/package.json +14 -14
@@ -8,6 +8,7 @@
8
8
  import { canWriteFilterOp, filterTerm, selectTerm, sortTerm, sortTermWithNulls } from "../spec/conventions.js";
9
9
  import { CHECK_IDS } from "./check-ids.js";
10
10
  import { publicGetCheck } from "./one-shot.js";
11
+ import { contractCallCheck } from "./contract.js";
11
12
  import { disposableCheck } from "./disposable.js";
12
13
  import { rootReadCheck } from "./root-read.js";
13
14
  import { selfIdentityCheck } from "./self.js";
@@ -30,7 +31,7 @@ import { forEachInvocation } from "./upload-each.js";
30
31
  import { driveWait } from "./wait.js";
31
32
  import { fillPath, isPlanLimitResponse } from "./world.js";
32
33
  import { bodyPropertyNames, collisionCreateBody, collisionUpdatePatch, idempotencyHeaderRequired, probeableUniqueSets, uniquifyProbeBody, uniqueProbeHeaders, } from "./unique.js";
33
- import { bindAfterCreateEffects, bindCreatedScope, applyActionBind, bindActionScope, bindInstanceScope, bindMissingPathParams, readActionBind, readBefore, canFillPath, describeEffectHold, effectHolds, identityPathParam, } from "./effects.js";
34
+ import { bindAfterCreateEffects, bindCreatedScope, applyActionBind, bindActionScope, bindInstanceScope, bindMissingPathParams, readActionBind, readBefore, canFillPath, createdIdKeys, describeEffectHold, effectHolds, findCreatedId, identityPathParam, } from "./effects.js";
34
35
  const graded = (ctx, op) => ctx.inScope?.(op) ?? true;
35
36
  /* ------------------------------------------------------------------- invented values */
36
37
  /** This entity's identifier as its item route takes it: the path parameter's schema, else the record's. */
@@ -100,7 +101,34 @@ function judgeRefusal(ctx, check, reasons, exchange) {
100
101
  * from what ordinary traffic got.
101
102
  */
102
103
  function asProbe(ctx) {
103
- return { ...ctx, client: ctx.client.view({ purpose: "probe" }) };
104
+ const view = ctx.client.view({ purpose: "probe" });
105
+ const createOp = ctx.createOp;
106
+ const record = ctx.recordCreated;
107
+ if (createOp === undefined || record === undefined || !("request" in view))
108
+ return { ...ctx, client: view };
109
+ /* A probe sends what should be refused. When the server accepts it anyway, the record it made is
110
+ * the run's to remove like any other: the finding says what went wrong, the teardown cleans up. */
111
+ const createPath = fillPath(createOp.path, ctx.scope);
112
+ const keys = createdIdKeys(ctx.entityName, ctx.identity);
113
+ const client = new Proxy(view, {
114
+ get(target, property) {
115
+ const value = Reflect.get(target, property, target);
116
+ if (property !== "request" || typeof value !== "function") {
117
+ return typeof value === "function" ? value.bind(target) : value;
118
+ }
119
+ return async (method, path, options) => {
120
+ const exchange = (await value.call(target, method, path, options));
121
+ const created = method.toUpperCase() === createOp.method.toUpperCase() && path === createPath;
122
+ if (created && exchange.status >= 200 && exchange.status < 300) {
123
+ const id = findCreatedId(exchange.responseBody, keys);
124
+ if (id !== undefined)
125
+ record(ctx.entityName, id, { ...ctx.scope });
126
+ }
127
+ return exchange;
128
+ };
129
+ },
130
+ });
131
+ return { ...ctx, client };
104
132
  }
105
133
  /** A valid value for `field` that differs from `current` — what a write probe changes it to. */
106
134
  function changedValue(ctx, field, current, op) {
@@ -583,10 +611,77 @@ async function listAll(ctx, query) {
583
611
  * Collects a whole result set — `readSet`, shaped for the set-algebra checks. `null` when a page
584
612
  * was refused, so the caller reports the check unresolved rather than reading "refused" as "empty".
585
613
  */
586
- async function collectSet(ctx, requested, extra = {}, order) {
587
- const read = await readSet(ctx, extra, { order, size: requested });
614
+ async function collectSet(ctx, requested, extra = {}, order,
615
+ /* A repeat read that must see the collection as it is now, not as an earlier read saw it. */
616
+ fresh = false) {
617
+ const read = await readSet(ctx, extra, { ...(fresh ? { fresh: true } : {}), order, size: requested });
588
618
  return read.status === "ok" ? read : null;
589
619
  }
620
+ /** A read's rows as a set: two reads of one collection agree on it unless the collection moved. */
621
+ function membership(records, identity) {
622
+ return ids(records, identity).sort().join(",");
623
+ }
624
+ const MOVING_FILTER = "the collection gained or lost rows between the term reads and the combined read (something wrote " +
625
+ "to it while the check ran), so the reads cannot be compared";
626
+ const MOVING = "the collection gained or lost rows between two reads in the same direction (something wrote to it " +
627
+ "while the check ran), so the reads cannot be compared";
628
+ /**
629
+ * Two reads that must hold the same rows and do not. Read the first again: when the same read
630
+ * now holds other rows, the collection is moving under the check — an append-only log the run
631
+ * itself writes to — and that is no defect of ordering. When it holds still, read the second again
632
+ * and compare that fresh pair; only a disagreement that survives it is the backend's.
633
+ */
634
+ async function settlePair(identity, first, second, again) {
635
+ if (membership(first.items, identity) === membership(second.items, identity))
636
+ return { first, second };
637
+ const firstAgain = await again.first();
638
+ if (firstAgain === null)
639
+ return null;
640
+ if (membership(firstAgain.items, identity) !== membership(first.items, identity))
641
+ return "moving";
642
+ const secondAgain = await again.second();
643
+ if (secondAgain === null)
644
+ return null;
645
+ if (membership(secondAgain.items, identity) !== membership(second.items, identity))
646
+ return "moving";
647
+ return { first: firstAgain, second: secondAgain };
648
+ }
649
+ /**
650
+ * Reads each term alone and then combined. A disagreement is read again: when any of the three
651
+ * reads now holds other rows, another writer moved the collection between them — a record created
652
+ * after the term reads and before the combined one — and that is no defect of composition. Only a
653
+ * disagreement the fresh reads repeat is the backend's.
654
+ */
655
+ async function readComposed(ctx, terms, compose) {
656
+ const limit = pageSize(ctx);
657
+ const read = async (fresh) => {
658
+ const onlyA = await collectSet(ctx, limit, terms.termA, undefined, fresh);
659
+ const onlyB = await collectSet(ctx, limit, terms.termB, undefined, fresh);
660
+ const combined = await collectSet(ctx, limit, terms.combined, undefined, fresh);
661
+ if (onlyA === null || onlyB === null || combined === null)
662
+ return "rejected";
663
+ if (!onlyA.complete || !onlyB.complete || !combined.complete)
664
+ return "incomplete";
665
+ const setA = new Set(ids(onlyA.items, ctx.identity));
666
+ const setB = new Set(ids(onlyB.items, ctx.identity));
667
+ const expected = compose(setA, setB);
668
+ const got = new Set(ids(combined.items, ctx.identity));
669
+ const missing = [...expected].filter((id) => !got.has(id));
670
+ const extra = [...got].filter((id) => !expected.has(id));
671
+ return { combined, expected, extra, got, missing, onlyA, onlyB, setA, setB };
672
+ };
673
+ const first = await read(false);
674
+ if (typeof first === "string" || (first.missing.length === 0 && first.extra.length === 0))
675
+ return first;
676
+ const again = await read(true);
677
+ if (typeof again === "string")
678
+ return again;
679
+ const same = (a, b) => membership(a.items, ctx.identity) === membership(b.items, ctx.identity);
680
+ if (!same(first.onlyA, again.onlyA) || !same(first.onlyB, again.onlyB) || !same(first.combined, again.combined)) {
681
+ return "moving";
682
+ }
683
+ return again;
684
+ }
590
685
  /** Walks pages in order, returning ids as encountered — repeats kept, since they are the finding. */
591
686
  async function walkPages(ctx, pageSize, order, maxPages = MAX_WALK_PAGES) {
592
687
  const read = await readSet(ctx, {}, { fresh: true, keepRepeats: true, maxPages, order, size: pageSize });
@@ -1084,22 +1179,16 @@ const filterAndComposesAsIntersection = {
1084
1179
  needs: "two filterable fields and a filter parameter or grammar supporting eq",
1085
1180
  subjects: subjectsOf.list,
1086
1181
  async run(ctx, { combined, fieldA, fieldB, termA, termB, valueA, valueB }) {
1087
- const limit = pageSize(ctx);
1088
- const onlyA = await collectSet(ctx, limit, termA);
1089
- const onlyB = await collectSet(ctx, limit, termB);
1090
- const both = await collectSet(ctx, limit, combined);
1091
- if (onlyA === null || onlyB === null || both === null) {
1182
+ const composed = await readComposed(ctx, { combined, termA, termB }, (a, b) => new Set([...a].filter((id) => b.has(id))));
1183
+ if (composed === "rejected") {
1092
1184
  return ctx.findings.unresolved(this.id, ctx.entityName, "one of the three listings needed for the intersection was rejected");
1093
1185
  }
1094
- if (!onlyA.complete || !onlyB.complete || !both.complete) {
1186
+ if (composed === "incomplete") {
1095
1187
  return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the intersection cannot be verified");
1096
1188
  }
1097
- const setA = new Set(ids(onlyA.items, ctx.identity));
1098
- const setB = new Set(ids(onlyB.items, ctx.identity));
1099
- const expected = new Set([...setA].filter((id) => setB.has(id)));
1100
- const got = new Set(ids(both.items, ctx.identity));
1101
- const missing = [...expected].filter((id) => !got.has(id));
1102
- const extra = [...got].filter((id) => !expected.has(id));
1189
+ if (composed === "moving")
1190
+ return ctx.findings.unresolved(this.id, ctx.entityName, MOVING_FILTER);
1191
+ const { expected, extra, got, missing, onlyA, onlyB, setA, setB } = composed;
1103
1192
  if (missing.length === 0 && extra.length === 0)
1104
1193
  return ASSERTED;
1105
1194
  return ctx.findings.backend(this.id, ctx.entityName, "and() does not compose as the intersection of its terms", `and(${fieldA}.eq.${valueA},${fieldB}.eq.${valueB}) returned ${got.size} record(s); ` +
@@ -1107,7 +1196,7 @@ const filterAndComposesAsIntersection = {
1107
1196
  `${setB.size}, whose intersection is ${expected.size}. ` +
1108
1197
  (missing.length > 0 ? `Missing from the combined result: ${missing.slice(0, 3).join(", ")}. ` : "") +
1109
1198
  (extra.length > 0 ? `Present but should not match both terms: ${extra.slice(0, 3).join(", ")}. ` : "") +
1110
- "A conjunction must select exactly the records both terms match individually.", [onlyA.last.exchange, onlyB.last.exchange, both.last.exchange]);
1199
+ "A conjunction must select exactly the records both terms match individually.", [onlyA.last.exchange, onlyB.last.exchange, composed.combined.last.exchange]);
1111
1200
  },
1112
1201
  };
1113
1202
  /**
@@ -1134,22 +1223,16 @@ const filterOrComposesAsUnion = {
1134
1223
  needs: "two filterable fields and an or() combinator (postgrest-shaped filter grammar)",
1135
1224
  subjects: subjectsOf.list,
1136
1225
  async run(ctx, { combined, fieldA, fieldB, termA, termB, valueA, valueB }) {
1137
- const limit = pageSize(ctx);
1138
- const onlyA = await collectSet(ctx, limit, termA);
1139
- const onlyB = await collectSet(ctx, limit, termB);
1140
- const either = await collectSet(ctx, limit, combined);
1141
- if (onlyA === null || onlyB === null || either === null) {
1226
+ const composed = await readComposed(ctx, { combined, termA, termB }, (a, b) => new Set([...a, ...b]));
1227
+ if (composed === "rejected") {
1142
1228
  return ctx.findings.unresolved(this.id, ctx.entityName, "one of the three listings needed for the union was rejected");
1143
1229
  }
1144
- if (!onlyA.complete || !onlyB.complete || !either.complete) {
1230
+ if (composed === "incomplete") {
1145
1231
  return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the union cannot be verified");
1146
1232
  }
1147
- const setA = new Set(ids(onlyA.items, ctx.identity));
1148
- const setB = new Set(ids(onlyB.items, ctx.identity));
1149
- const expected = new Set([...setA, ...setB]);
1150
- const got = new Set(ids(either.items, ctx.identity));
1151
- const missing = [...expected].filter((id) => !got.has(id));
1152
- const extra = [...got].filter((id) => !expected.has(id));
1233
+ if (composed === "moving")
1234
+ return ctx.findings.unresolved(this.id, ctx.entityName, MOVING_FILTER);
1235
+ const { expected, extra, got, missing, onlyA, onlyB, setA, setB } = composed;
1153
1236
  if (missing.length === 0 && extra.length === 0)
1154
1237
  return ASSERTED;
1155
1238
  return ctx.findings.backend(this.id, ctx.entityName, "or() does not compose as the union of its terms", `or(${fieldA}.eq.${valueA},${fieldB}.eq.${valueB}) returned ${got.size} record(s); ` +
@@ -1157,7 +1240,7 @@ const filterOrComposesAsUnion = {
1157
1240
  `${setB.size}, whose union is ${expected.size}. ` +
1158
1241
  (missing.length > 0 ? `Missing from the combined result: ${missing.slice(0, 3).join(", ")}. ` : "") +
1159
1242
  (extra.length > 0 ? `Present but matches neither term individually: ${extra.slice(0, 3).join(", ")}. ` : "") +
1160
- "A disjunction must select exactly the records either term matches alone.", [onlyA.last.exchange, onlyB.last.exchange, either.last.exchange]);
1243
+ "A disjunction must select exactly the records either term matches alone.", [onlyA.last.exchange, onlyB.last.exchange, composed.combined.last.exchange]);
1161
1244
  },
1162
1245
  };
1163
1246
  const sortReverseSymmetry = {
@@ -1190,8 +1273,8 @@ const sortReverseSymmetry = {
1190
1273
  /* Gathered across pages: a collection larger than one page would otherwise leave this
1191
1274
  * property — that a reversal reorders a set without changing its membership — untested on
1192
1275
  * exactly the collections where sorting matters most. */
1193
- const ascending = await collectSet(ctx, limit, {}, sortTerm(conv(ctx), field, "asc"));
1194
- const descending = await collectSet(ctx, limit, {}, sortTerm(conv(ctx), field, "desc"));
1276
+ let ascending = await collectSet(ctx, limit, {}, sortTerm(conv(ctx), field, "asc"));
1277
+ let descending = await collectSet(ctx, limit, {}, sortTerm(conv(ctx), field, "desc"));
1195
1278
  if (ascending === null || descending === null) {
1196
1279
  return ctx.findings.unresolved(this.id, ctx.entityName, `ordering by "${field}" was rejected in one direction, so the two cannot be compared`);
1197
1280
  }
@@ -1202,6 +1285,17 @@ const sortReverseSymmetry = {
1202
1285
  return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the two directions return " +
1203
1286
  "different windows of it rather than the same set reversed");
1204
1287
  }
1288
+ const settled = await settlePair(ctx.identity, ascending, descending, {
1289
+ first: () => collectSet(ctx, limit, {}, sortTerm(conv(ctx), field, "asc"), true),
1290
+ second: () => collectSet(ctx, limit, {}, sortTerm(conv(ctx), field, "desc"), true),
1291
+ });
1292
+ if (settled === "moving")
1293
+ return ctx.findings.unresolved(this.id, ctx.entityName, MOVING);
1294
+ if (settled === null) {
1295
+ return ctx.findings.unresolved(this.id, ctx.entityName, `ordering by "${field}" was rejected on a repeat read`);
1296
+ }
1297
+ ascending = settled.first;
1298
+ descending = settled.second;
1205
1299
  const forward = ids(ascending.items, ctx.identity);
1206
1300
  const backward = ids(descending.items, ctx.identity);
1207
1301
  if (forward.length !== backward.length) {
@@ -3511,25 +3605,34 @@ const inviteGrantsThenRevokes = {
3511
3605
  if (inviteOp === undefined || acceptOp === undefined || revokeOp === undefined) {
3512
3606
  return cannot("x-invite operations that exist in the document");
3513
3607
  }
3514
- return ready({ acceptOp, delegate, inviteAs: delegate.inviteAs, inviteOp, owner, revokeOp, spec });
3608
+ const grantsOp = spec.grants === undefined ? undefined : ctx.model.byOperationId.get(spec.grants);
3609
+ if (spec.grants !== undefined && grantsOp === undefined)
3610
+ return cannot("an x-invite.grants read that exists in the document");
3611
+ return ready({ acceptOp, delegate, grantsOp, inviteAs: delegate.inviteAs, inviteOp, owner, revokeOp, spec });
3515
3612
  },
3516
3613
  dependsOn: ["list.read-after-write", "tenant.item-not-readable-cross-tenant"],
3517
3614
  id: "auth.invite-grants-then-revokes",
3518
3615
  needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item or list route",
3519
3616
  subjects: subjectsOf.invite,
3520
- async run(ctx, { acceptOp, delegate, inviteAs, inviteOp, owner, revokeOp, spec }) {
3617
+ async run(ctx, { acceptOp, delegate, grantsOp, inviteAs, inviteOp, owner, revokeOp, spec }) {
3521
3618
  const target = ctx.records[0];
3522
3619
  const id = target === undefined ? undefined : String(target[ctx.identity]);
3523
3620
  const resource = {
3524
3621
  ...ctx.scope,
3525
3622
  ...(id === undefined ? {} : itemParamFor(ctx, id)),
3526
3623
  };
3527
- const itemPath = ctx.readOp?.path;
3624
+ const grantRead = grantsOp !== undefined
3625
+ ? { operationId: grantsOp.operationId, path: grantsOp.path }
3626
+ : ctx.readOp === undefined || id === undefined
3627
+ ? undefined
3628
+ : { operationId: ctx.readOp.operationId, path: ctx.readOp.path };
3629
+ const subject = grantsOp === undefined ? `${ctx.entityName} ${id}` : `${grantsOp.operationId} on the inviter's scope`;
3528
3630
  const canRead = async () => {
3529
- if (itemPath === undefined || id === undefined)
3631
+ if (grantRead === undefined)
3530
3632
  return null;
3531
- const exchange = await ctx.client.get(fillPath(itemPath, { ...delegate.roots, ...resource }), {
3633
+ const exchange = await ctx.client.get(fillPath(grantRead.path, { ...delegate.roots, ...resource }), {
3532
3634
  headers: delegate.headers(),
3635
+ operationId: grantRead.operationId,
3533
3636
  });
3534
3637
  return exchange.status < 300;
3535
3638
  };
@@ -3550,9 +3653,16 @@ const inviteGrantsThenRevokes = {
3550
3653
  const grantId = pointerValue(invited.responseBody, spec.grantPointer);
3551
3654
  let token;
3552
3655
  let revokedAlready = false;
3656
+ /* The grant is its own record: revoke addresses it, not the record the check read first. */
3657
+ const revokeParam = revokeOp.pathParams.at(-1);
3553
3658
  const revoke = async (grant) => {
3554
3659
  revokedAlready = true;
3555
- const revokeScope = { ...resource, grant_id: grant, ...(token === undefined ? {} : { token }) };
3660
+ const revokeScope = {
3661
+ ...resource,
3662
+ ...(revokeParam === undefined || revokeParam in ctx.scope ? {} : { [revokeParam]: grant }),
3663
+ grant_id: grant,
3664
+ ...(token === undefined ? {} : { token }),
3665
+ };
3556
3666
  const revokeBody = documentedJsonBody(ctx, revokeOp, revokeScope);
3557
3667
  return ctx.client.request(revokeOp.method, fillPath(revokeOp.path, revokeScope), {
3558
3668
  ...(revokeBody === undefined ? {} : { body: revokeBody }),
@@ -3567,7 +3677,7 @@ const inviteGrantsThenRevokes = {
3567
3677
  * handed out is taken back before it returns. */
3568
3678
  try {
3569
3679
  if ((await canRead()) === true) {
3570
- return ctx.findings.backend(this.id, ctx.entityName, "an invite grants access before it is accepted", `inviting ${delegate.id} made ${ctx.entityName} ${id} readable immediately. The accept ` +
3680
+ return ctx.findings.backend(this.id, ctx.entityName, "an invite grants access before it is accepted", `inviting ${delegate.id} made ${subject} readable immediately. The accept ` +
3571
3681
  "step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
3572
3682
  }
3573
3683
  if (spec.tokenFrom === "outOfBand") {
@@ -3622,7 +3732,7 @@ const inviteGrantsThenRevokes = {
3622
3732
  if (issued !== undefined)
3623
3733
  delegate.adoptCredential?.(issued);
3624
3734
  if ((await canRead()) === false) {
3625
- return ctx.findings.backend(this.id, ctx.entityName, "accepting an invite does not grant access", `${delegate.id} accepted an invite to ${ctx.entityName} ${id} and still cannot read it. ` +
3735
+ return ctx.findings.backend(this.id, ctx.entityName, "accepting an invite does not grant access", `${delegate.id} accepted an invite and still cannot read ${subject}. ` +
3626
3736
  "The invite flow completed; the grant did not.", [invited, accepted]);
3627
3737
  }
3628
3738
  if (grantId === undefined) {
@@ -3635,7 +3745,7 @@ const inviteGrantsThenRevokes = {
3635
3745
  return ctx.findings.unresolved(this.id, ctx.entityName, `revoke returned ${revoked.status}`);
3636
3746
  }
3637
3747
  if ((await canRead()) === true) {
3638
- ctx.findings.backend(this.id, ctx.entityName, "revoking an invite does not remove access", `${delegate.id} can still read ${ctx.entityName} ${id} after the grant was revoked. ` +
3748
+ ctx.findings.backend(this.id, ctx.entityName, "revoking an invite does not remove access", `${delegate.id} can still read ${subject} after the grant was revoked. ` +
3639
3749
  "A share that cannot be taken back is a standing leak.", [invited, accepted, revoked]);
3640
3750
  }
3641
3751
  return ASSERTED;
@@ -4653,6 +4763,25 @@ function writableStringFields(ctx, record) {
4653
4763
  }
4654
4764
  return out;
4655
4765
  }
4766
+ /**
4767
+ * The instance an action binds from, as it is now. An earlier action — or this one's x-before —
4768
+ * may have changed it since it was seeded: a reorder bound to every column the seed held refuses
4769
+ * the column added since. The fresh read leads; the seeded records stay behind it for whatever the
4770
+ * read does not hold.
4771
+ */
4772
+ async function currentRecords(ctx, scope) {
4773
+ const readOp = ctx.readOp;
4774
+ if (readOp === undefined || !canFillPath(readOp.path, scope))
4775
+ return ctx.records;
4776
+ const read = await ctx.client.get(fillPath(readOp.path, scope), {
4777
+ headers: ctx.auth(),
4778
+ operationId: readOp.operationId,
4779
+ });
4780
+ const body = read.responseBody;
4781
+ if (read.status >= 300 || body === null || typeof body !== "object" || Array.isArray(body))
4782
+ return ctx.records;
4783
+ return [body, ...ctx.records];
4784
+ }
4656
4785
  const declaredEffectsOccur = {
4657
4786
  plan: (ctx) => when(ctx.effectOps.length > 0),
4658
4787
  /* An effect is counted by reading whole sets before and after, across pages. */
@@ -4699,8 +4828,17 @@ const declaredEffectsOccur = {
4699
4828
  return;
4700
4829
  }
4701
4830
  beforeBody = beforeInvoked.responseBody;
4831
+ /* A prelude that creates is the run's to remove. One through the entity's own create is
4832
+ * already tracked; an invite is not, since an invite POST is never an entity's create. */
4833
+ const made = beforeOp.action === "create" ? ctx.model.entities.get(beforeOp.entity ?? "") : undefined;
4834
+ if (made !== undefined && made.delete !== undefined) {
4835
+ const id = findCreatedId(beforeBody, createdIdKeys(made.name, made.identity));
4836
+ if (id !== undefined)
4837
+ ctx.recordCreated?.(made.name, id, { ...beforeScope });
4838
+ }
4702
4839
  }
4703
- scope = bindActionScope(op.pathParams, ctx.records, scope, actionBind, beforeBody);
4840
+ const current = await currentRecords(ctx, scope);
4841
+ scope = bindActionScope(op.pathParams, current, scope, actionBind, beforeBody);
4704
4842
  if (!canFillPath(op.path, scope)) {
4705
4843
  const missing = op.pathParams.filter((name) => scope[name] === undefined);
4706
4844
  ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not be invoked`, `missing ${missing.map((name) => `{${name}}`).join(", ")}`, fixture);
@@ -4715,7 +4853,7 @@ const declaredEffectsOccur = {
4715
4853
  continue;
4716
4854
  befores.set(effect.entity, await observe(ctx, listOp, scope));
4717
4855
  }
4718
- const prepared = applyActionBind(actionBind, ctx.records, beforeBody, op.hasRequestBody ? bodyForOp(ctx, op) : undefined);
4856
+ const prepared = applyActionBind(actionBind, current, beforeBody, op.hasRequestBody ? bodyForOp(ctx, op) : undefined);
4719
4857
  const body = prepared.body;
4720
4858
  const invoked = await ctx.client.request(op.method, fillPath(op.path, scope), {
4721
4859
  headers: ctx.auth(),
@@ -5333,7 +5471,13 @@ const declaredRateLimitHonoured = {
5333
5471
  subjects: (entity, model) => ownOps(entity, model, (op) => op.rateLimit !== null && op.rateLimit.rps !== null),
5334
5472
  async run(ctx) {
5335
5473
  const byCategory = new Map();
5474
+ /* Only a route something actually sent was paced and judged. Claiming the rest would mark
5475
+ * them graded with no request behind them, and the contract tier never calls a graded one. */
5476
+ const observed = new Set();
5336
5477
  for (const exchange of ctx.client.exchangesForEntity(ctx.entityName)) {
5478
+ const op = exchange.operationId === undefined ? undefined : ctx.model.byOperationId.get(exchange.operationId);
5479
+ if (op?.rateLimit?.rps !== null && op?.rateLimit?.rps !== undefined)
5480
+ observed.add(op.operationId);
5337
5481
  if (exchange.status !== 429)
5338
5482
  continue;
5339
5483
  if (exchange.rateLimitSource !== "tag" || exchange.rateLimitHadRoom !== true)
@@ -5348,6 +5492,7 @@ const declaredRateLimitHonoured = {
5348
5492
  `the declared rate is wrong, or the backend enforces a stricter one than "${category}" ` +
5349
5493
  "documents.", exchanges.slice(0, 3));
5350
5494
  }
5495
+ ctx.judged?.([...observed]);
5351
5496
  return ASSERTED;
5352
5497
  },
5353
5498
  };
@@ -6312,11 +6457,21 @@ const sortDefaultOrderApplied = {
6312
6457
  subjects: subjectsOf.list,
6313
6458
  async run(ctx, { declared }) {
6314
6459
  const limit = pageSize(ctx);
6315
- const implicit = await collectSet(ctx, limit);
6316
- const explicit = await collectSet(ctx, limit, {}, declared);
6460
+ let implicit = await collectSet(ctx, limit);
6461
+ let explicit = await collectSet(ctx, limit, {}, declared);
6317
6462
  if (implicit === null || explicit === null) {
6318
6463
  return ctx.findings.unresolved(this.id, ctx.entityName, "default-order walk was rejected");
6319
6464
  }
6465
+ const settled = await settlePair(ctx.identity, implicit, explicit, {
6466
+ first: () => collectSet(ctx, limit, {}, undefined, true),
6467
+ second: () => collectSet(ctx, limit, {}, declared, true),
6468
+ });
6469
+ if (settled === "moving")
6470
+ return ctx.findings.unresolved(this.id, ctx.entityName, MOVING);
6471
+ if (settled === null)
6472
+ return ctx.findings.unresolved(this.id, ctx.entityName, "default-order walk was rejected");
6473
+ implicit = settled.first;
6474
+ explicit = settled.second;
6320
6475
  if (!implicit.complete || !explicit.complete) {
6321
6476
  return ctx.findings.unresolved(this.id, ctx.entityName, "the walk is incomplete, so default order cannot be compared");
6322
6477
  }
@@ -6343,10 +6498,20 @@ const sortStableTiebreak = {
6343
6498
  async run(ctx, { primary, tiebreak }) {
6344
6499
  const order = sortTerm(conv(ctx), primary, "asc");
6345
6500
  /* Two independent reads: a shared one would compare a walk with itself. */
6346
- const first = await readSet(ctx, {}, { fresh: true, order });
6347
- const second = await readSet(ctx, {}, { fresh: true, order });
6348
- if (first.status !== "ok" || second.status !== "ok")
6501
+ const read = async () => {
6502
+ const result = await readSet(ctx, {}, { fresh: true, order });
6503
+ return result.status === "ok" ? result : null;
6504
+ };
6505
+ const firstRead = await read();
6506
+ const secondRead = await read();
6507
+ if (firstRead === null || secondRead === null)
6508
+ return ctx.findings.unresolved(this.id, ctx.entityName, "repeat sort was rejected");
6509
+ const settled = await settlePair(ctx.identity, firstRead, secondRead, { first: read, second: read });
6510
+ if (settled === "moving")
6511
+ return ctx.findings.unresolved(this.id, ctx.entityName, MOVING);
6512
+ if (settled === null)
6349
6513
  return ctx.findings.unresolved(this.id, ctx.entityName, "repeat sort was rejected");
6514
+ const { first, second } = settled;
6350
6515
  if (ids(first.items, ctx.identity).join(",") !== ids(second.items, ctx.identity).join(",")) {
6351
6516
  return ctx.findings.backend(this.id, ctx.entityName, "the same order is not deterministic across two walks", `order=${order} (stableTiebreak=${tiebreak}) returned two different sequences.`, [first.last.exchange, second.last.exchange]);
6352
6517
  }
@@ -7126,6 +7291,7 @@ export const CHECKS = [
7126
7291
  rootReadCheck,
7127
7292
  disposableCheck,
7128
7293
  publicGetCheck,
7294
+ contractCallCheck,
7129
7295
  /* declared side effects and async lifecycles, last: both invoke operations that change the
7130
7296
  * world, and both are meaningless if the read surface above is already known broken */
7131
7297
  declaredEffectsOccur,