@affiant/core 0.1.0-alpha.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 (96) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +354 -0
  3. package/dist/context.d.ts +136 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/context.js +30 -0
  6. package/dist/context.js.map +1 -0
  7. package/dist/docket/entry.d.ts +421 -0
  8. package/dist/docket/entry.d.ts.map +1 -0
  9. package/dist/docket/entry.js +155 -0
  10. package/dist/docket/entry.js.map +1 -0
  11. package/dist/docket/expiry.d.ts +82 -0
  12. package/dist/docket/expiry.d.ts.map +1 -0
  13. package/dist/docket/expiry.js +106 -0
  14. package/dist/docket/expiry.js.map +1 -0
  15. package/dist/docket/memory.d.ts +163 -0
  16. package/dist/docket/memory.d.ts.map +1 -0
  17. package/dist/docket/memory.js +528 -0
  18. package/dist/docket/memory.js.map +1 -0
  19. package/dist/docket/store.d.ts +387 -0
  20. package/dist/docket/store.d.ts.map +1 -0
  21. package/dist/docket/store.js +51 -0
  22. package/dist/docket/store.js.map +1 -0
  23. package/dist/errors.d.ts +153 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +164 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/gate/coverage.d.ts +152 -0
  28. package/dist/gate/coverage.d.ts.map +1 -0
  29. package/dist/gate/coverage.js +114 -0
  30. package/dist/gate/coverage.js.map +1 -0
  31. package/dist/gate/decide.d.ts +207 -0
  32. package/dist/gate/decide.d.ts.map +1 -0
  33. package/dist/gate/decide.js +559 -0
  34. package/dist/gate/decide.js.map +1 -0
  35. package/dist/gate/gate.d.ts +212 -0
  36. package/dist/gate/gate.d.ts.map +1 -0
  37. package/dist/gate/gate.js +175 -0
  38. package/dist/gate/gate.js.map +1 -0
  39. package/dist/gate/pipeline.d.ts +285 -0
  40. package/dist/gate/pipeline.d.ts.map +1 -0
  41. package/dist/gate/pipeline.js +515 -0
  42. package/dist/gate/pipeline.js.map +1 -0
  43. package/dist/gate/policy.d.ts +272 -0
  44. package/dist/gate/policy.d.ts.map +1 -0
  45. package/dist/gate/policy.js +396 -0
  46. package/dist/gate/policy.js.map +1 -0
  47. package/dist/gate/wrap.d.ts +107 -0
  48. package/dist/gate/wrap.d.ts.map +1 -0
  49. package/dist/gate/wrap.js +164 -0
  50. package/dist/gate/wrap.js.map +1 -0
  51. package/dist/index.d.ts +95 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +111 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/model/affidavit.d.ts +354 -0
  56. package/dist/model/affidavit.d.ts.map +1 -0
  57. package/dist/model/affidavit.js +417 -0
  58. package/dist/model/affidavit.js.map +1 -0
  59. package/dist/model/amendments.d.ts +160 -0
  60. package/dist/model/amendments.d.ts.map +1 -0
  61. package/dist/model/amendments.js +183 -0
  62. package/dist/model/amendments.js.map +1 -0
  63. package/dist/model/canonical.d.ts +311 -0
  64. package/dist/model/canonical.d.ts.map +1 -0
  65. package/dist/model/canonical.js +665 -0
  66. package/dist/model/canonical.js.map +1 -0
  67. package/dist/model/money.d.ts +127 -0
  68. package/dist/model/money.d.ts.map +1 -0
  69. package/dist/model/money.js +177 -0
  70. package/dist/model/money.js.map +1 -0
  71. package/dist/model/provenance.d.ts +315 -0
  72. package/dist/model/provenance.d.ts.map +1 -0
  73. package/dist/model/provenance.js +223 -0
  74. package/dist/model/provenance.js.map +1 -0
  75. package/dist/ports.d.ts +269 -0
  76. package/dist/ports.d.ts.map +1 -0
  77. package/dist/ports.js +34 -0
  78. package/dist/ports.js.map +1 -0
  79. package/dist/store-memory.d.ts +21 -0
  80. package/dist/store-memory.d.ts.map +1 -0
  81. package/dist/store-memory.js +20 -0
  82. package/dist/store-memory.js.map +1 -0
  83. package/dist/telemetry-keys.d.ts +65 -0
  84. package/dist/telemetry-keys.d.ts.map +1 -0
  85. package/dist/telemetry-keys.js +72 -0
  86. package/dist/telemetry-keys.js.map +1 -0
  87. package/dist/telemetry.d.ts +77 -0
  88. package/dist/telemetry.d.ts.map +1 -0
  89. package/dist/telemetry.js +43 -0
  90. package/dist/telemetry.js.map +1 -0
  91. package/dist/testing.d.ts +574 -0
  92. package/dist/testing.d.ts.map +1 -0
  93. package/dist/testing.js +1291 -0
  94. package/dist/testing.js.map +1 -0
  95. package/package.json +75 -0
  96. package/telemetry-keys.json +92 -0
@@ -0,0 +1,1291 @@
1
+ /**
2
+ * `@affiant/core/testing` — the declarative fixture format, the runner that
3
+ * executes it against a real gate, and the stub ports a fixture is wired from.
4
+ *
5
+ * ## What a fixture is
6
+ *
7
+ * One JSON document describing **a wiring, a sequence of acts, and what must be
8
+ * true afterwards**:
9
+ *
10
+ * ```jsonc
11
+ * {
12
+ * "id": "gate/standing-order-by-the-book",
13
+ * "rules": ["GT-5", "AZ-1"],
14
+ * "title": "A Standing Order with no threshold fires on the verdict alone",
15
+ * "given": {
16
+ * "clock": "2026-09-04T09:00:00.000Z",
17
+ * "gate": { "defaultTtlMs": 1800000, "authorization": { "allow": ["*"] } },
18
+ * "ctx": { "tenantId": "tenant-a", "conversationId": "conv-1", "channel": "chat" },
19
+ * "prior": [],
20
+ * "step": { "kind": "file", "toolName": "capture", "operation": { … } }
21
+ * },
22
+ * "expect": { "entry": { "status": "approved" } }
23
+ * }
24
+ * ```
25
+ *
26
+ * ## The runner is strict about the document, not only about the gate
27
+ *
28
+ * A fixture is checked against the format **before** it is run, and a fixture that
29
+ * fails that check is not run at all: an unknown key at any level fails the fixture
30
+ * naming its path, and an `expect` clause that states no fact — `{}`,
31
+ * `{ entry: {} }`, `{ telemetryAbsent: [] }` — fails as vacuous. Both are the same
32
+ * defect wearing different clothes: a fixture that asserts nothing, or asserts it in
33
+ * a key nothing reads, is passed by every implementation including a broken one, and
34
+ * the rulebook's negative-oracle clause exists to forbid exactly that. Since this
35
+ * runner is what a conformance driver executes against a second implementation and
36
+ * what its parity manifest is derived from, a silently-ignored key would be a rule
37
+ * silently unchecked in both implementations at once.
38
+ *
39
+ * **One shape for every rule.** The gate's whole surface is reachable from
40
+ * {@link FixtureStep} — `wrap-execute`, `file`, `decide`, `resubmit`,
41
+ * `markExecuted`, `expireDue`, `get`, `rehydrate` — so a fixture about a decision
42
+ * and a fixture about a filing differ in their steps, not in their format. A single
43
+ * format is what lets one runner serve three consumers: this package's own suites,
44
+ * a host checking its ports against the reference behaviour, and the conformance
45
+ * driver that will run the same documents against a second implementation.
46
+ *
47
+ * ## What the expectations are, and what they are not
48
+ *
49
+ * Every expectation is a **partial** matcher: a fixture states the facts its rule is
50
+ * about and says nothing about the rest, so an unrelated addition to `DocketEntry`
51
+ * does not break thirty documents. What a fixture may not do is state something the
52
+ * implementation computed for it — every value in a fixture is authored, and the
53
+ * generator that mirrors these files into a TypeScript module computes nothing.
54
+ *
55
+ * ## Reading the results
56
+ *
57
+ * {@link runFixture} never throws for a failed expectation: it returns a
58
+ * {@link FixtureResult} carrying every failure it found, each with the path it was
59
+ * found at and the two values. A suite turns that into one assertion; a conformance
60
+ * driver turns the same structure into a parity manifest naming what an
61
+ * implementation does not yet pass. A runner that threw on the first mismatch could
62
+ * do neither.
63
+ *
64
+ * @packageDocumentation
65
+ */
66
+ import { readStatus } from "./docket/entry.js";
67
+ import { InMemoryDocketStore, InMemorySessionStore } from "./docket/memory.js";
68
+ import { isAffiantError } from "./errors.js";
69
+ import { createGate } from "./gate/gate.js";
70
+ import { canonicalHash, canonicalHashEntry, swornAffidavitOf } from "./model/canonical.js";
71
+ import { chainOf, mintTag } from "./model/provenance.js";
72
+ import { TELEMETRY_KEYS } from "./telemetry-keys.js";
73
+ // ---------------------------------------------------------------------------
74
+ // The fixture schema
75
+ // ---------------------------------------------------------------------------
76
+ /**
77
+ * Every key the fixture format defines, level by level.
78
+ *
79
+ * **Why a runtime schema when the format is already an interface.** A fixture is a
80
+ * JSON document read off disk or off a wire; the interfaces above type the *authors*
81
+ * of one and not the documents themselves. Without this table a misspelled
82
+ * expectation key — `statuz` for `status`, `valu` for `value` — is a key the checker
83
+ * never reads, so the fixture asserts nothing about that fact and passes. That is
84
+ * the shape the rulebook's negative-oracle clause exists to forbid: *a fixture a
85
+ * broken implementation passes is not a test*. This is the oracle a conformance
86
+ * driver runs against a second implementation and the parity manifest is derived
87
+ * from, so a silently-ignored key is a rule silently unchecked in both.
88
+ *
89
+ * The lists are exhaustive and the check is exact: an unknown key anywhere in a
90
+ * fixture fails that fixture, naming the path. Adding a clause to the format means
91
+ * adding it here in the same commit — which is the point.
92
+ */
93
+ const FIXTURE_KEYS = {
94
+ fixture: ["id", "rules", "title", "given", "expect"],
95
+ given: ["gate", "store", "clock", "ctx", "prior", "step"],
96
+ gate: [
97
+ "policies",
98
+ "riskScorer",
99
+ "interceptors",
100
+ "defaultTtlMs",
101
+ "authorization",
102
+ "inference",
103
+ "entities",
104
+ "uncovered",
105
+ "sessions",
106
+ ],
107
+ ctx: ["tenantId", "conversationId", "channel", "principal", "utterance", "messageId"],
108
+ step: ["kind", "as", "at", "principal", "tenantId", "conversationId", "entry", "refusal"],
109
+ expect: [
110
+ "error",
111
+ "entry",
112
+ "card",
113
+ "superseded",
114
+ "telemetry",
115
+ "telemetryAbsent",
116
+ "store",
117
+ "expired",
118
+ "page",
119
+ "found",
120
+ "canonicalHash",
121
+ ],
122
+ error: ["code", "messageContains"],
123
+ entry: [
124
+ "status",
125
+ "execution",
126
+ "executionDetail",
127
+ "requirement",
128
+ "blocked",
129
+ "toolName",
130
+ "channel",
131
+ "tenantId",
132
+ "conversationId",
133
+ "attestation",
134
+ "decision",
135
+ "amendments",
136
+ "preservedAmendments",
137
+ "lineage",
138
+ "expiresAtOffsetMs",
139
+ "affidavit",
140
+ "amendedAffidavit",
141
+ "canonicalDiffersFromProposal",
142
+ ],
143
+ lineage: ["supersedes", "supersededBy"],
144
+ affidavit: [
145
+ "operationType",
146
+ "entityType",
147
+ "entityId",
148
+ "aggregateConfidence",
149
+ "populatedConfidence",
150
+ "emptyFieldCount",
151
+ "fields",
152
+ ],
153
+ affidavitField: [
154
+ "name",
155
+ "value",
156
+ "previousValue",
157
+ "kind",
158
+ "isMandatory",
159
+ "source",
160
+ "bound",
161
+ "bindingKind",
162
+ "confidence",
163
+ "priorSources",
164
+ ],
165
+ card: [
166
+ "requiresConfirmation",
167
+ "warningsContain",
168
+ "priorAmendments",
169
+ "blocked",
170
+ "protocolVersion",
171
+ "aggregateConfidence",
172
+ "populatedConfidence",
173
+ "emptyFieldCount",
174
+ "fields",
175
+ "presentation",
176
+ ],
177
+ cardField: ["name", "kind", "value", "isMandatory"],
178
+ cardPresentation: ["name", "kind", "allowedValues", "pattern"],
179
+ store: ["count", "pending", "approvedUnexecuted"],
180
+ expired: ["count", "more"],
181
+ page: ["count", "more", "statuses"],
182
+ };
183
+ /** The keys each kind of step adds to {@link FIXTURE_KEYS.step}. */
184
+ const STEP_KEYS = {
185
+ "wrap-execute": ["tool", "args"],
186
+ file: ["toolName", "operation", "schema", "preparedFields", "args", "operationLabel"],
187
+ decide: ["decision"],
188
+ resubmit: [],
189
+ markExecuted: ["outcome", "detail"],
190
+ expireDue: ["limit", "scope"],
191
+ get: [],
192
+ rehydrate: ["page", "scope"],
193
+ };
194
+ /** The clauses {@link wireUpRefusal} can answer. Everything else is unanswerable there. */
195
+ const WIRE_UP_ANSWERABLE = ["error", "telemetry", "telemetryAbsent", "store"];
196
+ /** Whether `value` is a plain object a key check applies to. */
197
+ function isRecord(value) {
198
+ return typeof value === "object" && value !== null && !Array.isArray(value);
199
+ }
200
+ /** Push a failure for every key of `value` the format does not define. */
201
+ function checkKeys(value, allowed, path, failures) {
202
+ if (!isRecord(value))
203
+ return;
204
+ for (const key of Object.keys(value)) {
205
+ if (allowed.includes(key))
206
+ continue;
207
+ failures.push({
208
+ at: `${path}.${key}`,
209
+ expected: `one of: ${[...allowed].sort().join(", ")}`,
210
+ actual: "an unknown key",
211
+ });
212
+ }
213
+ }
214
+ /** Push a failure for every key `required` names that `value` does not carry. */
215
+ function checkRequired(value, required, path, failures) {
216
+ if (!isRecord(value)) {
217
+ failures.push({ at: path, expected: "an object", actual: value });
218
+ return;
219
+ }
220
+ for (const key of required) {
221
+ if (value[key] === undefined) {
222
+ failures.push({ at: `${path}.${key}`, expected: "stated", actual: "absent" });
223
+ }
224
+ }
225
+ }
226
+ /** Every telemetry key the registry names (TL-1). A fixture may not assert on another. */
227
+ const REGISTERED_TELEMETRY = TELEMETRY_KEYS.map((entry) => entry.key);
228
+ /** Check one telemetry clause: an array of keys the registry knows. */
229
+ function checkTelemetryKeys(value, path, failures) {
230
+ if (value === undefined || value === null)
231
+ return;
232
+ if (!Array.isArray(value)) {
233
+ failures.push({ at: path, expected: "an array of telemetry keys", actual: value });
234
+ return;
235
+ }
236
+ for (const [index, key] of value.entries()) {
237
+ if (typeof key !== "string" || !REGISTERED_TELEMETRY.includes(key)) {
238
+ failures.push({
239
+ at: `${path}[${String(index)}]`,
240
+ expected: `one of: ${[...REGISTERED_TELEMETRY].sort().join(", ")}`,
241
+ actual: key,
242
+ });
243
+ }
244
+ }
245
+ }
246
+ /** Check a row matcher and everything under it. */
247
+ function checkRowKeys(value, path, failures) {
248
+ if (!isRecord(value))
249
+ return;
250
+ checkKeys(value, FIXTURE_KEYS.entry, path, failures);
251
+ checkKeys(value["lineage"], FIXTURE_KEYS.lineage, `${path}.lineage`, failures);
252
+ for (const which of ["affidavit", "amendedAffidavit"]) {
253
+ const affidavit = value[which];
254
+ if (!isRecord(affidavit))
255
+ continue;
256
+ checkKeys(affidavit, FIXTURE_KEYS.affidavit, `${path}.${which}`, failures);
257
+ const fields = affidavit["fields"];
258
+ if (!Array.isArray(fields))
259
+ continue;
260
+ for (const [index, field] of fields.entries()) {
261
+ checkKeys(field, FIXTURE_KEYS.affidavitField, `${path}.${which}.fields[${String(index)}]`, failures);
262
+ }
263
+ }
264
+ }
265
+ /**
266
+ * Check a fixture document against the format before running it.
267
+ *
268
+ * Two families of defect, both of which pass silently without this: a key the format
269
+ * does not define (which the checker never reads, so the fact goes unasserted), and
270
+ * an `expect` clause that states no fact at all (`{}`, `{ entry: {} }`,
271
+ * `{ telemetryAbsent: [] }` — each of which any implementation passes, including one
272
+ * that does nothing).
273
+ */
274
+ function validateFixture(fixture) {
275
+ const failures = [];
276
+ const document = fixture;
277
+ checkRequired(document, FIXTURE_KEYS.fixture, "fixture", failures);
278
+ checkKeys(document, FIXTURE_KEYS.fixture, "fixture", failures);
279
+ const given = document["given"];
280
+ if (isRecord(given)) {
281
+ checkRequired(given, ["gate", "clock", "ctx", "step"], "given", failures);
282
+ checkKeys(given, FIXTURE_KEYS.given, "given", failures);
283
+ checkKeys(given["gate"], FIXTURE_KEYS.gate, "given.gate", failures);
284
+ checkKeys(given["ctx"], FIXTURE_KEYS.ctx, "given.ctx", failures);
285
+ const prior = given["prior"];
286
+ if (Array.isArray(prior)) {
287
+ for (const [index, step] of prior.entries()) {
288
+ checkStepKeys(step, `given.prior[${String(index)}]`, failures);
289
+ }
290
+ }
291
+ checkStepKeys(given["step"], "given.step", failures);
292
+ }
293
+ const expectation = document["expect"];
294
+ if (isRecord(expectation)) {
295
+ checkKeys(expectation, FIXTURE_KEYS.expect, "expect", failures);
296
+ checkKeys(expectation["error"], FIXTURE_KEYS.error, "expect.error", failures);
297
+ checkRowKeys(expectation["entry"], "expect.entry", failures);
298
+ checkRowKeys(expectation["superseded"], "expect.superseded", failures);
299
+ checkKeys(expectation["store"], FIXTURE_KEYS.store, "expect.store", failures);
300
+ checkKeys(expectation["expired"], FIXTURE_KEYS.expired, "expect.expired", failures);
301
+ checkKeys(expectation["page"], FIXTURE_KEYS.page, "expect.page", failures);
302
+ checkTelemetryKeys(expectation["telemetry"], "expect.telemetry", failures);
303
+ checkTelemetryKeys(expectation["telemetryAbsent"], "expect.telemetryAbsent", failures);
304
+ const card = expectation["card"];
305
+ if (isRecord(card)) {
306
+ checkKeys(card, FIXTURE_KEYS.card, "expect.card", failures);
307
+ const fields = card["fields"];
308
+ if (Array.isArray(fields)) {
309
+ for (const [index, field] of fields.entries()) {
310
+ checkKeys(field, FIXTURE_KEYS.cardField, `expect.card.fields[${String(index)}]`, failures);
311
+ }
312
+ }
313
+ const presentation = card["presentation"];
314
+ if (Array.isArray(presentation)) {
315
+ for (const [index, hint] of presentation.entries()) {
316
+ checkKeys(hint, FIXTURE_KEYS.cardPresentation, `expect.card.presentation[${String(index)}]`, failures);
317
+ }
318
+ }
319
+ }
320
+ if (countAssertions(expectation) === 0) {
321
+ failures.push({
322
+ at: "expect",
323
+ expected: "at least one stated fact",
324
+ actual: "a fixture that asserts nothing",
325
+ });
326
+ }
327
+ }
328
+ return failures;
329
+ }
330
+ /** Check one step: the common keys plus the ones its `kind` adds. */
331
+ function checkStepKeys(step, path, failures) {
332
+ if (!isRecord(step)) {
333
+ failures.push({ at: path, expected: "a step object", actual: step });
334
+ return;
335
+ }
336
+ const kind = step["kind"];
337
+ if (typeof kind !== "string" || !Object.hasOwn(STEP_KEYS, kind)) {
338
+ failures.push({
339
+ at: `${path}.kind`,
340
+ expected: `one of: ${Object.keys(STEP_KEYS).sort().join(", ")}`,
341
+ actual: kind,
342
+ });
343
+ return;
344
+ }
345
+ const own = STEP_KEYS[kind];
346
+ checkKeys(step, [...FIXTURE_KEYS.step, ...own], path, failures);
347
+ }
348
+ /** How many leaf facts a clause states, counting only keys the format defines. */
349
+ function countStated(value, keys) {
350
+ if (!isRecord(value))
351
+ return 0;
352
+ return keys.filter((key) => value[key] !== undefined).length;
353
+ }
354
+ /** How many leaf facts an Affidavit matcher states. `null` states one: "there is none". */
355
+ function countAffidavit(value) {
356
+ if (value === null)
357
+ return 1;
358
+ if (!isRecord(value))
359
+ return 0;
360
+ let total = countStated(value, FIXTURE_KEYS.affidavit.filter((key) => key !== "fields"));
361
+ const fields = value["fields"];
362
+ if (Array.isArray(fields)) {
363
+ // Stating the list asserts the field names exactly (AF-1); each further key on a
364
+ // field is one more fact.
365
+ total += 1;
366
+ for (const field of fields) {
367
+ total += countStated(field, FIXTURE_KEYS.affidavitField.filter((key) => key !== "name"));
368
+ }
369
+ }
370
+ return total;
371
+ }
372
+ /** How many leaf facts a row matcher states. */
373
+ function countRow(value) {
374
+ if (!isRecord(value))
375
+ return 0;
376
+ const scalars = FIXTURE_KEYS.entry.filter((key) => key !== "lineage" && key !== "affidavit" && key !== "amendedAffidavit");
377
+ return (countStated(value, scalars) +
378
+ countStated(value["lineage"], FIXTURE_KEYS.lineage) +
379
+ countAffidavit(value["affidavit"]) +
380
+ (Object.hasOwn(value, "amendedAffidavit") ? countAffidavit(value["amendedAffidavit"]) : 0));
381
+ }
382
+ /** How many leaf facts a card matcher states. */
383
+ function countCard(value) {
384
+ if (!isRecord(value))
385
+ return 0;
386
+ let total = countStated(value, FIXTURE_KEYS.card.filter((key) => key !== "fields" && key !== "presentation" && key !== "warningsContain"));
387
+ const warnings = value["warningsContain"];
388
+ if (Array.isArray(warnings))
389
+ total += warnings.length;
390
+ const fields = value["fields"];
391
+ if (Array.isArray(fields)) {
392
+ total += 1;
393
+ for (const field of fields) {
394
+ total += countStated(field, FIXTURE_KEYS.cardField.filter((key) => key !== "name"));
395
+ }
396
+ }
397
+ const presentation = value["presentation"];
398
+ if (Array.isArray(presentation)) {
399
+ // Stating the list asserts the hinted names exactly; each further key on a hint
400
+ // is one more fact.
401
+ total += 1;
402
+ for (const hint of presentation) {
403
+ total += countStated(hint, FIXTURE_KEYS.cardPresentation.filter((key) => key !== "name"));
404
+ }
405
+ }
406
+ return total;
407
+ }
408
+ /**
409
+ * How many facts a fixture's `expect` clause actually states.
410
+ *
411
+ * Zero means the fixture asserts nothing an implementation could fail — which every
412
+ * implementation passes, including one that files nothing at all. `expect: {}`,
413
+ * `{ entry: {} }` and `{ telemetryAbsent: [] }` are the three shapes this catches;
414
+ * they are counted here rather than by tallying the comparisons a run performed,
415
+ * because a run also performs the card invariants that hold for every filing, and a
416
+ * vacuous fixture with a filing step would clear that bar without stating a thing.
417
+ */
418
+ function countAssertions(expectation) {
419
+ const arrayLength = (value) => (Array.isArray(value) ? value.length : 0);
420
+ return (countStated(expectation["error"], FIXTURE_KEYS.error) +
421
+ (expectation["found"] === undefined ? 0 : 1) +
422
+ (expectation["canonicalHash"] === undefined ? 0 : 1) +
423
+ arrayLength(expectation["telemetry"]) +
424
+ arrayLength(expectation["telemetryAbsent"]) +
425
+ countStated(expectation["store"], FIXTURE_KEYS.store) +
426
+ countStated(expectation["expired"], FIXTURE_KEYS.expired) +
427
+ countStated(expectation["page"], FIXTURE_KEYS.page) +
428
+ countRow(expectation["entry"]) +
429
+ countRow(expectation["superseded"]) +
430
+ countCard(expectation["card"]));
431
+ }
432
+ /** A {@link Clock} that reads `start` until a step moves it. */
433
+ export function fixedClock(start) {
434
+ let current = start;
435
+ return {
436
+ now: () => current,
437
+ set: (instant) => {
438
+ current = instant;
439
+ },
440
+ };
441
+ }
442
+ /**
443
+ * An {@link InferencePort} that reports exactly what the fixture scripted, for every
444
+ * turn.
445
+ *
446
+ * Scripted rather than computed: the gate's contract is that it asks the host for
447
+ * values and tags whatever it gets, so a fixture that also decided *how* the values
448
+ * were found would be testing a model the framework does not ship.
449
+ */
450
+ export function scriptedInference(fields) {
451
+ const scripted = {};
452
+ for (const [name, field] of Object.entries(fields ?? {})) {
453
+ scripted[name] = {
454
+ value: field.value,
455
+ confidence: field.confidence,
456
+ presence: field.presence,
457
+ utteranceSpan: field.utteranceSpan ?? null,
458
+ };
459
+ }
460
+ return {
461
+ async infer() {
462
+ return { fields: scripted };
463
+ },
464
+ };
465
+ }
466
+ /**
467
+ * A {@link ProjectionPort} reading the fixture's entity table (AF-3).
468
+ *
469
+ * An entity the table does not name does not exist, and the port answers `null` —
470
+ * which the pipeline treats as "nothing to project", not as "every field was empty".
471
+ */
472
+ export function entityProjection(entities) {
473
+ const table = entities ?? {};
474
+ return {
475
+ async previousValues(op) {
476
+ if (op.kind !== "update")
477
+ return null;
478
+ const row = table[`${op.entityType}/${op.entityId}`];
479
+ if (row === undefined)
480
+ return null;
481
+ const previous = {};
482
+ for (const name of op.fields) {
483
+ if (Object.hasOwn(row, name))
484
+ previous[name] = row[name];
485
+ }
486
+ return previous;
487
+ },
488
+ };
489
+ }
490
+ /**
491
+ * An {@link AuthorizationPort} over an allowlist of principal ids (AZ-2).
492
+ *
493
+ * `"*"` admits everyone. A port configured to throw is the AZ-2 case that matters
494
+ * most: the gate must read a port that fell over as a refusal, never as an approval.
495
+ */
496
+ export function allowlistAuthorization(config) {
497
+ const allowed = new Set(config.allow);
498
+ return {
499
+ async mayDecide(principal) {
500
+ if (config.throws === true)
501
+ throw new Error("the host's directory is unavailable");
502
+ return allowed.has("*") || allowed.has(principal.id);
503
+ },
504
+ };
505
+ }
506
+ /** A {@link RiskScorer} that always returns `score` (GT-5). */
507
+ export function fixedRiskScorer(score) {
508
+ return {
509
+ async score() {
510
+ return score;
511
+ },
512
+ };
513
+ }
514
+ /**
515
+ * Run `fixture` against a real gate and report what it did.
516
+ *
517
+ * Never throws for a failed expectation — see the module note. It **does** propagate
518
+ * a `RangeError` or a `TypeError`, because those are programming errors in the
519
+ * fixture or in a port, not behaviours a rule is about, and swallowing them into a
520
+ * failure list would hide a broken document behind a red test.
521
+ */
522
+ export async function runFixture(fixture, ports = {}) {
523
+ // The document before the gate. A fixture the format does not recognise, or one
524
+ // that states no fact, is not run at all: running it would report a pass, and a
525
+ // pass is the one answer it must never give.
526
+ const structural = validateFixture(fixture);
527
+ if (structural.length > 0) {
528
+ return {
529
+ id: fixture.id,
530
+ rules: fixture.rules,
531
+ title: fixture.title,
532
+ pass: false,
533
+ failures: structural,
534
+ };
535
+ }
536
+ const failures = [];
537
+ const given = fixture.given;
538
+ const clock = (ports.clock ?? ((f) => fixedClock(f.given.clock)))(fixture);
539
+ const store = (ports.store ?? ((c) => new InMemoryDocketStore({ clock: c })))(clock);
540
+ const events = [];
541
+ const telemetry = {
542
+ emit(event) {
543
+ events.push(event);
544
+ },
545
+ };
546
+ // A wiring the gate refuses is a rule (CV-1), so it has to be expressible as a
547
+ // fixture: the refusal is reported exactly as a refusal from the step would be,
548
+ // and nothing after it runs, because there is no gate to run it on.
549
+ let gate;
550
+ try {
551
+ gate = buildGate(fixture, { clock, store, telemetry, ports });
552
+ }
553
+ catch (error) {
554
+ if (!isAffiantError(error))
555
+ throw error;
556
+ return wireUpRefusal(fixture, error.code, error.message, events);
557
+ }
558
+ const labelled = new Map();
559
+ let lastFiled = null;
560
+ const supersededIds = [];
561
+ const runOne = async (step, where) => {
562
+ if (step.at !== undefined)
563
+ clock.set(step.at);
564
+ const target = step.entry === undefined ? lastFiled : (labelled.get(step.entry) ?? step.entry);
565
+ const outcome = await performStep(step, {
566
+ gate,
567
+ store,
568
+ fixture,
569
+ clock,
570
+ target,
571
+ });
572
+ if (outcome.filed !== null) {
573
+ lastFiled = outcome.filed.entry.entryId;
574
+ if (step.as !== undefined)
575
+ labelled.set(step.as, lastFiled);
576
+ const supersedes = outcome.filed.entry.lineage.supersedes;
577
+ if (supersedes !== null)
578
+ supersededIds.push(supersedes);
579
+ }
580
+ else if (outcome.entryId !== null && step.as !== undefined) {
581
+ labelled.set(step.as, outcome.entryId);
582
+ }
583
+ // A declared refusal is compared wherever it is declared — on a prior step and
584
+ // on the step under test alike. Skipping the final one let a fixture claim its
585
+ // own act was refused and pass when it was not; the step under test may still
586
+ // leave `refusal` off and state its refusal in `expect.error`, which is where
587
+ // the format asks for it and which is compared below.
588
+ if (step.refusal !== undefined || where !== "step") {
589
+ const expectedRefusal = step.refusal ?? null;
590
+ if (expectedRefusal !== outcome.code) {
591
+ failures.push({ at: `${where}.refusal`, expected: expectedRefusal, actual: outcome.code });
592
+ }
593
+ }
594
+ return outcome;
595
+ };
596
+ const priors = given.prior ?? [];
597
+ for (const [index, step] of priors.entries()) {
598
+ await runOne(step, `prior[${String(index)}]`);
599
+ }
600
+ const final = await runOne(given.step, "step");
601
+ // ---- the refusal (or the absence of one) --------------------------------
602
+ const expectedError = fixture.expect.error ?? null;
603
+ if (expectedError === null) {
604
+ if (final.code !== null) {
605
+ failures.push({ at: "error", expected: null, actual: `${final.code}: ${final.message}` });
606
+ }
607
+ }
608
+ else {
609
+ if (final.code !== expectedError.code) {
610
+ failures.push({
611
+ at: "error.code",
612
+ expected: expectedError.code,
613
+ actual: final.code === null ? "no refusal" : `${final.code}: ${final.message}`,
614
+ });
615
+ }
616
+ const phrase = expectedError.messageContains;
617
+ if (phrase !== undefined && !(final.message ?? "").includes(phrase)) {
618
+ failures.push({ at: "error.message", expected: phrase, actual: final.message });
619
+ }
620
+ }
621
+ // ---- the row ------------------------------------------------------------
622
+ const scope = { tenantId: given.ctx.tenantId };
623
+ const targetId = final.filed?.entry.entryId ?? final.entryId ?? lastFiled;
624
+ const row = final.read ?? (targetId === null ? null : await store.get(targetId, scope));
625
+ if (fixture.expect.found !== undefined) {
626
+ if ((row !== null) !== fixture.expect.found) {
627
+ failures.push({ at: "found", expected: fixture.expect.found, actual: row !== null });
628
+ }
629
+ }
630
+ const entryExpectation = fixture.expect.entry ?? null;
631
+ if (entryExpectation !== null) {
632
+ if (row === null) {
633
+ failures.push({ at: "entry", expected: "a Docket row", actual: null });
634
+ }
635
+ else {
636
+ await checkEntry(row, entryExpectation, clock.now(), failures, "entry");
637
+ }
638
+ }
639
+ // SR-1: the exact bytes a host's execution grant binds to, through the package's
640
+ // own exported helper rather than re-derived here — an oracle that re-derived the
641
+ // binding could not catch an implementation whose exported helper disagreed with
642
+ // it, which is precisely the substitution SR-1 exists to prevent.
643
+ if (fixture.expect.canonicalHash !== undefined) {
644
+ if (row === null) {
645
+ failures.push({ at: "canonicalHash", expected: fixture.expect.canonicalHash, actual: null });
646
+ }
647
+ else {
648
+ compare("canonicalHash", fixture.expect.canonicalHash, await canonicalHashEntry(row), failures);
649
+ }
650
+ }
651
+ const supersededExpectation = fixture.expect.superseded ?? null;
652
+ if (supersededExpectation !== null) {
653
+ const supersededId = supersededIds[supersededIds.length - 1];
654
+ const superseded = supersededId === undefined ? null : await store.get(supersededId, scope);
655
+ if (superseded === null) {
656
+ failures.push({ at: "superseded", expected: "a superseded Docket row", actual: null });
657
+ }
658
+ else {
659
+ await checkEntry(superseded, supersededExpectation, clock.now(), failures, "superseded");
660
+ }
661
+ }
662
+ // ---- the card -----------------------------------------------------------
663
+ // Checked on every filing, stated or not: these hold for every card the gate ever
664
+ // produces, and a fixture that had to repeat them would be a fixture that stops
665
+ // being about its own rule.
666
+ if (final.filed !== null)
667
+ checkCardInvariants(final.filed, failures);
668
+ const cardExpectation = fixture.expect.card ?? null;
669
+ if (cardExpectation !== null) {
670
+ if (final.filed === null) {
671
+ failures.push({ at: "card", expected: "an Evidence Card", actual: null });
672
+ }
673
+ else {
674
+ checkCard(final.filed.card, cardExpectation, failures);
675
+ }
676
+ }
677
+ // ---- telemetry, the store, the sweep, the page --------------------------
678
+ const emitted = events.map((event) => String(event.key));
679
+ for (const key of fixture.expect.telemetry ?? []) {
680
+ if (!emitted.includes(key)) {
681
+ failures.push({ at: `telemetry.${key}`, expected: "emitted", actual: emitted });
682
+ }
683
+ }
684
+ for (const key of fixture.expect.telemetryAbsent ?? []) {
685
+ if (emitted.includes(key)) {
686
+ failures.push({ at: `telemetryAbsent.${key}`, expected: "never emitted", actual: emitted });
687
+ }
688
+ }
689
+ const storeExpectation = fixture.expect.store ?? null;
690
+ if (storeExpectation !== null) {
691
+ const all = await drain(store, scope);
692
+ compare("store.count", storeExpectation.count, all.length, failures);
693
+ const now = clock.now();
694
+ compare("store.pending", storeExpectation.pending, all.filter((entry) => readStatus(entry, now) === "pending").length, failures);
695
+ compare("store.approvedUnexecuted", storeExpectation.approvedUnexecuted, all.filter((entry) => readStatus(entry, now) === "approved" && entry.execution === "unexecuted").length, failures);
696
+ }
697
+ const expiredExpectation = fixture.expect.expired ?? null;
698
+ if (expiredExpectation !== null) {
699
+ compare("expired.count", expiredExpectation.count, final.expired?.expired.length, failures);
700
+ compare("expired.more", expiredExpectation.more, final.expired?.more, failures);
701
+ }
702
+ const pageExpectation = fixture.expect.page ?? null;
703
+ if (pageExpectation !== null) {
704
+ compare("page.count", pageExpectation.count, final.page?.items.length, failures);
705
+ compare("page.more", pageExpectation.more, final.page?.more, failures);
706
+ if (pageExpectation.statuses !== undefined) {
707
+ const now = clock.now();
708
+ compare("page.statuses", pageExpectation.statuses, (final.page?.items ?? []).map((entry) => readStatus(entry, now)), failures);
709
+ }
710
+ }
711
+ return {
712
+ id: fixture.id,
713
+ rules: fixture.rules,
714
+ title: fixture.title,
715
+ pass: failures.length === 0,
716
+ failures,
717
+ };
718
+ }
719
+ /**
720
+ * The result for a fixture whose *wiring* was refused (CV-1).
721
+ *
722
+ * Reported through the same `expect.error` clause a step's refusal is, because to a
723
+ * host the two are the same event with different timing — and the timing is the
724
+ * point of CV-1: a misconfiguration the framework can detect fails when the gate is
725
+ * built, not on the unlucky request that first reaches the broken branch.
726
+ */
727
+ function wireUpRefusal(fixture, code, message, events) {
728
+ const failures = [];
729
+ const expected = fixture.expect.error ?? null;
730
+ if (expected === null) {
731
+ failures.push({ at: "error", expected: null, actual: `${code}: ${message}` });
732
+ }
733
+ else {
734
+ compare("error.code", expected.code, code, failures);
735
+ if (expected.messageContains !== undefined && !message.includes(expected.messageContains)) {
736
+ failures.push({ at: "error.message", expected: expected.messageContains, actual: message });
737
+ }
738
+ }
739
+ const emitted = events.map((event) => String(event.key));
740
+ for (const key of fixture.expect.telemetry ?? []) {
741
+ if (!emitted.includes(key)) {
742
+ failures.push({ at: `telemetry.${key}`, expected: "emitted", actual: emitted });
743
+ }
744
+ }
745
+ for (const key of fixture.expect.telemetryAbsent ?? []) {
746
+ if (emitted.includes(key)) {
747
+ failures.push({ at: `telemetryAbsent.${key}`, expected: "never emitted", actual: emitted });
748
+ }
749
+ }
750
+ // Nothing was filed, so every count is zero and the store clause is answerable in
751
+ // full rather than in its first key only.
752
+ const storeExpectation = fixture.expect.store ?? null;
753
+ if (storeExpectation !== null) {
754
+ compare("store.count", storeExpectation.count, 0, failures);
755
+ compare("store.pending", storeExpectation.pending, 0, failures);
756
+ compare("store.approvedUnexecuted", storeExpectation.approvedUnexecuted, 0, failures);
757
+ }
758
+ // A clause this path cannot answer is a failure, not a silent pass: there is no
759
+ // row, no card and no page when the gate was never built, so a fixture that states
760
+ // one is a fixture stating something nobody will check (the same defect family as
761
+ // an unknown key).
762
+ for (const clause of Object.keys(fixture.expect)) {
763
+ if (!WIRE_UP_ANSWERABLE.includes(clause)) {
764
+ failures.push({
765
+ at: `${clause}`,
766
+ expected: `one of: ${[...WIRE_UP_ANSWERABLE].sort().join(", ")}`,
767
+ actual: "stated on a fixture whose wiring was refused, where nothing was filed",
768
+ });
769
+ }
770
+ }
771
+ return {
772
+ id: fixture.id,
773
+ rules: fixture.rules,
774
+ title: fixture.title,
775
+ pass: failures.length === 0,
776
+ failures,
777
+ };
778
+ }
779
+ /**
780
+ * Run every fixture and summarise.
781
+ *
782
+ * Takes the documents rather than a directory: this module runs inside workerd and
783
+ * under Bun as well as on Node, and none of those three read a directory the same
784
+ * way. A caller that has files loads them and passes them here.
785
+ */
786
+ export async function runFixtureDir(fixtures, ports = {}) {
787
+ const results = [];
788
+ for (const fixture of fixtures) {
789
+ results.push(await runFixture(fixture, ports));
790
+ }
791
+ const failed = results.filter((result) => !result.pass);
792
+ return {
793
+ total: results.length,
794
+ passed: results.length - failed.length,
795
+ failed: failed.length,
796
+ results,
797
+ failedIds: failed.map((result) => result.id),
798
+ };
799
+ }
800
+ // ---------------------------------------------------------------------------
801
+ // Wiring
802
+ // ---------------------------------------------------------------------------
803
+ /** Build the gate a fixture describes. Refusals at wire-up are the caller's to catch. */
804
+ function buildGate(fixture, deps) {
805
+ const given = fixture.given.gate;
806
+ const options = {
807
+ store: deps.store,
808
+ inference: (deps.ports.inference ?? ((f) => scriptedInference(f.given.gate.inference ?? null)))(fixture),
809
+ projection: (deps.ports.projection ?? ((f) => entityProjection(f.given.gate.entities)))(fixture),
810
+ authorization: (deps.ports.authorization ??
811
+ ((f) => allowlistAuthorization(f.given.gate.authorization)))(fixture),
812
+ policies: (given.policies ?? []).map(policyOf),
813
+ interceptors: (given.interceptors ?? []).map(interceptorOf),
814
+ clock: deps.clock,
815
+ telemetry: deps.telemetry,
816
+ defaultTtlMs: given.defaultTtlMs,
817
+ ...(given.sessions === false ? {} : { sessions: new InMemorySessionStore(deps.store) }),
818
+ ...(given.riskScorer === undefined || given.riskScorer === null
819
+ ? {}
820
+ : { riskScorer: fixedRiskScorer(given.riskScorer) }),
821
+ };
822
+ const gate = createGate(options);
823
+ for (const declaration of given.uncovered ?? []) {
824
+ gate.declareUncovered({ name: declaration.tool }, declaration.category);
825
+ }
826
+ return gate;
827
+ }
828
+ /** An {@link ApprovalPolicy} that always returns the fixture's verdict. */
829
+ function policyOf(fixture) {
830
+ return {
831
+ id: fixture.id,
832
+ version: fixture.version,
833
+ declaredInputs: fixture.declaredInputs ?? [],
834
+ ...(fixture.declaresThreshold === undefined
835
+ ? {}
836
+ : { declaresThreshold: fixture.declaresThreshold }),
837
+ ...(fixture.defaultTtlMs === undefined || fixture.defaultTtlMs === null
838
+ ? {}
839
+ : { defaultTtlMs: fixture.defaultTtlMs }),
840
+ async evaluate() {
841
+ return fixture.verdict;
842
+ },
843
+ };
844
+ }
845
+ /** A {@link FieldInterceptor} that resolves the fixture's fields for every operation. */
846
+ function interceptorOf(fixture) {
847
+ return {
848
+ name: fixture.name,
849
+ resolve() {
850
+ const resolved = {};
851
+ for (const [name, field] of Object.entries(fixture.fields)) {
852
+ resolved[name] = {
853
+ value: field.value,
854
+ source: field.source,
855
+ binding: field.binding,
856
+ confidence: field.confidence,
857
+ evidence: field.evidence ?? null,
858
+ };
859
+ }
860
+ return resolved;
861
+ },
862
+ };
863
+ }
864
+ /** The turn a step runs in (GT-2): explicit in every property, nothing ambient. */
865
+ function contextOf(fixture, step, at) {
866
+ const ctx = fixture.given.ctx;
867
+ const principal = step.principal === undefined ? (ctx.principal ?? null) : step.principal;
868
+ return {
869
+ tenantId: step.tenantId ?? ctx.tenantId,
870
+ conversationId: step.conversationId ?? ctx.conversationId,
871
+ channel: ctx.channel,
872
+ principal,
873
+ turn: {
874
+ utterance: ctx.utterance ?? "",
875
+ messageId: ctx.messageId ?? "msg-1",
876
+ at,
877
+ },
878
+ };
879
+ }
880
+ /** The prepared fields a `file` step describes, with their tags minted at `at`. */
881
+ function preparedOf(fields, at) {
882
+ return fields.map((field) => ({
883
+ name: field.name,
884
+ kind: field.kind,
885
+ value: field.value,
886
+ ...(field.isMandatory === undefined ? {} : { isMandatory: field.isMandatory }),
887
+ ...(field.provenance === undefined || field.provenance === null
888
+ ? {}
889
+ : {
890
+ provenance: chainOf(mintTag({
891
+ source: field.provenance.source,
892
+ confidence: field.provenance.confidence,
893
+ at,
894
+ note: field.provenance.note ?? null,
895
+ binding: field.provenance.binding ?? null,
896
+ })),
897
+ }),
898
+ }));
899
+ }
900
+ /** The field schema a tool or a `file` step declares. */
901
+ function schemaOf(entityType, fields) {
902
+ return {
903
+ entityType,
904
+ fields: fields.map((field) => ({
905
+ name: field.name,
906
+ kind: field.kind,
907
+ description: field.description ?? null,
908
+ required: field.required ?? false,
909
+ allowedValues: field.allowedValues ?? null,
910
+ pattern: field.pattern ?? null,
911
+ })),
912
+ };
913
+ }
914
+ /** The empty outcome every step starts from. */
915
+ const NOTHING = {
916
+ code: null,
917
+ message: null,
918
+ filed: null,
919
+ entryId: null,
920
+ read: null,
921
+ expired: null,
922
+ page: null,
923
+ };
924
+ /** Perform one step, turning a refusal into a code rather than letting it escape. */
925
+ async function performStep(step, deps) {
926
+ const at = deps.clock.now();
927
+ const ctx = contextOf(deps.fixture, step, at);
928
+ try {
929
+ switch (step.kind) {
930
+ case "wrap-execute": {
931
+ const tool = step.tool;
932
+ const entityId = tool.entityId === undefined ? null : tool.entityId;
933
+ const wrapped = deps.gate.wrap({
934
+ name: tool.name,
935
+ description: tool.description ?? tool.name,
936
+ inputSchema: schemaOf(tool.entityType, tool.fields),
937
+ writeCapable: tool.writeCapable ?? true,
938
+ ...(tool.omitExecute === true
939
+ ? {}
940
+ : {
941
+ execute: () => {
942
+ throw new Error("GT-6: the gate called a write tool's own execute");
943
+ },
944
+ }),
945
+ ...(tool.executedBy === undefined ? {} : { executedBy: tool.executedBy }),
946
+ ...(tool.hostedMcp === undefined ? {} : { hostedMcp: tool.hostedMcp }),
947
+ ...(tool.operationLabel === undefined ? {} : { operationLabel: tool.operationLabel }),
948
+ operation: (args) => entityId === null
949
+ ? {
950
+ kind: "create",
951
+ entityType: tool.entityType,
952
+ entityId: null,
953
+ fields: Object.keys(args),
954
+ }
955
+ : {
956
+ kind: "update",
957
+ entityType: tool.entityType,
958
+ entityId,
959
+ fields: Object.keys(args),
960
+ },
961
+ }, ctx);
962
+ const result = await wrapped.execute(step.args);
963
+ if (result.kind === "error") {
964
+ return { ...NOTHING, code: result.code, message: result.message };
965
+ }
966
+ if (result.kind === "read")
967
+ return NOTHING;
968
+ const filed = await deps.store.get(result.entryId, { tenantId: ctx.tenantId });
969
+ return {
970
+ ...NOTHING,
971
+ entryId: result.entryId,
972
+ read: filed,
973
+ filed: filed === null ? null : { entry: filed, created: true, card: result.card },
974
+ };
975
+ }
976
+ case "file": {
977
+ const proposal = {
978
+ operation: step.operation,
979
+ toolName: step.toolName,
980
+ ...(step.args === undefined ? {} : { args: step.args }),
981
+ ...(step.preparedFields === undefined || step.preparedFields === null
982
+ ? {}
983
+ : { fields: preparedOf(step.preparedFields, at) }),
984
+ ...(step.schema === undefined || step.schema === null
985
+ ? {}
986
+ : { schema: schemaOf(step.operation.entityType, step.schema) }),
987
+ ...(step.operationLabel === undefined || step.operationLabel === null
988
+ ? {}
989
+ : { operationLabel: step.operationLabel }),
990
+ };
991
+ const filed = await deps.gate.file(proposal, ctx);
992
+ return { ...NOTHING, filed, entryId: filed.entry.entryId };
993
+ }
994
+ case "decide": {
995
+ const id = requireTarget(deps.target, step.kind);
996
+ const stated = step.decision;
997
+ const decision = stated.kind === "reject"
998
+ ? { kind: "reject", reason: stated.reason ?? "" }
999
+ : {
1000
+ kind: "approve",
1001
+ ...(stated.amendments === undefined || stated.amendments === null
1002
+ ? {}
1003
+ : { amendments: stated.amendments }),
1004
+ ...(stated.reason === undefined || stated.reason === null
1005
+ ? {}
1006
+ : { reason: stated.reason }),
1007
+ };
1008
+ const entry = await deps.gate.decide(id, decision, ctx);
1009
+ return { ...NOTHING, entryId: id, read: entry };
1010
+ }
1011
+ case "resubmit": {
1012
+ const id = requireTarget(deps.target, step.kind);
1013
+ const filed = await deps.gate.resubmit(id, ctx);
1014
+ return { ...NOTHING, filed, entryId: filed.entry.entryId };
1015
+ }
1016
+ case "markExecuted": {
1017
+ const id = requireTarget(deps.target, step.kind);
1018
+ const entry = await deps.gate.markExecuted(id, step.outcome, step.detail ?? null, ctx);
1019
+ return { ...NOTHING, entryId: id, read: entry };
1020
+ }
1021
+ case "expireDue": {
1022
+ const scope = {
1023
+ tenantId: step.scope?.tenantId ?? ctx.tenantId,
1024
+ ...(step.scope?.conversationId === undefined
1025
+ ? {}
1026
+ : { conversationId: step.scope.conversationId }),
1027
+ };
1028
+ const expired = await deps.gate.expireDue(at, scope, step.limit);
1029
+ return { ...NOTHING, expired };
1030
+ }
1031
+ case "get": {
1032
+ const id = requireTarget(deps.target, step.kind);
1033
+ const entry = await deps.gate.get(id, ctx);
1034
+ return { ...NOTHING, entryId: id, read: entry };
1035
+ }
1036
+ case "rehydrate": {
1037
+ const scope = {
1038
+ tenantId: step.scope?.tenantId ?? ctx.tenantId,
1039
+ ...(step.scope?.conversationId === undefined
1040
+ ? {}
1041
+ : { conversationId: step.scope.conversationId }),
1042
+ };
1043
+ const page = {
1044
+ limit: step.page.limit,
1045
+ ...(step.page.cursor === undefined || step.page.cursor === null
1046
+ ? {}
1047
+ : { cursor: step.page.cursor }),
1048
+ };
1049
+ const result = await deps.gate.rehydrate(scope, page);
1050
+ return { ...NOTHING, page: { items: result.items, more: result.more } };
1051
+ }
1052
+ }
1053
+ }
1054
+ catch (error) {
1055
+ if (isAffiantError(error)) {
1056
+ return { ...NOTHING, code: error.code, message: error.message };
1057
+ }
1058
+ throw error;
1059
+ }
1060
+ }
1061
+ /** The entry a step acts on, or a fixture bug. */
1062
+ function requireTarget(target, kind) {
1063
+ if (target !== null)
1064
+ return target;
1065
+ throw new RangeError(`fixture step ${JSON.stringify(kind)} names no entry and nothing has been filed; ` +
1066
+ `give the step an \`entry\` label or file something in \`prior\` first`);
1067
+ }
1068
+ /** Everything the Docket holds in `scope`, in filing order. */
1069
+ async function drain(store, scope) {
1070
+ const all = [];
1071
+ for await (const entry of store.export(scope))
1072
+ all.push(entry);
1073
+ return all;
1074
+ }
1075
+ // ---------------------------------------------------------------------------
1076
+ // Checking
1077
+ // ---------------------------------------------------------------------------
1078
+ /** Record a failure when `expected` is stated and differs from `actual`. */
1079
+ function compare(at, expected, actual, failures) {
1080
+ if (expected === undefined)
1081
+ return;
1082
+ if (!deepEqual(expected, actual))
1083
+ failures.push({ at, expected, actual });
1084
+ }
1085
+ /** Structural equality over the JSON values a fixture can state. */
1086
+ function deepEqual(left, right) {
1087
+ if (Object.is(left, right))
1088
+ return true;
1089
+ if (typeof left !== "object" || typeof right !== "object")
1090
+ return false;
1091
+ if (left === null || right === null)
1092
+ return false;
1093
+ if (Array.isArray(left) !== Array.isArray(right))
1094
+ return false;
1095
+ if (Array.isArray(left) && Array.isArray(right)) {
1096
+ return left.length === right.length && left.every((item, i) => deepEqual(item, right[i]));
1097
+ }
1098
+ const leftKeys = Object.keys(left);
1099
+ const rightKeys = Object.keys(right);
1100
+ if (leftKeys.length !== rightKeys.length)
1101
+ return false;
1102
+ return leftKeys.every((key) => Object.hasOwn(right, key) &&
1103
+ deepEqual(left[key], right[key]));
1104
+ }
1105
+ /** Check a row against its partial matcher. */
1106
+ async function checkEntry(entry, expected, now, failures, at) {
1107
+ // The status a row *reads* (DK-1), not the one it says: a row past its deadline
1108
+ // reads expired whether or not a sweep has run, and every fixture about expiry is
1109
+ // about the read.
1110
+ compare(`${at}.status`, expected.status, readStatus(entry, now), failures);
1111
+ compare(`${at}.execution`, expected.execution, entry.execution, failures);
1112
+ compare(`${at}.executionDetail`, expected.executionDetail, entry.executionDetail, failures);
1113
+ compare(`${at}.requirement`, expected.requirement, entry.requirement, failures);
1114
+ compare(`${at}.blocked`, expected.blocked, entry.blocked, failures);
1115
+ compare(`${at}.toolName`, expected.toolName, entry.toolName, failures);
1116
+ compare(`${at}.channel`, expected.channel, entry.channel, failures);
1117
+ compare(`${at}.tenantId`, expected.tenantId, entry.tenantId, failures);
1118
+ compare(`${at}.conversationId`, expected.conversationId, entry.conversationId, failures);
1119
+ compare(`${at}.amendments`, expected.amendments, entry.amendments, failures);
1120
+ compare(`${at}.preservedAmendments`, expected.preservedAmendments, entry.preservedAmendments, failures);
1121
+ compare(`${at}.attestation`, expected.attestation, entry.attestation?.by ?? null, failures);
1122
+ if (expected.attestation != null && entry.attestation !== null) {
1123
+ // AZ-1: an attestation names the entry it attests to, or it is not evidence.
1124
+ compare(`${at}.attestation.entryId`, entry.entryId, entry.attestation.entryId, failures);
1125
+ }
1126
+ if (expected.decision !== undefined) {
1127
+ compare(`${at}.decision`, expected.decision, entry.decision === null ? null : { kind: entry.decision.kind, reason: entry.decision.reason }, failures);
1128
+ }
1129
+ if (expected.lineage !== undefined) {
1130
+ checkLink(`${at}.lineage.supersedes`, expected.lineage.supersedes, entry.lineage.supersedes, failures);
1131
+ checkLink(`${at}.lineage.supersededBy`, expected.lineage.supersededBy, entry.lineage.supersededBy, failures);
1132
+ }
1133
+ if (expected.expiresAtOffsetMs !== undefined) {
1134
+ const wanted = new Date(Date.parse(entry.filedAt) + expected.expiresAtOffsetMs).toISOString();
1135
+ compare(`${at}.expiresAt`, wanted, entry.expiresAt, failures);
1136
+ }
1137
+ if (expected.affidavit !== undefined) {
1138
+ checkAffidavit(entry.affidavit, expected.affidavit, failures, `${at}.affidavit`);
1139
+ }
1140
+ if (expected.amendedAffidavit !== undefined) {
1141
+ if (expected.amendedAffidavit === null) {
1142
+ compare(`${at}.amendedAffidavit`, null, entry.amendedAffidavit, failures);
1143
+ }
1144
+ else if (entry.amendedAffidavit === null) {
1145
+ failures.push({
1146
+ at: `${at}.amendedAffidavit`,
1147
+ expected: "the accepted state",
1148
+ actual: null,
1149
+ });
1150
+ }
1151
+ else {
1152
+ checkAffidavit(entry.amendedAffidavit, expected.amendedAffidavit, failures, `${at}.amendedAffidavit`);
1153
+ }
1154
+ }
1155
+ if (expected.canonicalDiffersFromProposal !== undefined) {
1156
+ // Through the exported helper, not re-derived: `canonicalHashEntry` is what a
1157
+ // host's grant hashes over, so it is what an oracle has to exercise (SR-1).
1158
+ const sworn = await canonicalHashEntry(entry);
1159
+ const proposal = await canonicalHash(entry.affidavit);
1160
+ compare(`${at}.canonicalDiffersFromProposal`, expected.canonicalDiffersFromProposal, sworn !== proposal, failures);
1161
+ }
1162
+ }
1163
+ /**
1164
+ * Check one lineage link (DK-1).
1165
+ *
1166
+ * A fixture cannot state a derived entry id — the id is a hash of the proposal — so
1167
+ * `"@some"` asserts only that the link is present, which is the fact the rule is
1168
+ * about: a resubmission names what it replaces, and the replaced row names it back.
1169
+ */
1170
+ function checkLink(at, expected, actual, failures) {
1171
+ if (expected === undefined)
1172
+ return;
1173
+ if (expected === "@some") {
1174
+ if (actual === null)
1175
+ failures.push({ at, expected: "an entry id", actual: null });
1176
+ return;
1177
+ }
1178
+ compare(at, expected, actual, failures);
1179
+ }
1180
+ /** Check an Affidavit against its partial matcher. */
1181
+ function checkAffidavit(affidavit, expected, failures, at) {
1182
+ compare(`${at}.operationType`, expected.operationType, affidavit.operationType, failures);
1183
+ compare(`${at}.entityType`, expected.entityType, affidavit.entityType, failures);
1184
+ compare(`${at}.entityId`, expected.entityId, affidavit.entityId, failures);
1185
+ compare(`${at}.aggregateConfidence`, expected.aggregateConfidence, affidavit.aggregateConfidence, failures);
1186
+ compare(`${at}.populatedConfidence`, expected.populatedConfidence, affidavit.populatedConfidence, failures);
1187
+ compare(`${at}.emptyFieldCount`, expected.emptyFieldCount, affidavit.emptyFieldCount, failures);
1188
+ if (expected.fields === undefined)
1189
+ return;
1190
+ compare(`${at}.fields`, expected.fields.map((field) => field.name), affidavit.fields.map((field) => field.name), failures);
1191
+ for (const [index, wanted] of expected.fields.entries()) {
1192
+ const field = affidavit.fields.find((candidate) => candidate.name === wanted.name);
1193
+ if (field === undefined)
1194
+ continue;
1195
+ const path = `${at}.fields[${String(index)}]`;
1196
+ compare(`${path}.value`, wanted.value, field.value, failures);
1197
+ compare(`${path}.previousValue`, wanted.previousValue, field.previousValue, failures);
1198
+ compare(`${path}.kind`, wanted.kind, field.kind, failures);
1199
+ compare(`${path}.isMandatory`, wanted.isMandatory, field.isMandatory, failures);
1200
+ compare(`${path}.source`, wanted.source, field.provenance.current.source, failures);
1201
+ compare(`${path}.confidence`, wanted.confidence, field.provenance.current.confidence, failures);
1202
+ compare(`${path}.bound`, wanted.bound, field.provenance.current.binding != null, failures);
1203
+ compare(`${path}.bindingKind`, wanted.bindingKind, field.provenance.current.binding?.kind ?? null, failures);
1204
+ compare(`${path}.priorSources`, wanted.priorSources, field.provenance.prior.map((tag) => tag.source), failures);
1205
+ }
1206
+ }
1207
+ /**
1208
+ * The card facts that hold for every filing, whatever the fixture is about.
1209
+ *
1210
+ * - **SR-4**: the card names the protocol version the row is pinned to, and points
1211
+ * at the row it was built from with the row's own deadline (GT-4).
1212
+ * - **AF-2**: all three confidence numbers, and all three are the *record's* — a
1213
+ * card whose numbers were recomputed for display could disagree with the row.
1214
+ * - **AZ-4 / CV-4**: a blocked row says so on the card and never asks for a
1215
+ * confirmation no decision path will accept.
1216
+ */
1217
+ function checkCardInvariants(filed, failures) {
1218
+ const { entry, card } = filed;
1219
+ // The exported helper, not the same expression written out again: the card's three
1220
+ // numbers must be the *sworn* record's — the state an approval accepted where
1221
+ // there is one, the proposal otherwise — and what "sworn" means is whatever
1222
+ // `swornAffidavitOf` says it is, because that is the function a host's execution
1223
+ // grant is taken over (SR-1).
1224
+ const sworn = swornAffidavitOf(entry);
1225
+ compare("card.docketId", entry.entryId, card.docketId, failures);
1226
+ compare("card.requiredBy", entry.expiresAt, card.requiredBy, failures);
1227
+ compare("card.protocolVersion", entry.protocolVersion, card.protocolVersion, failures);
1228
+ compare("card.affidavit.aggregateConfidence", sworn.aggregateConfidence, card.affidavit.aggregateConfidence, failures);
1229
+ compare("card.populatedConfidence", sworn.populatedConfidence, card.populatedConfidence, failures);
1230
+ compare("card.emptyFieldCount", sworn.emptyFieldCount, card.emptyFieldCount, failures);
1231
+ compare("card.blocked", entry.blocked, card.blocked, failures);
1232
+ if (entry.blocked !== null) {
1233
+ compare("card.requiresConfirmation", false, card.requiresConfirmation, failures);
1234
+ }
1235
+ }
1236
+ /** Check an Evidence Card against its partial matcher. */
1237
+ function checkCard(card, expected, failures) {
1238
+ compare("card.requiresConfirmation", expected.requiresConfirmation, card.requiresConfirmation, failures);
1239
+ compare("card.priorAmendments", expected.priorAmendments, card.priorAmendments, failures);
1240
+ compare("card.blocked", expected.blocked, card.blocked, failures);
1241
+ compare("card.protocolVersion", expected.protocolVersion, card.protocolVersion, failures);
1242
+ compare("card.aggregateConfidence", expected.aggregateConfidence, card.affidavit.aggregateConfidence, failures);
1243
+ compare("card.populatedConfidence", expected.populatedConfidence, card.populatedConfidence, failures);
1244
+ compare("card.emptyFieldCount", expected.emptyFieldCount, card.emptyFieldCount, failures);
1245
+ // The warnings moved onto the envelope in v0.1 for the same reason the hints did:
1246
+ // a sentence a reviewer reads is presentation, and nothing swears to it. A card
1247
+ // with none omits the property.
1248
+ const stated = card.warnings ?? [];
1249
+ const warnings = stated.join(" ");
1250
+ for (const [index, phrase] of (expected.warningsContain ?? []).entries()) {
1251
+ if (!warnings.includes(phrase)) {
1252
+ failures.push({
1253
+ at: `card.warningsContain[${String(index)}]`,
1254
+ expected: phrase,
1255
+ actual: stated,
1256
+ });
1257
+ }
1258
+ }
1259
+ // The rendering hints are the envelope's own array from v0.1, not a per-field key:
1260
+ // a closed value set and an input mask are how a surface should show a field, and
1261
+ // the canonical form a host's execution grant binds to is the Affidavit and its
1262
+ // accepted amendments alone (SR-1). A fixture that states `presentation` states
1263
+ // the WHOLE array, in the order the card carries it — a card with an extra hint
1264
+ // nobody asked for is a card a reviewer surface renders differently.
1265
+ if (expected.presentation !== undefined) {
1266
+ const carried = card.presentation ?? [];
1267
+ compare("card.presentation", expected.presentation.map((hint) => hint.name), carried.map((hint) => hint.name), failures);
1268
+ for (const [index, wanted] of expected.presentation.entries()) {
1269
+ const hint = carried.find((candidate) => candidate.name === wanted.name);
1270
+ if (hint === undefined)
1271
+ continue;
1272
+ const path = `card.presentation[${String(index)}]`;
1273
+ compare(`${path}.kind`, wanted.kind, hint.kind, failures);
1274
+ compare(`${path}.allowedValues`, wanted.allowedValues, hint.allowedValues, failures);
1275
+ compare(`${path}.pattern`, wanted.pattern, hint.pattern, failures);
1276
+ }
1277
+ }
1278
+ if (expected.fields === undefined)
1279
+ return;
1280
+ compare("card.fields", expected.fields.map((field) => field.name), card.affidavit.fields.map((field) => field.name), failures);
1281
+ for (const [index, wanted] of expected.fields.entries()) {
1282
+ const field = card.affidavit.fields.find((candidate) => candidate.name === wanted.name);
1283
+ if (field === undefined)
1284
+ continue;
1285
+ const path = `card.fields[${String(index)}]`;
1286
+ compare(`${path}.kind`, wanted.kind, field.kind, failures);
1287
+ compare(`${path}.value`, wanted.value, field.value, failures);
1288
+ compare(`${path}.isMandatory`, wanted.isMandatory, field.isMandatory, failures);
1289
+ }
1290
+ }
1291
+ //# sourceMappingURL=testing.js.map