@gate-forge/core 0.3.0 → 0.6.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 (94) hide show
  1. package/dist/baselines/index.js +1 -1
  2. package/dist/baselines/index.js.map +1 -1
  3. package/dist/config/index.d.ts +2 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +7 -0
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/fingerprints.d.ts +35 -4
  8. package/dist/fingerprints.d.ts.map +1 -1
  9. package/dist/fingerprints.js +58 -5
  10. package/dist/fingerprints.js.map +1 -1
  11. package/dist/index.d.ts +32 -10
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +29 -9
  14. package/dist/index.js.map +1 -1
  15. package/dist/mapping/resolve.d.ts +6 -2
  16. package/dist/mapping/resolve.d.ts.map +1 -1
  17. package/dist/mapping/resolve.js +122 -17
  18. package/dist/mapping/resolve.js.map +1 -1
  19. package/dist/policy/behavior.d.ts +44 -0
  20. package/dist/policy/behavior.d.ts.map +1 -0
  21. package/dist/policy/behavior.js +373 -0
  22. package/dist/policy/behavior.js.map +1 -0
  23. package/dist/policy/coverage.d.ts +4 -3
  24. package/dist/policy/coverage.d.ts.map +1 -1
  25. package/dist/policy/coverage.js +12 -6
  26. package/dist/policy/coverage.js.map +1 -1
  27. package/dist/policy/evaluate.d.ts +19 -0
  28. package/dist/policy/evaluate.d.ts.map +1 -1
  29. package/dist/policy/index.d.ts +1 -0
  30. package/dist/policy/index.d.ts.map +1 -1
  31. package/dist/policy/index.js +1 -0
  32. package/dist/policy/index.js.map +1 -1
  33. package/dist/receipt/index.d.ts +56 -4
  34. package/dist/receipt/index.d.ts.map +1 -1
  35. package/dist/receipt/index.js +139 -5
  36. package/dist/receipt/index.js.map +1 -1
  37. package/dist/report/index.js +4 -19
  38. package/dist/report/index.js.map +1 -1
  39. package/dist/schemas/behavior-catalog.d.ts +2868 -0
  40. package/dist/schemas/behavior-catalog.d.ts.map +1 -0
  41. package/dist/schemas/behavior-catalog.js +103 -0
  42. package/dist/schemas/behavior-catalog.js.map +1 -0
  43. package/dist/schemas/behavior-evidence.d.ts +174 -0
  44. package/dist/schemas/behavior-evidence.d.ts.map +1 -0
  45. package/dist/schemas/behavior-evidence.js +157 -0
  46. package/dist/schemas/behavior-evidence.js.map +1 -0
  47. package/dist/schemas/behavior-policy.d.ts +6214 -0
  48. package/dist/schemas/behavior-policy.d.ts.map +1 -0
  49. package/dist/schemas/behavior-policy.js +896 -0
  50. package/dist/schemas/behavior-policy.js.map +1 -0
  51. package/dist/schemas/execution-result.d.ts +4 -4
  52. package/dist/schemas/gate-receipt.d.ts +10 -3
  53. package/dist/schemas/gate-receipt.d.ts.map +1 -1
  54. package/dist/schemas/gate-receipt.js +46 -3
  55. package/dist/schemas/gate-receipt.js.map +1 -1
  56. package/dist/schemas/index.d.ts +3 -0
  57. package/dist/schemas/index.d.ts.map +1 -1
  58. package/dist/schemas/index.js +3 -0
  59. package/dist/schemas/index.js.map +1 -1
  60. package/dist/schemas/obligation.d.ts +1 -0
  61. package/dist/schemas/obligation.d.ts.map +1 -1
  62. package/dist/schemas/obligation.js +9 -0
  63. package/dist/schemas/obligation.js.map +1 -1
  64. package/dist/schemas/test-catalog.d.ts +20 -2
  65. package/dist/schemas/test-catalog.d.ts.map +1 -1
  66. package/dist/schemas/test-catalog.js +13 -0
  67. package/dist/schemas/test-catalog.js.map +1 -1
  68. package/dist/schemas/test-map.d.ts +4 -0
  69. package/dist/schemas/test-map.d.ts.map +1 -1
  70. package/dist/schemas/test-map.js +24 -1
  71. package/dist/schemas/test-map.js.map +1 -1
  72. package/dist/schemas/verdict.d.ts +9 -0
  73. package/dist/schemas/verdict.d.ts.map +1 -1
  74. package/dist/schemas/verdict.js +27 -6
  75. package/dist/schemas/verdict.js.map +1 -1
  76. package/dist/testing/gate-runner.d.ts.map +1 -1
  77. package/dist/testing/gate-runner.js +2 -7
  78. package/dist/testing/gate-runner.js.map +1 -1
  79. package/dist/verdict/behavior.d.ts +87 -0
  80. package/dist/verdict/behavior.d.ts.map +1 -0
  81. package/dist/verdict/behavior.js +1328 -0
  82. package/dist/verdict/behavior.js.map +1 -0
  83. package/dist/verdict/evaluate.d.ts +10 -0
  84. package/dist/verdict/evaluate.d.ts.map +1 -1
  85. package/dist/verdict/evaluate.js +188 -27
  86. package/dist/verdict/evaluate.js.map +1 -1
  87. package/dist/verdict/index.d.ts +6 -0
  88. package/dist/verdict/index.d.ts.map +1 -1
  89. package/dist/verdict/index.js +6 -0
  90. package/dist/verdict/index.js.map +1 -1
  91. package/dist/verdict/pack-verifiers.d.ts.map +1 -1
  92. package/dist/verdict/pack-verifiers.js +124 -33
  93. package/dist/verdict/pack-verifiers.js.map +1 -1
  94. package/package.json +1 -1
@@ -0,0 +1,1328 @@
1
+ /**
2
+ * Required-case aggregation (plan 2026-09-19 §4.7, Phase 5): the pure
3
+ * semantic grader for obligations compiled from the behavior catalog.
4
+ *
5
+ * Aggregation is across the REQUIRED CASES, never inside the legacy
6
+ * "any claim satisfied" shortcut: every required case must pass exactly
7
+ * once. A legacy record never contributes to the new case set, and
8
+ * suite-expanded claim ids never widen it — only the compiled
9
+ * requirement set (trusted context, not record payloads) decides what
10
+ * is required, and only payload-listed obligation ids decide what a
11
+ * record may satisfy.
12
+ *
13
+ * Per case the grader validates provenance, catalog/spec digests,
14
+ * endpoint/effect/actor bindings, and state-machine facts, then grades
15
+ * with the namespace's pure semantic rules:
16
+ * - exact method, canonical endpoint, and all relevant route/query/body
17
+ * identity selectors from approved case data;
18
+ * - authoritative before/after checkpoints with normalized declared
19
+ * effect projections (multi-resource changes grade atomically; an
20
+ * unexplained delta in any declared scope is an unexpected effect);
21
+ * - declarative bulk/import exact-set comparison (missing, extra, or
22
+ * duplicate entities fail).
23
+ *
24
+ * Phase 5 grades `request` actions over the `engine-http` channel.
25
+ * `surface` (Phase 6), `deliver`, and `sequence` (Phase 8) actions, and
26
+ * non-HTTP channels, block with an explicit phase cause — never a pass.
27
+ */
28
+ import { canonicalJson, sha256Canonical } from '../canonical-json.js';
29
+ import { compareStrings } from '../graph/util.js';
30
+ import { BEHAVIOR_CASE_KIND, BEHAVIOR_CASE_PAYLOAD_VERSION, BehaviorCasePayloadSchema, } from '../schemas/behavior-evidence.js';
31
+ import { interpretObservedPath, resolveHttpRoute } from './pack-verifiers.js';
32
+ /** Strong HTTP contracts graded here (engine-http request actions). */
33
+ export const STRONG_HTTP_CONTRACTS = new Set([
34
+ 'http:effect-verified',
35
+ 'http:read-result-verified',
36
+ ]);
37
+ /** Authentication contracts graded here (Phase 7, same case machinery). */
38
+ export const AUTH_CONTRACTS = new Set([
39
+ 'auth:role-allowed',
40
+ 'auth:role-denied',
41
+ 'auth:tenant-isolated',
42
+ 'auth:denied-no-side-effect',
43
+ 'auth:forged-token-rejected',
44
+ ]);
45
+ /** Validation contracts graded here (Phase 7, same case machinery). */
46
+ export const VALIDATION_CONTRACTS = new Set([
47
+ 'validation:boundary-accepted',
48
+ 'validation:boundary-rejected',
49
+ 'validation:no-side-effect-on-reject',
50
+ 'validation:error-message-explicit',
51
+ 'validation:envelope-shape-stable',
52
+ ]);
53
+ /** Workflow contracts graded through authenticated behavior.case evidence. */
54
+ export const WORKFLOW_CONTRACTS = new Set([
55
+ 'workflow:transition-allowed',
56
+ 'workflow:transition-rejected',
57
+ 'workflow:terminal-immutable',
58
+ 'workflow:audit-emitted',
59
+ 'workflow:persisted-final-state',
60
+ ]);
61
+ /** Task contracts graded through authenticated behavior.case evidence. */
62
+ export const TASK_CONTRACTS = new Set([
63
+ 'task:retry-policy-enforced',
64
+ 'task:idempotent',
65
+ 'task:terminal-handled',
66
+ 'task:observability-recorded',
67
+ 'task:duplicate-delivery-handled',
68
+ ]);
69
+ /** Webhook contracts graded through authenticated behavior.case evidence. */
70
+ export const WEBHOOK_CONTRACTS = new Set([
71
+ 'webhook:signature-accepted',
72
+ 'webhook:signature-rejected',
73
+ 'webhook:malformed-rejected',
74
+ 'webhook:replay-idempotent',
75
+ 'webhook:retry-bounded',
76
+ ]);
77
+ /** Every contract the required-case aggregation grades. */
78
+ export const BEHAVIOR_CASE_CONTRACTS = new Set([
79
+ ...STRONG_HTTP_CONTRACTS,
80
+ ...AUTH_CONTRACTS,
81
+ ...VALIDATION_CONTRACTS,
82
+ ...WORKFLOW_CONTRACTS,
83
+ ...TASK_CONTRACTS,
84
+ ...WEBHOOK_CONTRACTS,
85
+ ]);
86
+ /**
87
+ * Canonical digest over a case action descriptor. The witness seals
88
+ * this; the grader recomputes it from the compiled catalog — both use
89
+ * GF-canonical JSON, so equal actions hash equally on both sides.
90
+ */
91
+ export function behaviorActionDigestOf(action) {
92
+ return sha256Canonical(action);
93
+ }
94
+ /** Identity key mirroring the witness snapshot validation (lockstep). */
95
+ function identityKeyOf(entityId, identityFields) {
96
+ if (entityId === null || entityId === undefined)
97
+ return null;
98
+ if (typeof entityId === 'string' || typeof entityId === 'number' || typeof entityId === 'boolean') {
99
+ return `${typeof entityId}:${String(entityId)}`;
100
+ }
101
+ if (typeof entityId !== 'object' || Array.isArray(entityId))
102
+ return null;
103
+ const record = entityId;
104
+ const parts = [];
105
+ for (const column of [...identityFields].sort()) {
106
+ const value = record[column];
107
+ if (value === undefined || value === null)
108
+ return null;
109
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
110
+ return null;
111
+ parts.push(`${column}=${typeof value}:${String(value)}`);
112
+ }
113
+ if (parts.length === 0)
114
+ return null;
115
+ return `composite:${parts.join('|')}`;
116
+ }
117
+ /** Deep equality over JSON values (canonical form). */
118
+ function valuesEqual(left, right) {
119
+ try {
120
+ return canonicalJson(left) === canonicalJson(right);
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
126
+ /** Unsafe lookup segments (no prototype-chain traversal). */
127
+ const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
128
+ /** Splits a value pointer: `/a/b` (JSON-pointer) or `a.b` (dotted). */
129
+ function pointerSegments(pointer) {
130
+ const segments = pointer.startsWith('/')
131
+ ? pointer
132
+ .split('/')
133
+ .slice(1)
134
+ .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'))
135
+ : pointer.split('.');
136
+ if (segments.some((segment) => segment.length === 0 || FORBIDDEN_SEGMENTS.has(segment)))
137
+ return null;
138
+ return segments;
139
+ }
140
+ /** Resolves pointer segments into records/arrays (numeric indexes arrays). */
141
+ function resolvePointer(root, pointer) {
142
+ const segments = pointerSegments(pointer);
143
+ if (segments === null)
144
+ return { found: false, value: undefined };
145
+ let current = root;
146
+ for (const segment of segments) {
147
+ if (Array.isArray(current)) {
148
+ if (!/^[0-9]+$/.test(segment))
149
+ return { found: false, value: undefined };
150
+ const index = Number(segment);
151
+ if (index >= current.length)
152
+ return { found: false, value: undefined };
153
+ current = current[index];
154
+ continue;
155
+ }
156
+ if (typeof current !== 'object' || current === null)
157
+ return { found: false, value: undefined };
158
+ if (!Object.prototype.hasOwnProperty.call(current, segment))
159
+ return { found: false, value: undefined };
160
+ current = current[segment];
161
+ }
162
+ return { found: true, value: current };
163
+ }
164
+ /** Resolves a dotted fixture key (`accountA.id`) inside sealed fixture values. */
165
+ function resolveFixtureKey(fixtureValues, key) {
166
+ const segments = key.split('.');
167
+ if (segments.some((segment) => segment.length === 0 || FORBIDDEN_SEGMENTS.has(segment))) {
168
+ return { ok: false, value: undefined };
169
+ }
170
+ let current = fixtureValues;
171
+ for (const segment of segments) {
172
+ if (typeof current !== 'object' || current === null || Array.isArray(current)) {
173
+ return { ok: false, value: undefined };
174
+ }
175
+ if (!Object.prototype.hasOwnProperty.call(current, segment))
176
+ return { ok: false, value: undefined };
177
+ current = current[segment];
178
+ }
179
+ return { ok: true, value: current };
180
+ }
181
+ /** Resolves an InputValue (literal or fixture key). */
182
+ function resolveInputValue(value, fixtureValues) {
183
+ const v = value;
184
+ if (typeof v !== 'object' || v === null)
185
+ return { ok: false, value: undefined };
186
+ if (v.from === 'literal')
187
+ return { ok: true, value: v.value };
188
+ if (v.from === 'fixture' && typeof v.key === 'string')
189
+ return resolveFixtureKey(fixtureValues, v.key);
190
+ return { ok: false, value: undefined };
191
+ }
192
+ /** Applies a comparison transform to a string value. */
193
+ function applyTransform(value, transform) {
194
+ if (typeof value !== 'string')
195
+ return value;
196
+ if (transform === 'trim')
197
+ return value.trim();
198
+ if (transform === 'lowercase')
199
+ return value.toLowerCase();
200
+ return value;
201
+ }
202
+ /** Resolves an ExpectedValue (literal, fixture, observed request, or before-state). */
203
+ function resolveExpectedValue(value, ctx) {
204
+ const v = value;
205
+ if (typeof v !== 'object' || v === null)
206
+ return { ok: false, value: undefined };
207
+ if (v.from === 'literal')
208
+ return { ok: true, value: v.value };
209
+ if (v.from === 'fixture' && typeof v.key === 'string')
210
+ return resolveFixtureKey(ctx.fixtureValues, v.key);
211
+ if (v.from === 'request') {
212
+ if (ctx.observation === null || typeof v.pointer !== 'string')
213
+ return { ok: false, value: undefined };
214
+ if (typeof v.attempt !== 'number' || v.attempt !== 0)
215
+ return { ok: false, value: undefined };
216
+ const resolved = resolvePointer(ctx.observation, v.pointer);
217
+ if (!resolved.found)
218
+ return { ok: false, value: undefined };
219
+ return { ok: true, value: applyTransform(resolved.value, v.transform) };
220
+ }
221
+ if (v.from === 'before') {
222
+ const scopeName = v.scope;
223
+ const subject = v.subject;
224
+ const field = v.field;
225
+ if (typeof scopeName !== 'string' || typeof field !== 'string')
226
+ return { ok: false, value: undefined };
227
+ const subjectResolved = resolveInputValue(subject, ctx.fixtureValues);
228
+ if (!subjectResolved.ok)
229
+ return { ok: false, value: undefined };
230
+ const scopeIndex = ctx.before.get(scopeName);
231
+ if (scopeIndex === undefined)
232
+ return { ok: false, value: undefined };
233
+ // Identity fields are scope-specific; find by serialized entity match.
234
+ for (const entity of scopeIndex.values()) {
235
+ if (valuesEqual(entity.entityId, subjectResolved.value)) {
236
+ if (!Object.prototype.hasOwnProperty.call(entity.fields, field))
237
+ return { ok: false, value: undefined };
238
+ return { ok: true, value: entity.fields[field] };
239
+ }
240
+ }
241
+ return { ok: false, value: undefined };
242
+ }
243
+ return { ok: false, value: undefined };
244
+ }
245
+ /** Builds an index over sealed scope snapshots with scope-identity keys. */
246
+ function indexSnapshots(snapshots, compiled) {
247
+ const index = new Map();
248
+ const effectByScope = new Map(compiled.effects.map((effect) => [effect.scope, effect]));
249
+ for (const snapshot of snapshots) {
250
+ const effect = effectByScope.get(snapshot.scope);
251
+ if (effect === undefined) {
252
+ return { ok: false, reason: `sealed snapshot scope '${snapshot.scope}' is not a declared effect` };
253
+ }
254
+ const scopeIndex = new Map();
255
+ for (const entity of snapshot.entities) {
256
+ const key = identityKeyOf(entity.entityId, effect.identityFields);
257
+ if (key === null || scopeIndex.has(key)) {
258
+ return { ok: false, reason: `sealed snapshot scope '${snapshot.scope}' has unknown or duplicate identity` };
259
+ }
260
+ scopeIndex.set(key, { entityId: entity.entityId, fields: entity.fields });
261
+ }
262
+ index.set(snapshot.scope, scopeIndex);
263
+ }
264
+ return { ok: true, index };
265
+ }
266
+ /** True when a scalar value identifies an entity present in before-state. */
267
+ function beforeContains(before, value) {
268
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
269
+ return false;
270
+ for (const scopeIndex of before.values()) {
271
+ for (const entity of scopeIndex.values()) {
272
+ if (valuesEqual(entity.entityId, value))
273
+ return true;
274
+ if (typeof entity.entityId === 'object' && entity.entityId !== null && !Array.isArray(entity.entityId)) {
275
+ const columns = Object.values(entity.entityId);
276
+ if (columns.some((column) => valuesEqual(column, value)))
277
+ return true;
278
+ }
279
+ }
280
+ }
281
+ return false;
282
+ }
283
+ function newLedger() {
284
+ return { added: new Set(), removed: new Set(), changed: new Set(), complete: new Set() };
285
+ }
286
+ /** Scope delta between before and after indexes. */
287
+ function scopeDelta(before, after) {
288
+ const added = [];
289
+ const removed = [];
290
+ const changed = [];
291
+ for (const [key, entity] of after) {
292
+ const prior = before.get(key);
293
+ if (prior === undefined)
294
+ added.push(key);
295
+ else if (!valuesEqual(prior.fields, entity.fields))
296
+ changed.push(key);
297
+ }
298
+ for (const key of before.keys()) {
299
+ if (!after.has(key))
300
+ removed.push(key);
301
+ }
302
+ return { added, removed, changed };
303
+ }
304
+ /** Grades one state rule; records explained identities for atomicity. */
305
+ function gradeStateRule(obligationId, rule, before, after, valueCtx, explained, scopeKeyOf) {
306
+ const kind = rule['kind'];
307
+ if (kind === 'attempts') {
308
+ return {
309
+ ok: false,
310
+ reason: `'${obligationId}': 'attempts' rules require the Phase 8 delivery trace — blocked, never satisfied`,
311
+ };
312
+ }
313
+ if (typeof rule['scope'] !== 'string') {
314
+ return { ok: false, reason: `'${obligationId}': state rule has no scope` };
315
+ }
316
+ // Rule scopes name declared EFFECT ids; snapshots are keyed by the
317
+ // effect's approved scope key.
318
+ const scopeName = scopeKeyOf(rule['scope']);
319
+ if (scopeName === null) {
320
+ return { ok: false, reason: `'${obligationId}': state rule scope '${rule['scope']}' is not a declared effect` };
321
+ }
322
+ const beforeScope = before.get(scopeName);
323
+ const afterScope = after.get(scopeName);
324
+ if (beforeScope === undefined || afterScope === undefined) {
325
+ return { ok: false, reason: `'${obligationId}': scope '${scopeName}' lacks before/after snapshots` };
326
+ }
327
+ const ledger = explained.get(scopeName) ?? newLedger();
328
+ explained.set(scopeName, ledger);
329
+ const fail = (reason) => ({ ok: false, reason: `'${obligationId}': ${reason}` });
330
+ if (kind === 'unchanged') {
331
+ const delta = scopeDelta(beforeScope, afterScope);
332
+ if (delta.added.length > 0 || delta.removed.length > 0 || delta.changed.length > 0) {
333
+ return fail(`scope '${scopeName}' changed but the rule requires unchanged`);
334
+ }
335
+ ledger.complete.add(scopeName);
336
+ return { ok: true };
337
+ }
338
+ if (kind === 'updated' || kind === 'archived') {
339
+ const subject = resolveInputValue(rule['subject'], valueCtx.fixtureValues);
340
+ if (!subject.ok)
341
+ return fail(`scope '${scopeName}' ${kind} subject does not resolve`);
342
+ // Find the entity by serialized identity (scope-local key needs identity fields).
343
+ let key = null;
344
+ for (const [candidate, entity] of beforeScope) {
345
+ if (valuesEqual(entity.entityId, subject.value)) {
346
+ key = candidate;
347
+ break;
348
+ }
349
+ }
350
+ if (key === null)
351
+ return fail(`scope '${scopeName}' ${kind} subject is absent before the operation`);
352
+ const afterEntity = afterScope.get(key);
353
+ if (afterEntity === undefined)
354
+ return fail(`scope '${scopeName}' ${kind} subject vanished after the operation`);
355
+ const fields = rule['fields'];
356
+ if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
357
+ return fail(`scope '${scopeName}' ${kind} fields must be a non-empty map`);
358
+ }
359
+ const names = Object.keys(fields);
360
+ if (names.length === 0)
361
+ return fail(`scope '${scopeName}' ${kind} fields must be non-empty`);
362
+ const beforeEntity = beforeScope.get(key);
363
+ let deltaSeen = false;
364
+ for (const name of names) {
365
+ const expected = resolveExpectedValue(fields[name], valueCtx);
366
+ if (!expected.ok)
367
+ return fail(`scope '${scopeName}' ${kind} field '${name}' does not resolve`);
368
+ if (!Object.prototype.hasOwnProperty.call(afterEntity.fields, name)) {
369
+ return fail(`scope '${scopeName}' ${kind} field '${name}' is absent after the operation`);
370
+ }
371
+ if (!valuesEqual(afterEntity.fields[name], expected.value)) {
372
+ return fail(`scope '${scopeName}' ${kind} field '${name}' differs from the expected effect`);
373
+ }
374
+ if (!valuesEqual(beforeEntity.fields[name], afterEntity.fields[name]))
375
+ deltaSeen = true;
376
+ }
377
+ if (!deltaSeen) {
378
+ return fail(`scope '${scopeName}' declares an update with zero state delta — a 200 without a saved change proves nothing`);
379
+ }
380
+ ledger.changed.add(key);
381
+ return { ok: true };
382
+ }
383
+ if (kind === 'created' || kind === 'append-only') {
384
+ const rows = rule['rows'];
385
+ if (!Array.isArray(rows) || rows.length === 0) {
386
+ return fail(`scope '${scopeName}' ${kind} rows must be non-empty`);
387
+ }
388
+ const afterOnly = [...afterScope.keys()].filter((key) => !beforeScope.has(key));
389
+ const claimed = new Set();
390
+ for (let index = 0; index < rows.length; index += 1) {
391
+ const row = rows[index];
392
+ const fields = row?.fields;
393
+ if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
394
+ return fail(`scope '${scopeName}' ${kind} row ${String(index)} fields must be a map`);
395
+ }
396
+ const match = afterOnly.find((key) => {
397
+ if (claimed.has(key))
398
+ return false;
399
+ const entity = afterScope.get(key);
400
+ return Object.entries(fields).every(([name, expectedRaw]) => {
401
+ const expected = resolveExpectedValue(expectedRaw, valueCtx);
402
+ return (expected.ok &&
403
+ Object.prototype.hasOwnProperty.call(entity.fields, name) &&
404
+ valuesEqual(entity.fields[name], expected.value));
405
+ });
406
+ });
407
+ if (match === undefined) {
408
+ return fail(`scope '${scopeName}' ${kind} row ${String(index)} matches no created entity`);
409
+ }
410
+ claimed.add(match);
411
+ ledger.added.add(match);
412
+ }
413
+ if (kind === 'append-only') {
414
+ // Pre-existing entities must be untouched.
415
+ for (const [key, entity] of beforeScope) {
416
+ const current = afterScope.get(key);
417
+ if (current === undefined || !valuesEqual(current.fields, entity.fields)) {
418
+ return fail(`scope '${scopeName}' append-only changed a pre-existing entity`);
419
+ }
420
+ }
421
+ }
422
+ return { ok: true };
423
+ }
424
+ if (kind === 'absent') {
425
+ const subjects = rule['subjects'];
426
+ if (!Array.isArray(subjects) || subjects.length === 0) {
427
+ return fail(`scope '${scopeName}' absent subjects must be non-empty`);
428
+ }
429
+ for (const subjectRaw of subjects) {
430
+ const subject = resolveInputValue(subjectRaw, valueCtx.fixtureValues);
431
+ if (!subject.ok)
432
+ return fail(`scope '${scopeName}' absent subject does not resolve`);
433
+ // Known-existing target (plan §4.5): a denial/absent proof against
434
+ // a nonexistent record proves nothing — "denied" might merely mean
435
+ // malformed fixture. The subject must exist in before-state.
436
+ let present = false;
437
+ for (const scopeIndex of valueCtx.before.values()) {
438
+ for (const entity of scopeIndex.values()) {
439
+ if (valuesEqual(entity.entityId, subject.value)) {
440
+ present = true;
441
+ break;
442
+ }
443
+ }
444
+ if (present)
445
+ break;
446
+ }
447
+ if (!present) {
448
+ return fail(`scope '${scopeName}' absent subject does not exist in before-state — the denial target must be a known existing record (fixture validation)`);
449
+ }
450
+ for (const [key, entity] of afterScope) {
451
+ if (valuesEqual(entity.entityId, subject.value)) {
452
+ return fail(`scope '${scopeName}' still contains a forbidden entity ('${key}')`);
453
+ }
454
+ }
455
+ }
456
+ // Removals explained: any before-only key is accounted for.
457
+ for (const key of beforeScope.keys()) {
458
+ if (!afterScope.has(key))
459
+ ledger.removed.add(key);
460
+ }
461
+ return { ok: true };
462
+ }
463
+ if (kind === 'exact-set') {
464
+ const rows = rule['rows'];
465
+ if (!Array.isArray(rows) || rows.length === 0) {
466
+ return fail(`scope '${scopeName}' exact-set rows must be non-empty`);
467
+ }
468
+ if (afterScope.size !== rows.length) {
469
+ return fail(`scope '${scopeName}' exact set mismatch: ${String(afterScope.size)} observed, ${String(rows.length)} expected`);
470
+ }
471
+ const matched = new Set();
472
+ for (let index = 0; index < rows.length; index += 1) {
473
+ const row = rows[index];
474
+ const subject = resolveInputValue(row?.subject, valueCtx.fixtureValues);
475
+ if (!subject.ok)
476
+ return fail(`scope '${scopeName}' exact-set row ${String(index)} subject does not resolve`);
477
+ let key = null;
478
+ for (const [candidate, entity] of afterScope) {
479
+ if (valuesEqual(entity.entityId, subject.value)) {
480
+ key = candidate;
481
+ break;
482
+ }
483
+ }
484
+ if (key === null || matched.has(key)) {
485
+ return fail(`scope '${scopeName}' exact set is missing or duplicates an expected entity (row ${String(index)})`);
486
+ }
487
+ matched.add(key);
488
+ const fields = row?.fields;
489
+ if (typeof fields === 'object' && fields !== null && !Array.isArray(fields)) {
490
+ const entity = afterScope.get(key);
491
+ for (const [name, expectedRaw] of Object.entries(fields)) {
492
+ const expected = resolveExpectedValue(expectedRaw, valueCtx);
493
+ if (!expected.ok ||
494
+ !Object.prototype.hasOwnProperty.call(entity.fields, name) ||
495
+ !valuesEqual(entity.fields[name], expected.value)) {
496
+ return fail(`scope '${scopeName}' exact-set row ${String(index)} field '${name}' differs`);
497
+ }
498
+ }
499
+ }
500
+ }
501
+ ledger.complete.add(scopeName);
502
+ return { ok: true };
503
+ }
504
+ if (kind === 'count-delta') {
505
+ const delta = rule['delta'];
506
+ if (typeof delta !== 'number' || !Number.isInteger(delta)) {
507
+ return fail(`scope '${scopeName}' count-delta needs an integer delta`);
508
+ }
509
+ if (afterScope.size - beforeScope.size !== delta) {
510
+ return fail(`scope '${scopeName}' count delta ${String(afterScope.size - beforeScope.size)} is not the declared ${String(delta)}`);
511
+ }
512
+ return { ok: true };
513
+ }
514
+ if (kind === 'transition') {
515
+ const subject = resolveInputValue(rule['subject'], valueCtx.fixtureValues);
516
+ const field = rule['field'];
517
+ const from = resolveExpectedValue(rule['from'], valueCtx);
518
+ const to = resolveExpectedValue(rule['to'], valueCtx);
519
+ if (!subject.ok || typeof field !== 'string' || !from.ok || !to.ok) {
520
+ return fail(`scope '${scopeName}' transition subject/field/from/to must resolve`);
521
+ }
522
+ let key = null;
523
+ for (const [candidate, entity] of beforeScope) {
524
+ if (valuesEqual(entity.entityId, subject.value)) {
525
+ key = candidate;
526
+ break;
527
+ }
528
+ }
529
+ if (key === null)
530
+ return fail(`scope '${scopeName}' transition subject is absent before the operation`);
531
+ const beforeEntity = beforeScope.get(key);
532
+ const afterEntity = afterScope.get(key);
533
+ if (afterEntity === undefined)
534
+ return fail(`scope '${scopeName}' transition subject vanished`);
535
+ if (!valuesEqual(beforeEntity.fields[field], from.value)) {
536
+ return fail(`scope '${scopeName}' transition starts from an unexpected state`);
537
+ }
538
+ if (!valuesEqual(afterEntity.fields[field], to.value)) {
539
+ return fail(`scope '${scopeName}' transition did not reach the declared state`);
540
+ }
541
+ ledger.changed.add(key);
542
+ return { ok: true };
543
+ }
544
+ return { ok: false, reason: `'${obligationId}': unknown state rule kind '${String(kind)}' — blocked, never satisfied` };
545
+ }
546
+ /** Validates one response rule against the principal response body. */
547
+ function gradeResponseRule(obligationId, rule, responseBody, valueCtx) {
548
+ const kind = rule['kind'];
549
+ const fail = (reason) => ({ ok: false, reason: `'${obligationId}': ${reason}` });
550
+ if (kind === 'equals') {
551
+ if (typeof rule['pointer'] !== 'string')
552
+ return fail('equals rule needs a pointer');
553
+ const actual = resolvePointer(responseBody, rule['pointer']);
554
+ if (!actual.found)
555
+ return fail(`response pointer '${rule['pointer']}' resolves to nothing`);
556
+ const expected = resolveExpectedValue(rule['value'], valueCtx);
557
+ if (!expected.ok)
558
+ return fail('equals rule value does not resolve');
559
+ if (!valuesEqual(actual.value, expected.value))
560
+ return fail('response value differs from the expected result');
561
+ return { ok: true };
562
+ }
563
+ if (kind === 'field-error') {
564
+ const field = rule['field'];
565
+ const fieldPointer = rule['fieldPointer'];
566
+ const codePointer = rule['codePointer'];
567
+ const allowedCodes = rule['allowedCodes'];
568
+ if (typeof field !== 'string' || typeof fieldPointer !== 'string' || typeof codePointer !== 'string' || !Array.isArray(allowedCodes)) {
569
+ return fail('field-error rule needs field, fieldPointer, codePointer, and allowedCodes');
570
+ }
571
+ const actualField = resolvePointer(responseBody, fieldPointer);
572
+ const actualCode = resolvePointer(responseBody, codePointer);
573
+ if (!actualField.found || !valuesEqual(actualField.value, field)) {
574
+ return fail(`response error does not identify field '${field}'`);
575
+ }
576
+ if (!actualCode.found || !allowedCodes.some((code) => valuesEqual(code, actualCode.value))) {
577
+ return fail('response error code is not an approved code');
578
+ }
579
+ return { ok: true };
580
+ }
581
+ if (kind === 'absent') {
582
+ if (typeof rule['pointer'] !== 'string')
583
+ return fail('absent rule needs a pointer');
584
+ const actual = resolvePointer(responseBody, rule['pointer']);
585
+ if (actual.found)
586
+ return fail(`forbidden response field '${rule['pointer']}' is present`);
587
+ return { ok: true };
588
+ }
589
+ if (kind === 'entity-set') {
590
+ const pointer = rule['pointer'];
591
+ const identityFields = rule['identityFields'];
592
+ const expected = rule['expected'];
593
+ if (typeof pointer !== 'string' || !Array.isArray(identityFields) || !Array.isArray(expected)) {
594
+ return fail('entity-set rule needs pointer, identityFields, and expected');
595
+ }
596
+ const actual = resolvePointer(responseBody, pointer);
597
+ if (!actual.found || !Array.isArray(actual.value)) {
598
+ return fail(`response pointer '${pointer}' is not an entity array`);
599
+ }
600
+ const actualRows = actual.value;
601
+ if (actualRows.length !== expected.length) {
602
+ return fail(`response entity set has ${String(actualRows.length)} rows, expected ${String(expected.length)}`);
603
+ }
604
+ const used = new Set();
605
+ for (let index = 0; index < expected.length; index += 1) {
606
+ const resolved = resolveExpectedValue(expected[index], valueCtx);
607
+ if (!resolved.ok || typeof resolved.value !== 'object' || resolved.value === null) {
608
+ return fail(`entity-set expected row ${String(index)} does not resolve to an entity`);
609
+ }
610
+ const expectedRow = resolved.value;
611
+ const match = actualRows.findIndex((row, rowIndex) => {
612
+ if (used.has(rowIndex) || typeof row !== 'object' || row === null)
613
+ return false;
614
+ const actualRow = row;
615
+ return (identityFields.every((column) => typeof column === 'string' &&
616
+ Object.prototype.hasOwnProperty.call(actualRow, column) &&
617
+ Object.prototype.hasOwnProperty.call(expectedRow, column) &&
618
+ valuesEqual(actualRow[column], expectedRow[column])) &&
619
+ Object.entries(expectedRow).every(([name, value]) => Object.prototype.hasOwnProperty.call(actualRow, name) && valuesEqual(actualRow[name], value)));
620
+ });
621
+ if (match === -1)
622
+ return fail(`entity-set expected row ${String(index)} matches no returned row`);
623
+ used.add(match);
624
+ }
625
+ return { ok: true };
626
+ }
627
+ if (kind === 'envelope') {
628
+ const shape = rule['schema'];
629
+ const outcome = validateEnvelopeShape(responseBody, shape);
630
+ if (!outcome.ok)
631
+ return fail(`response envelope mismatch: ${outcome.reason}`);
632
+ return { ok: true };
633
+ }
634
+ return { ok: false, reason: `'${obligationId}': unknown response rule kind '${String(kind)}' — blocked` };
635
+ }
636
+ /** Validates a response body against the approved envelope shape (bounded vocabulary). */
637
+ function validateEnvelopeShape(body, shape) {
638
+ const s = shape;
639
+ if (typeof s !== 'object' || s === null)
640
+ return { ok: false, reason: 'shape must be an object' };
641
+ switch (s.type) {
642
+ case 'object': {
643
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
644
+ return { ok: false, reason: 'expected an object' };
645
+ }
646
+ const record = body;
647
+ for (const name of s.required ?? []) {
648
+ if (!Object.prototype.hasOwnProperty.call(record, name))
649
+ return { ok: false, reason: `missing required field '${name}'` };
650
+ }
651
+ for (const [name, sub] of Object.entries(s.properties ?? {})) {
652
+ if (Object.prototype.hasOwnProperty.call(record, name)) {
653
+ const outcome = validateEnvelopeShape(record[name], sub);
654
+ if (!outcome.ok)
655
+ return { ok: false, reason: `field '${name}': ${outcome.reason}` };
656
+ }
657
+ }
658
+ for (const name of Object.keys(record)) {
659
+ if (!(name in (s.properties ?? {})))
660
+ return { ok: false, reason: `additional property '${name}' is forbidden` };
661
+ }
662
+ return { ok: true };
663
+ }
664
+ case 'array': {
665
+ if (!Array.isArray(body))
666
+ return { ok: false, reason: 'expected an array' };
667
+ if (body.length < (s.minItems ?? 0) || body.length > (s.maxItems ?? Number.MAX_SAFE_INTEGER)) {
668
+ return { ok: false, reason: 'array length out of bounds' };
669
+ }
670
+ for (let index = 0; index < body.length; index += 1) {
671
+ const outcome = validateEnvelopeShape(body[index], s.items);
672
+ if (!outcome.ok)
673
+ return { ok: false, reason: `item ${String(index)}: ${outcome.reason}` };
674
+ }
675
+ return { ok: true };
676
+ }
677
+ case 'string': {
678
+ if (typeof body !== 'string')
679
+ return { ok: false, reason: 'expected a string' };
680
+ if (body.length < (s.minLength ?? 0) || body.length > (s.maxLength ?? Number.MAX_SAFE_INTEGER)) {
681
+ return { ok: false, reason: 'string length out of bounds' };
682
+ }
683
+ if (s.enum !== undefined && !s.enum.includes(body))
684
+ return { ok: false, reason: 'string is not an approved value' };
685
+ return { ok: true };
686
+ }
687
+ case 'number': {
688
+ if (typeof body !== 'number' || !Number.isFinite(body))
689
+ return { ok: false, reason: 'expected a number' };
690
+ if (body < (s.minimum ?? -Infinity) || body > (s.maximum ?? Infinity)) {
691
+ return { ok: false, reason: 'number out of bounds' };
692
+ }
693
+ return { ok: true };
694
+ }
695
+ case 'integer': {
696
+ if (typeof body !== 'number' || !Number.isInteger(body))
697
+ return { ok: false, reason: 'expected an integer' };
698
+ if (body < (s.minimum ?? -Infinity) || body > (s.maximum ?? Infinity)) {
699
+ return { ok: false, reason: 'integer out of bounds' };
700
+ }
701
+ return { ok: true };
702
+ }
703
+ case 'boolean':
704
+ return typeof body === 'boolean' ? { ok: true } : { ok: false, reason: 'expected a boolean' };
705
+ case 'null':
706
+ return body === null ? { ok: true } : { ok: false, reason: 'expected null' };
707
+ default:
708
+ return { ok: false, reason: 'unknown shape type' };
709
+ }
710
+ }
711
+ /**
712
+ * Aggregates the required cases of one obligation (plan §4.7): resolves
713
+ * the required set from trusted context, matches case records to
714
+ * allowed planned test identities, validates provenance and bindings,
715
+ * grades each case with the pure semantic rules, and requires every
716
+ * case to pass exactly once.
717
+ */
718
+ export function evaluateRequiredCases(input) {
719
+ const obligationId = input.obligation.id;
720
+ const sortedRequired = [...new Set(input.requiredCaseIds)].sort(compareStrings);
721
+ if (sortedRequired.length === 0) {
722
+ return { status: 'missing', reason: `'${obligationId}': no required cases are declared for this obligation`, recordIds: [] };
723
+ }
724
+ const consideredIds = sortedUniqueRecordIds(input.records);
725
+ const satisfied = [];
726
+ let firstInvalid = null;
727
+ let firstMissing = null;
728
+ for (const caseId of sortedRequired) {
729
+ const outcome = gradeRequiredCase(input.obligation, caseId, input.records, input.context, input.httpRoutes);
730
+ if (outcome.status === 'satisfied') {
731
+ satisfied.push(...outcome.recordIds);
732
+ continue;
733
+ }
734
+ if (outcome.status === 'invalid' && firstInvalid === null) {
735
+ firstInvalid = { reason: outcome.reason };
736
+ }
737
+ if (outcome.status === 'missing' && firstMissing === null) {
738
+ firstMissing = { reason: outcome.reason };
739
+ }
740
+ }
741
+ if (firstInvalid !== null) {
742
+ return { status: 'invalid', reason: firstInvalid.reason, recordIds: consideredIds };
743
+ }
744
+ if (firstMissing !== null) {
745
+ return { status: 'missing', reason: firstMissing.reason, recordIds: consideredIds };
746
+ }
747
+ return { status: 'satisfied', recordIds: [...new Set(satisfied)].sort(compareStrings) };
748
+ }
749
+ function sortedUniqueRecordIds(records) {
750
+ return [...new Set(records.map((record) => (typeof record.recordId === 'string' ? record.recordId : '')))]
751
+ .filter((id) => id.length > 0)
752
+ .sort(compareStrings);
753
+ }
754
+ /** Grades one required case: binding validation then semantic grading. */
755
+ function gradeRequiredCase(obligation, caseId, records, context, httpRoutes) {
756
+ const obligationId = obligation.id;
757
+ const compiled = context.catalog.cases.find((item) => item.caseId === caseId);
758
+ if (compiled === undefined) {
759
+ return {
760
+ status: 'missing',
761
+ reason: `'${obligationId}': required case '${caseId}' is not in the compiled catalog — repair reviewed references and rerun`,
762
+ recordIds: [],
763
+ };
764
+ }
765
+ const action = compiled.definition.action;
766
+ if (compiled.definition.channel !== 'engine-http' && action['kind'] === 'request') {
767
+ const phase = compiled.definition.channel === 'engine-browser' ? 'Phase 6' : 'Phase 8';
768
+ return {
769
+ status: 'missing',
770
+ reason: `'${obligationId}': case '${compiled.definition.id}' requires the '${compiled.definition.channel}' ` +
771
+ `channel (${phase}) — blocked until that driver produces genuine evidence`,
772
+ recordIds: [],
773
+ };
774
+ }
775
+ if (action['kind'] !== 'request' && action['kind'] !== 'surface') {
776
+ return {
777
+ status: 'missing',
778
+ reason: `'${obligationId}': case '${compiled.definition.id}' uses a '${String(action['kind'])}' action ` +
779
+ `(Phase 8) — blocked until that driver produces genuine evidence`,
780
+ recordIds: [],
781
+ };
782
+ }
783
+ if (action['kind'] === 'surface') {
784
+ return gradeSurfaceCase(obligation, compiled, caseId, records, context);
785
+ }
786
+ // Candidate records: this case, engine-issued, witnessed, planned test.
787
+ // Records naming this case for another test stay other tests' business
788
+ // unless they claim THIS obligation from an unplanned test (replay).
789
+ const matched = matchSingleCaseRecord(obligation, compiled, caseId, records, context);
790
+ if (matched.kind === 'outcome')
791
+ return matched.outcome;
792
+ const { record, recordId, payload } = matched;
793
+ // Binding validation (trusted catalog vs sealed payload).
794
+ const bound = validateCaseBindings(obligation, compiled, caseId, recordId, payload, context);
795
+ if (bound.kind === 'outcome')
796
+ return bound.outcome;
797
+ // Principal attempt: re-resolve method+path against the full inventory
798
+ // with the obligation's endpoint as expected — the grader never trusts
799
+ // witness attribution blindly.
800
+ if (httpRoutes === null || httpRoutes === undefined || httpRoutes.length === 0) {
801
+ return {
802
+ status: 'missing',
803
+ reason: `'${obligationId}': no complete route inventory — endpoint attribution is impossible without every applicable route (no any-endpoint fallback)`,
804
+ recordIds: [recordId],
805
+ };
806
+ }
807
+ const expectedMethod = action['method'].toUpperCase();
808
+ const principals = [];
809
+ let bindingViolation = null;
810
+ for (const attempt of payload.attempts) {
811
+ const interpreted = interpretObservedPath(attempt.path);
812
+ if (!interpreted.ok) {
813
+ bindingViolation = `'${obligationId}': case record '${recordId}' carries a noncanonical observed path`;
814
+ continue;
815
+ }
816
+ const resolution = resolveHttpRoute(attempt.method, interpreted.path, httpRoutes, obligation.resourceId);
817
+ if (resolution.status === 'match') {
818
+ principals.push({ attempt, path: interpreted.path });
819
+ }
820
+ else if (resolution.status === 'ambiguous') {
821
+ return {
822
+ status: 'invalid',
823
+ reason: `'${obligationId}': ambiguous route attribution for ${attempt.method.toUpperCase()} ${interpreted.path} (BEHAVIOR_BINDING_MISMATCH)`,
824
+ recordIds: [recordId],
825
+ };
826
+ }
827
+ else if (resolution.status === 'mismatch' || resolution.status === 'nomatch') {
828
+ bindingViolation =
829
+ `'${obligationId}': case record '${recordId}' captures ${attempt.method.toUpperCase()} ` +
830
+ `${interpreted.path}, not the required endpoint '${obligation.resourceId}' (BEHAVIOR_BINDING_MISMATCH)`;
831
+ }
832
+ else {
833
+ return { status: 'missing', reason: `'${obligationId}': ${resolution.reason}`, recordIds: [recordId] };
834
+ }
835
+ }
836
+ if (principals.length === 0) {
837
+ return {
838
+ status: bindingViolation === null ? 'missing' : 'invalid',
839
+ reason: bindingViolation ??
840
+ `'${obligationId}': case '${compiled.definition.id}' captured no request to the required endpoint (BEHAVIOR_CASE_MISSING)`,
841
+ recordIds: [recordId],
842
+ };
843
+ }
844
+ if (principals.length > 1) {
845
+ return {
846
+ status: 'invalid',
847
+ reason: `'${obligationId}': case '${compiled.definition.id}' captures ${String(principals.length)} matching ` +
848
+ 'requests — unexplained extra mutations are ambiguity, not proof (BEHAVIOR_BINDING_MISMATCH)',
849
+ recordIds: [recordId],
850
+ };
851
+ }
852
+ const principal = principals[0];
853
+ if (principal.attempt.method.toUpperCase() !== expectedMethod) {
854
+ return {
855
+ status: 'invalid',
856
+ reason: `'${obligationId}': principal request uses ${principal.attempt.method.toUpperCase()}, expected ${expectedMethod} (BEHAVIOR_BINDING_MISMATCH)`,
857
+ recordIds: [recordId],
858
+ };
859
+ }
860
+ // Domain subjects carry no approved endpoint: HTTP attempts sealed
861
+ // for them cannot attribute to a reviewed route.
862
+ if (compiled.endpointResourceId === null && payload.attempts.length > 0) {
863
+ return {
864
+ status: 'invalid',
865
+ reason: `'${obligationId}': domain-resource case captured HTTP attempts without an approved endpoint binding — attach the case to its endpoint (BEHAVIOR_BINDING_MISMATCH)`,
866
+ recordIds: [recordId],
867
+ };
868
+ }
869
+ // Indexes first: identity-selector presence checks need before-state.
870
+ const beforeBuilt = indexSnapshots(payload.before, compiled);
871
+ const afterBuilt = indexSnapshots(payload.after, compiled);
872
+ if (!beforeBuilt.ok || !afterBuilt.ok) {
873
+ return {
874
+ status: 'invalid',
875
+ reason: `'${obligationId}': ${(beforeBuilt.ok ? afterBuilt : beforeBuilt).reason}`,
876
+ recordIds: [recordId],
877
+ };
878
+ }
879
+ const earlyBeforeIndex = beforeBuilt.index;
880
+ // Declared identity selectors: exact concrete path/query/body.
881
+ const selectorViolation = checkIdentitySelectors(compiled, payload, principal, obligationId, recordId, earlyBeforeIndex);
882
+ if (selectorViolation !== null)
883
+ return { status: 'invalid', reason: selectorViolation, recordIds: [recordId] };
884
+ // Status: the declared allowed statuses (303 included for form POSTs).
885
+ const allowedStatuses = compiled.definition.expect.statuses;
886
+ if (principal.attempt.status === null || !allowedStatuses.includes(principal.attempt.status)) {
887
+ return {
888
+ status: 'invalid',
889
+ reason: `'${obligationId}': principal request status ${String(principal.attempt.status)} is not an allowed outcome (BEHAVIOR_EFFECT_MISMATCH)`,
890
+ recordIds: [recordId],
891
+ };
892
+ }
893
+ // Request observation joined by engine request id.
894
+ const observation = payload.requestObservations.find((item) => item.engineRequestId === principal.attempt.engineRequestId);
895
+ if (observation === undefined) {
896
+ return {
897
+ status: 'invalid',
898
+ reason: `'${obligationId}': principal request has no sealed observation (OBSERVATION_SCOPE_INCOMPLETE)`,
899
+ recordIds: [recordId],
900
+ };
901
+ }
902
+ const sections = {
903
+ path: principal.path,
904
+ query: observation.query,
905
+ body: observation.body,
906
+ response: observation.responseBody,
907
+ status: observation.status,
908
+ };
909
+ const valueCtx = {
910
+ fixtureValues: payload.fixtureValues,
911
+ observation: sections,
912
+ before: earlyBeforeIndex,
913
+ };
914
+ const beforeIndex = earlyBeforeIndex;
915
+ const afterIndex = afterBuilt.index;
916
+ // Response rules (both contracts grade declared response expectations).
917
+ for (const rule of compiled.definition.expect.response) {
918
+ const graded = gradeResponseRule(obligationId, rule, observation.responseBody, valueCtx);
919
+ if (!graded.ok) {
920
+ return { status: 'invalid', reason: `${graded.reason} (BEHAVIOR_EFFECT_MISMATCH)`, recordIds: [recordId] };
921
+ }
922
+ }
923
+ // State rules grade atomically: every declared effect and every
924
+ // forbidden change in one case result.
925
+ const explained = new Map();
926
+ const scopeKeyOf = (effectId) => compiled.effects.find((effect) => effect.id === effectId)?.scope ?? null;
927
+ let sawAttemptsRule = false;
928
+ for (const rule of compiled.definition.expect.state) {
929
+ if (rule.kind === 'attempts')
930
+ sawAttemptsRule = true;
931
+ const graded = gradeStateRule(obligationId, rule, beforeIndex, afterIndex, valueCtx, explained, scopeKeyOf);
932
+ if (!graded.ok) {
933
+ if (graded.reason.includes('Phase 8')) {
934
+ return { status: 'missing', reason: graded.reason, recordIds: [recordId] };
935
+ }
936
+ return { status: 'invalid', reason: `${graded.reason} (BEHAVIOR_EFFECT_MISMATCH)`, recordIds: [recordId] };
937
+ }
938
+ }
939
+ void sawAttemptsRule;
940
+ const declaredScopes = new Set(compiled.effects.map((effect) => effect.scope));
941
+ // Read contracts additionally require zero delta on every scope.
942
+ if (obligation.contract === 'http:read-result-verified') {
943
+ for (const scope of declaredScopes) {
944
+ const beforeScope = beforeIndex.get(scope);
945
+ const afterScope = afterIndex.get(scope);
946
+ if (beforeScope === undefined || afterScope === undefined) {
947
+ return {
948
+ status: 'invalid',
949
+ reason: `'${obligationId}': scope '${scope}' lacks before/after snapshots`,
950
+ recordIds: [recordId],
951
+ };
952
+ }
953
+ const delta = scopeDelta(beforeScope, afterScope);
954
+ if (delta.added.length > 0 || delta.removed.length > 0 || delta.changed.length > 0) {
955
+ return {
956
+ status: 'invalid',
957
+ reason: `'${obligationId}': read case changed scope '${scope}' — reads cause no state change (BEHAVIOR_UNEXPECTED_EFFECT)`,
958
+ recordIds: [recordId],
959
+ };
960
+ }
961
+ }
962
+ return { status: 'satisfied', recordIds: [recordId] };
963
+ }
964
+ // Unexpected effects: any delta the rules did not explain.
965
+ for (const scope of declaredScopes) {
966
+ const beforeScope = beforeIndex.get(scope);
967
+ const afterScope = afterIndex.get(scope);
968
+ if (beforeScope === undefined || afterScope === undefined)
969
+ continue;
970
+ const ledger = explained.get(scope);
971
+ if (ledger?.complete.has(scope) === true)
972
+ continue;
973
+ const delta = scopeDelta(beforeScope, afterScope);
974
+ const unexplained = [
975
+ ...delta.added.filter((key) => !ledger?.added.has(key)),
976
+ ...delta.removed.filter((key) => !ledger?.removed.has(key)),
977
+ ...delta.changed.filter((key) => !ledger?.changed.has(key)),
978
+ ];
979
+ if (unexplained.length > 0) {
980
+ return {
981
+ status: 'invalid',
982
+ reason: `'${obligationId}': scope '${scope}' has ${String(unexplained.length)} unexplained change(s) ` +
983
+ 'beside the declared effect — a correct row plus a wrong secondary effect still fails (BEHAVIOR_UNEXPECTED_EFFECT)',
984
+ recordIds: [recordId],
985
+ };
986
+ }
987
+ }
988
+ return { status: 'satisfied', recordIds: [recordId] };
989
+ }
990
+ /**
991
+ * Matches exactly one sealed record for a required case: engine-issued,
992
+ * witnessed, planned test, claiming this obligation. Duplicates,
993
+ * replays from unplanned tests, and absence resolve here — shared by
994
+ * the request and surface grading paths.
995
+ */
996
+ function matchSingleCaseRecord(obligation, compiled, caseId, records, context) {
997
+ const obligationId = obligation.id;
998
+ const matching = [];
999
+ for (const record of records) {
1000
+ if (record.kind !== BEHAVIOR_CASE_KIND)
1001
+ continue;
1002
+ if (record.origin !== 'engine-observed' || record.trust !== 'witnessed')
1003
+ continue;
1004
+ if (typeof record.testId !== 'string')
1005
+ continue;
1006
+ const parsed = BehaviorCasePayloadSchema.safeParse(record.payload);
1007
+ if (!parsed.success || parsed.data.caseId !== caseId)
1008
+ continue;
1009
+ const planned = context.plannedTestIds.includes(record.testId);
1010
+ const claimsThis = parsed.data.obligationIds.includes(obligationId);
1011
+ if (!planned && claimsThis) {
1012
+ return {
1013
+ kind: 'outcome',
1014
+ outcome: {
1015
+ status: 'invalid',
1016
+ reason: `'${obligationId}': case '${compiled.definition.id}' record '${String(record.recordId)}' claims ` +
1017
+ 'this obligation from an unplanned test — replayed evidence never satisfies (BEHAVIOR_BINDING_MISMATCH)',
1018
+ recordIds: [String(record.recordId)],
1019
+ },
1020
+ };
1021
+ }
1022
+ if (!planned || !claimsThis)
1023
+ continue;
1024
+ matching.push(record);
1025
+ }
1026
+ if (matching.length === 0) {
1027
+ return {
1028
+ kind: 'outcome',
1029
+ outcome: {
1030
+ status: 'missing',
1031
+ reason: `'${obligationId}': required case '${compiled.definition.id}' produced no complete evidence — execute the case through its required channel (BEHAVIOR_CASE_MISSING)`,
1032
+ recordIds: [],
1033
+ },
1034
+ };
1035
+ }
1036
+ if (matching.length > 1) {
1037
+ return {
1038
+ kind: 'outcome',
1039
+ outcome: {
1040
+ status: 'invalid',
1041
+ reason: `'${obligationId}': required case '${compiled.definition.id}' has ${String(matching.length)} sealed ` +
1042
+ 'records — one execution per required case per run; duplicates are ambiguity, not proof (BEHAVIOR_BINDING_MISMATCH)',
1043
+ recordIds: matching.map((record) => String(record.recordId)).sort(compareStrings),
1044
+ },
1045
+ };
1046
+ }
1047
+ const record = matching[0];
1048
+ const payload = BehaviorCasePayloadSchema.parse(record.payload);
1049
+ return { kind: 'record', record, recordId: String(record.recordId), payload };
1050
+ }
1051
+ /**
1052
+ * Validates sealed bindings against the trusted catalog: payload
1053
+ * version, sealed state, spec digest, endpoint, channel, action digest,
1054
+ * authority profile, snapshot scope coverage, and namespace purity.
1055
+ * Shared by the request and surface grading paths.
1056
+ */
1057
+ function validateCaseBindings(obligation, compiled, caseId, recordId, payload, context) {
1058
+ const obligationId = obligation.id;
1059
+ const invalid = (reason) => ({
1060
+ kind: 'outcome',
1061
+ outcome: { status: 'invalid', reason, recordIds: [recordId] },
1062
+ });
1063
+ void caseId;
1064
+ if (payload.payloadVersion !== BEHAVIOR_CASE_PAYLOAD_VERSION) {
1065
+ return invalid(`'${obligationId}': case record '${recordId}' has an unsupported payload version`);
1066
+ }
1067
+ if (payload.state !== 'sealed') {
1068
+ return invalid(`'${obligationId}': case record '${recordId}' is not sealed`);
1069
+ }
1070
+ if (payload.caseSpecDigest !== compiled.specDigest) {
1071
+ return invalid(`'${obligationId}': case record '${recordId}' seals a different specification than the approved catalog (BEHAVIOR_BINDING_MISMATCH)`);
1072
+ }
1073
+ if (payload.endpointResourceId !== compiled.endpointResourceId) {
1074
+ return invalid(`'${obligationId}': case record '${recordId}' names a different endpoint than the approved case (BEHAVIOR_BINDING_MISMATCH)`);
1075
+ }
1076
+ if (payload.channel !== compiled.definition.channel) {
1077
+ return invalid(`'${obligationId}': case record '${recordId}' channel differs from the approved case (BEHAVIOR_BINDING_MISMATCH)`);
1078
+ }
1079
+ if (payload.actionDigest !== behaviorActionDigestOf(compiled.definition.action)) {
1080
+ return invalid(`'${obligationId}': case record '${recordId}' action digest differs from the approved case (BEHAVIOR_BINDING_MISMATCH)`);
1081
+ }
1082
+ if (context.authorityProfileDigest !== undefined &&
1083
+ context.authorityProfileDigest !== null &&
1084
+ payload.authorityProfileDigest !== context.authorityProfileDigest) {
1085
+ return invalid(`'${obligationId}': case record '${recordId}' was sealed under a different authority profile (ENFORCEMENT_UNTRUSTED)`);
1086
+ }
1087
+ const declaredScopes = new Set(compiled.effects.map((effect) => effect.scope));
1088
+ const beforeScopes = new Set(payload.before.map((snapshot) => snapshot.scope));
1089
+ const afterScopes = new Set(payload.after.map((snapshot) => snapshot.scope));
1090
+ const scopeSetsEqual = beforeScopes.size === declaredScopes.size &&
1091
+ afterScopes.size === declaredScopes.size &&
1092
+ [...declaredScopes].every((scope) => beforeScopes.has(scope) && afterScopes.has(scope));
1093
+ if (!scopeSetsEqual) {
1094
+ return invalid(`'${obligationId}': case record '${recordId}' snapshots do not cover the declared effect scopes (OBSERVATION_SCOPE_INCOMPLETE)`);
1095
+ }
1096
+ for (const snapshot of [...payload.before, ...payload.after]) {
1097
+ if (snapshot.fixtureNamespace !== payload.fixtureNamespace) {
1098
+ return invalid(`'${obligationId}': case record '${recordId}' mixes fixture namespaces (BEHAVIOR_BINDING_MISMATCH)`);
1099
+ }
1100
+ if (snapshot.complete !== true) {
1101
+ return invalid(`'${obligationId}': case record '${recordId}' seals an incomplete scope (OBSERVATION_SCOPE_INCOMPLETE)`);
1102
+ }
1103
+ }
1104
+ return { kind: 'bound' };
1105
+ }
1106
+ /**
1107
+ * Grades one surface-driven required case (Phase 6): the engine drove
1108
+ * the approved surface descriptor with the approved inputs, observed
1109
+ * the rendered result itself, and sealed the visible observation. No
1110
+ * HTTP attempt attribution applies — proof is the engine-observed
1111
+ * entity/visible outcome plus the exact state effect. Status checks do
1112
+ * not apply to surface navigation (form POST redirects are navigation,
1113
+ * not API outcomes); visible + state carry the proof.
1114
+ */
1115
+ function gradeSurfaceCase(obligation, compiled, caseId, records, context) {
1116
+ const obligationId = obligation.id;
1117
+ if (compiled.definition.channel !== 'engine-browser') {
1118
+ return {
1119
+ status: 'missing',
1120
+ reason: `'${obligationId}': surface case '${compiled.definition.id}' requires the ` +
1121
+ `'${compiled.definition.channel}' channel (Phase 8) — blocked until that driver produces genuine evidence`,
1122
+ recordIds: [],
1123
+ };
1124
+ }
1125
+ const matched = matchSingleCaseRecord(obligation, compiled, caseId, records, context);
1126
+ if (matched.kind === 'outcome')
1127
+ return matched.outcome;
1128
+ const { recordId, payload } = matched;
1129
+ const bound = validateCaseBindings(obligation, compiled, caseId, recordId, payload, context);
1130
+ if (bound.kind === 'outcome')
1131
+ return bound.outcome;
1132
+ if (payload.attempts.length > 0) {
1133
+ return {
1134
+ status: 'invalid',
1135
+ reason: `'${obligationId}': surface case record '${recordId}' carries HTTP attempts — surface proof is browser observation, not requests (BEHAVIOR_BINDING_MISMATCH)`,
1136
+ recordIds: [recordId],
1137
+ };
1138
+ }
1139
+ const browser = payload.browserObservation;
1140
+ if (browser === undefined) {
1141
+ return {
1142
+ status: 'missing',
1143
+ reason: `'${obligationId}': surface case '${compiled.definition.id}' has no sealed browser observation — the Phase 6 browser driver produces that evidence`,
1144
+ recordIds: [recordId],
1145
+ };
1146
+ }
1147
+ const beforeBuilt = indexSnapshots(payload.before, compiled);
1148
+ const afterBuilt = indexSnapshots(payload.after, compiled);
1149
+ if (!beforeBuilt.ok || !afterBuilt.ok) {
1150
+ return {
1151
+ status: 'invalid',
1152
+ reason: `'${obligationId}': ${(beforeBuilt.ok ? afterBuilt : beforeBuilt).reason}`,
1153
+ recordIds: [recordId],
1154
+ };
1155
+ }
1156
+ const valueCtx = {
1157
+ fixtureValues: payload.fixtureValues,
1158
+ observation: null,
1159
+ before: beforeBuilt.index,
1160
+ };
1161
+ const beforeIndex = beforeBuilt.index;
1162
+ const afterIndex = afterBuilt.index;
1163
+ // Visible expectation: the engine-observed entity must be the declared
1164
+ // subject, with the declared visible fields.
1165
+ const visible = compiled.definition.expect.visible;
1166
+ if (visible !== undefined) {
1167
+ const subject = resolveInputValue(visible.subject, payload.fixtureValues);
1168
+ if (!subject.ok) {
1169
+ return {
1170
+ status: 'invalid',
1171
+ reason: `'${obligationId}': visible subject does not resolve (BEHAVIOR_BINDING_MISMATCH)`,
1172
+ recordIds: [recordId],
1173
+ };
1174
+ }
1175
+ if (!valuesEqual(browser.entityId, subject.value)) {
1176
+ return {
1177
+ status: 'invalid',
1178
+ reason: `'${obligationId}': the engine-observed entity is not the declared case subject (BEHAVIOR_BINDING_MISMATCH)`,
1179
+ recordIds: [recordId],
1180
+ };
1181
+ }
1182
+ const fields = visible.fields;
1183
+ if (typeof fields === 'object' && fields !== null && !Array.isArray(fields)) {
1184
+ for (const [name, expectedRaw] of Object.entries(fields)) {
1185
+ const expected = resolveExpectedValue(expectedRaw, valueCtx);
1186
+ if (!expected.ok ||
1187
+ !Object.prototype.hasOwnProperty.call(browser.visibleFields, name) ||
1188
+ !valuesEqual(browser.visibleFields[name], expected.value)) {
1189
+ return {
1190
+ status: 'invalid',
1191
+ reason: `'${obligationId}': rendered field '${name}' differs from the declared visible outcome (BEHAVIOR_EFFECT_MISMATCH)`,
1192
+ recordIds: [recordId],
1193
+ };
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+ // State rules grade atomically, exactly like request-driven cases.
1199
+ const explained = new Map();
1200
+ const scopeKeyOf = (effectId) => compiled.effects.find((effect) => effect.id === effectId)?.scope ?? null;
1201
+ for (const rule of compiled.definition.expect.state) {
1202
+ const graded = gradeStateRule(obligationId, rule, beforeIndex, afterIndex, valueCtx, explained, scopeKeyOf);
1203
+ if (!graded.ok) {
1204
+ if (graded.reason.includes('Phase 8')) {
1205
+ return { status: 'missing', reason: graded.reason, recordIds: [recordId] };
1206
+ }
1207
+ return { status: 'invalid', reason: `${graded.reason} (BEHAVIOR_EFFECT_MISMATCH)`, recordIds: [recordId] };
1208
+ }
1209
+ }
1210
+ const declaredScopes = new Set(compiled.effects.map((effect) => effect.scope));
1211
+ for (const scope of declaredScopes) {
1212
+ const beforeScope = beforeIndex.get(scope);
1213
+ const afterScope = afterIndex.get(scope);
1214
+ if (beforeScope === undefined || afterScope === undefined)
1215
+ continue;
1216
+ const ledger = explained.get(scope);
1217
+ if (ledger?.complete.has(scope) === true)
1218
+ continue;
1219
+ const delta = scopeDelta(beforeScope, afterScope);
1220
+ const unexplained = [
1221
+ ...delta.added.filter((key) => !ledger?.added.has(key)),
1222
+ ...delta.removed.filter((key) => !ledger?.removed.has(key)),
1223
+ ...delta.changed.filter((key) => !ledger?.changed.has(key)),
1224
+ ];
1225
+ if (unexplained.length > 0) {
1226
+ return {
1227
+ status: 'invalid',
1228
+ reason: `'${obligationId}': scope '${scope}' has ${String(unexplained.length)} unexplained change(s) ` +
1229
+ 'beside the declared effect (BEHAVIOR_UNEXPECTED_EFFECT)',
1230
+ recordIds: [recordId],
1231
+ };
1232
+ }
1233
+ }
1234
+ return { status: 'satisfied', recordIds: [recordId] };
1235
+ }
1236
+ /**
1237
+ * Checks declared route/query/body identity selectors: the observed
1238
+ * concrete path must equal the template filled with resolved params,
1239
+ * and declared query/body values must equal the observed ones exactly
1240
+ * (the engine driver sends exactly the declared contract — extras are
1241
+ * undeclared behavior).
1242
+ */
1243
+ function checkIdentitySelectors(compiled, payload, principal, obligationId, recordId, before) {
1244
+ const action = compiled.definition.action;
1245
+ const observation = payload.requestObservations.find((item) => item.engineRequestId === principal.attempt.engineRequestId);
1246
+ if (observation === undefined) {
1247
+ return `'${obligationId}': case record '${recordId}' has no sealed observation for the principal request (OBSERVATION_SCOPE_INCOMPLETE)`;
1248
+ }
1249
+ // Concrete path: substitute resolved path params into the template.
1250
+ const templateSegments = action.pathTemplate.split('/');
1251
+ const resolvedSegments = [];
1252
+ const pathParamValues = [];
1253
+ for (const segment of templateSegments) {
1254
+ const match = /^\{([A-Za-z0-9_]+)\}$/.exec(segment);
1255
+ if (match === null) {
1256
+ resolvedSegments.push(segment);
1257
+ continue;
1258
+ }
1259
+ const resolved = resolveInputValue(action.path[match[1]], payload.fixtureValues);
1260
+ if (!resolved.ok || (typeof resolved.value !== 'string' && typeof resolved.value !== 'number')) {
1261
+ return `'${obligationId}': case record '${recordId}' path parameter '${match[1]}' does not resolve (BEHAVIOR_BINDING_MISMATCH)`;
1262
+ }
1263
+ pathParamValues.push(resolved.value);
1264
+ resolvedSegments.push(String(resolved.value));
1265
+ }
1266
+ const expectedPath = resolvedSegments.join('/').replace(/\/+/g, '/');
1267
+ if (principal.path !== expectedPath) {
1268
+ return `'${obligationId}': observed path '${principal.path}' is not the declared '${expectedPath}' for this subject (BEHAVIOR_BINDING_MISMATCH)`;
1269
+ }
1270
+ // Known-existing targets (plan §4.5): every resolved path parameter
1271
+ // must identify an entity present in before-state — a denial against
1272
+ // a nonexistent record might merely mean a malformed fixture.
1273
+ for (const value of pathParamValues) {
1274
+ if (!beforeContains(before, value)) {
1275
+ return `'${obligationId}': path subject '${String(value)}' does not exist in before-state — the target must be a known existing record (fixture validation)`;
1276
+ }
1277
+ }
1278
+ // Declared query must equal observed query exactly.
1279
+ const expectedQuery = {};
1280
+ for (const [name, raw] of Object.entries(action.query)) {
1281
+ const resolved = resolveInputValue(raw, payload.fixtureValues);
1282
+ if (!resolved.ok) {
1283
+ return `'${obligationId}': case record '${recordId}' query parameter '${name}' does not resolve (BEHAVIOR_BINDING_MISMATCH)`;
1284
+ }
1285
+ expectedQuery[name] = resolved.value;
1286
+ }
1287
+ if (!valuesEqual(observation.query, expectedQuery)) {
1288
+ return `'${obligationId}': observed query differs from the declared query for this subject (BEHAVIOR_BINDING_MISMATCH)`;
1289
+ }
1290
+ // Declared body must equal observed body exactly (json/form only).
1291
+ if (action.body.encoding === 'raw') {
1292
+ // Raw bodies: exact submitted bytes are graded by comparing the
1293
+ // observed body string against the fixture bytes resolved from the
1294
+ // sealed fixture values (the engine signed those exact bytes).
1295
+ const fixtureName = action.body.fixture;
1296
+ if (typeof fixtureName !== 'string') {
1297
+ return `'${obligationId}': raw body has no fixture reference (BEHAVIOR_BINDING_MISMATCH)`;
1298
+ }
1299
+ const resolved = resolveFixtureKey(payload.fixtureValues, fixtureName);
1300
+ if (!resolved.ok || typeof resolved.value !== 'string') {
1301
+ return `'${obligationId}': raw body fixture '${fixtureName}' does not resolve to exact bytes (BEHAVIOR_BINDING_MISMATCH)`;
1302
+ }
1303
+ if (observation.body !== resolved.value) {
1304
+ return `'${obligationId}': submitted raw bytes differ from the declared fixture bytes (BEHAVIOR_BINDING_MISMATCH)`;
1305
+ }
1306
+ return null;
1307
+ }
1308
+ if (action.body.encoding !== 'json' && action.body.encoding !== 'form') {
1309
+ return `'${obligationId}': '${action.body.encoding}' bodies need the Phase 6/8 driver — blocked, never satisfied`;
1310
+ }
1311
+ const expectedBody = {};
1312
+ for (const [name, raw] of Object.entries(action.body.fields ?? {})) {
1313
+ const resolved = resolveInputValue(raw, payload.fixtureValues);
1314
+ if (!resolved.ok) {
1315
+ return `'${obligationId}': case record '${recordId}' body field '${name}' does not resolve (BEHAVIOR_BINDING_MISMATCH)`;
1316
+ }
1317
+ expectedBody[name] = resolved.value;
1318
+ }
1319
+ const observedBody = observation.body;
1320
+ const observedRecord = typeof observedBody === 'object' && observedBody !== null && !Array.isArray(observedBody)
1321
+ ? observedBody
1322
+ : null;
1323
+ if (observedRecord === null || !valuesEqual(observedRecord, expectedBody)) {
1324
+ return `'${obligationId}': observed body differs from the declared body for this subject (BEHAVIOR_BINDING_MISMATCH)`;
1325
+ }
1326
+ return null;
1327
+ }
1328
+ //# sourceMappingURL=behavior.js.map