@gate-forge/core 0.1.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 (242) hide show
  1. package/LICENSE +202 -0
  2. package/dist/baselines/adoption.d.ts +76 -0
  3. package/dist/baselines/adoption.d.ts.map +1 -0
  4. package/dist/baselines/adoption.js +163 -0
  5. package/dist/baselines/adoption.js.map +1 -0
  6. package/dist/baselines/index.d.ts +102 -0
  7. package/dist/baselines/index.d.ts.map +1 -0
  8. package/dist/baselines/index.js +186 -0
  9. package/dist/baselines/index.js.map +1 -0
  10. package/dist/canonical-json.d.ts +51 -0
  11. package/dist/canonical-json.d.ts.map +1 -0
  12. package/dist/canonical-json.js +112 -0
  13. package/dist/canonical-json.js.map +1 -0
  14. package/dist/classifier/bind.d.ts +64 -0
  15. package/dist/classifier/bind.d.ts.map +1 -0
  16. package/dist/classifier/bind.js +188 -0
  17. package/dist/classifier/bind.js.map +1 -0
  18. package/dist/classifier/classify.d.ts +154 -0
  19. package/dist/classifier/classify.d.ts.map +1 -0
  20. package/dist/classifier/classify.js +1207 -0
  21. package/dist/classifier/classify.js.map +1 -0
  22. package/dist/classifier/glob.d.ts +48 -0
  23. package/dist/classifier/glob.d.ts.map +1 -0
  24. package/dist/classifier/glob.js +104 -0
  25. package/dist/classifier/glob.js.map +1 -0
  26. package/dist/classifier/index.d.ts +10 -0
  27. package/dist/classifier/index.d.ts.map +1 -0
  28. package/dist/classifier/index.js +21 -0
  29. package/dist/classifier/index.js.map +1 -0
  30. package/dist/classifier/schema.d.ts +150 -0
  31. package/dist/classifier/schema.d.ts.map +1 -0
  32. package/dist/classifier/schema.js +139 -0
  33. package/dist/classifier/schema.js.map +1 -0
  34. package/dist/config/index.d.ts +259 -0
  35. package/dist/config/index.d.ts.map +1 -0
  36. package/dist/config/index.js +523 -0
  37. package/dist/config/index.js.map +1 -0
  38. package/dist/fingerprints.d.ts +56 -0
  39. package/dist/fingerprints.d.ts.map +1 -0
  40. package/dist/fingerprints.js +59 -0
  41. package/dist/fingerprints.js.map +1 -0
  42. package/dist/graph/build.d.ts +16 -0
  43. package/dist/graph/build.d.ts.map +1 -0
  44. package/dist/graph/build.js +423 -0
  45. package/dist/graph/build.js.map +1 -0
  46. package/dist/graph/index.d.ts +12 -0
  47. package/dist/graph/index.d.ts.map +1 -0
  48. package/dist/graph/index.js +10 -0
  49. package/dist/graph/index.js.map +1 -0
  50. package/dist/graph/schema.d.ts +448 -0
  51. package/dist/graph/schema.d.ts.map +1 -0
  52. package/dist/graph/schema.js +237 -0
  53. package/dist/graph/schema.js.map +1 -0
  54. package/dist/graph/symbols.d.ts +129 -0
  55. package/dist/graph/symbols.d.ts.map +1 -0
  56. package/dist/graph/symbols.js +177 -0
  57. package/dist/graph/symbols.js.map +1 -0
  58. package/dist/graph/util.d.ts +40 -0
  59. package/dist/graph/util.d.ts.map +1 -0
  60. package/dist/graph/util.js +41 -0
  61. package/dist/graph/util.js.map +1 -0
  62. package/dist/index.d.ts +683 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +563 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/mapping/resolve.d.ts +177 -0
  67. package/dist/mapping/resolve.d.ts.map +1 -0
  68. package/dist/mapping/resolve.js +523 -0
  69. package/dist/mapping/resolve.js.map +1 -0
  70. package/dist/policy/coverage.d.ts +79 -0
  71. package/dist/policy/coverage.d.ts.map +1 -0
  72. package/dist/policy/coverage.js +119 -0
  73. package/dist/policy/coverage.js.map +1 -0
  74. package/dist/policy/evaluate.d.ts +275 -0
  75. package/dist/policy/evaluate.d.ts.map +1 -0
  76. package/dist/policy/evaluate.js +382 -0
  77. package/dist/policy/evaluate.js.map +1 -0
  78. package/dist/policy/index.d.ts +15 -0
  79. package/dist/policy/index.d.ts.map +1 -0
  80. package/dist/policy/index.js +14 -0
  81. package/dist/policy/index.js.map +1 -0
  82. package/dist/policy/trusted.d.ts +59 -0
  83. package/dist/policy/trusted.d.ts.map +1 -0
  84. package/dist/policy/trusted.js +96 -0
  85. package/dist/policy/trusted.js.map +1 -0
  86. package/dist/provenance.d.ts +170 -0
  87. package/dist/provenance.d.ts.map +1 -0
  88. package/dist/provenance.js +306 -0
  89. package/dist/provenance.js.map +1 -0
  90. package/dist/receipt/index.d.ts +74 -0
  91. package/dist/receipt/index.d.ts.map +1 -0
  92. package/dist/receipt/index.js +154 -0
  93. package/dist/receipt/index.js.map +1 -0
  94. package/dist/report/index.d.ts +100 -0
  95. package/dist/report/index.d.ts.map +1 -0
  96. package/dist/report/index.js +355 -0
  97. package/dist/report/index.js.map +1 -0
  98. package/dist/schemas/adoption.d.ts +40 -0
  99. package/dist/schemas/adoption.d.ts.map +1 -0
  100. package/dist/schemas/adoption.js +87 -0
  101. package/dist/schemas/adoption.js.map +1 -0
  102. package/dist/schemas/baseline.d.ts +19 -0
  103. package/dist/schemas/baseline.d.ts.map +1 -0
  104. package/dist/schemas/baseline.js +45 -0
  105. package/dist/schemas/baseline.js.map +1 -0
  106. package/dist/schemas/claim.d.ts +32 -0
  107. package/dist/schemas/claim.d.ts.map +1 -0
  108. package/dist/schemas/claim.js +34 -0
  109. package/dist/schemas/claim.js.map +1 -0
  110. package/dist/schemas/classification-policy.d.ts +84 -0
  111. package/dist/schemas/classification-policy.d.ts.map +1 -0
  112. package/dist/schemas/classification-policy.js +130 -0
  113. package/dist/schemas/classification-policy.js.map +1 -0
  114. package/dist/schemas/classification-signal.d.ts +125 -0
  115. package/dist/schemas/classification-signal.d.ts.map +1 -0
  116. package/dist/schemas/classification-signal.js +126 -0
  117. package/dist/schemas/classification-signal.js.map +1 -0
  118. package/dist/schemas/classification.d.ts +120 -0
  119. package/dist/schemas/classification.d.ts.map +1 -0
  120. package/dist/schemas/classification.js +154 -0
  121. package/dist/schemas/classification.js.map +1 -0
  122. package/dist/schemas/common.d.ts +73 -0
  123. package/dist/schemas/common.d.ts.map +1 -0
  124. package/dist/schemas/common.js +60 -0
  125. package/dist/schemas/common.js.map +1 -0
  126. package/dist/schemas/coverage-policy.d.ts +89 -0
  127. package/dist/schemas/coverage-policy.d.ts.map +1 -0
  128. package/dist/schemas/coverage-policy.js +81 -0
  129. package/dist/schemas/coverage-policy.js.map +1 -0
  130. package/dist/schemas/evidence.d.ts +65 -0
  131. package/dist/schemas/evidence.d.ts.map +1 -0
  132. package/dist/schemas/evidence.js +64 -0
  133. package/dist/schemas/evidence.js.map +1 -0
  134. package/dist/schemas/execution-result.d.ts +214 -0
  135. package/dist/schemas/execution-result.d.ts.map +1 -0
  136. package/dist/schemas/execution-result.js +240 -0
  137. package/dist/schemas/execution-result.js.map +1 -0
  138. package/dist/schemas/gate-receipt.d.ts +86 -0
  139. package/dist/schemas/gate-receipt.d.ts.map +1 -0
  140. package/dist/schemas/gate-receipt.js +193 -0
  141. package/dist/schemas/gate-receipt.js.map +1 -0
  142. package/dist/schemas/index.d.ts +22 -0
  143. package/dist/schemas/index.d.ts.map +1 -0
  144. package/dist/schemas/index.js +22 -0
  145. package/dist/schemas/index.js.map +1 -0
  146. package/dist/schemas/obligation.d.ts +37 -0
  147. package/dist/schemas/obligation.d.ts.map +1 -0
  148. package/dist/schemas/obligation.js +46 -0
  149. package/dist/schemas/obligation.js.map +1 -0
  150. package/dist/schemas/plugin.d.ts +19 -0
  151. package/dist/schemas/plugin.d.ts.map +1 -0
  152. package/dist/schemas/plugin.js +20 -0
  153. package/dist/schemas/plugin.js.map +1 -0
  154. package/dist/schemas/policy.d.ts +73 -0
  155. package/dist/schemas/policy.d.ts.map +1 -0
  156. package/dist/schemas/policy.js +46 -0
  157. package/dist/schemas/policy.js.map +1 -0
  158. package/dist/schemas/resource.d.ts +26 -0
  159. package/dist/schemas/resource.d.ts.map +1 -0
  160. package/dist/schemas/resource.js +29 -0
  161. package/dist/schemas/resource.js.map +1 -0
  162. package/dist/schemas/run-manifest.d.ts +72 -0
  163. package/dist/schemas/run-manifest.d.ts.map +1 -0
  164. package/dist/schemas/run-manifest.js +125 -0
  165. package/dist/schemas/run-manifest.js.map +1 -0
  166. package/dist/schemas/runner-adapter.d.ts +169 -0
  167. package/dist/schemas/runner-adapter.d.ts.map +1 -0
  168. package/dist/schemas/runner-adapter.js +2 -0
  169. package/dist/schemas/runner-adapter.js.map +1 -0
  170. package/dist/schemas/test-catalog.d.ts +472 -0
  171. package/dist/schemas/test-catalog.d.ts.map +1 -0
  172. package/dist/schemas/test-catalog.js +311 -0
  173. package/dist/schemas/test-catalog.js.map +1 -0
  174. package/dist/schemas/test-map.d.ts +99 -0
  175. package/dist/schemas/test-map.d.ts.map +1 -0
  176. package/dist/schemas/test-map.js +103 -0
  177. package/dist/schemas/test-map.js.map +1 -0
  178. package/dist/schemas/verdict.d.ts +73 -0
  179. package/dist/schemas/verdict.d.ts.map +1 -0
  180. package/dist/schemas/verdict.js +92 -0
  181. package/dist/schemas/verdict.js.map +1 -0
  182. package/dist/schemas/waiver.d.ts +36 -0
  183. package/dist/schemas/waiver.d.ts.map +1 -0
  184. package/dist/schemas/waiver.js +42 -0
  185. package/dist/schemas/waiver.js.map +1 -0
  186. package/dist/supervision/index.d.ts +170 -0
  187. package/dist/supervision/index.d.ts.map +1 -0
  188. package/dist/supervision/index.js +354 -0
  189. package/dist/supervision/index.js.map +1 -0
  190. package/dist/testing/clock.d.ts +50 -0
  191. package/dist/testing/clock.d.ts.map +1 -0
  192. package/dist/testing/clock.js +75 -0
  193. package/dist/testing/clock.js.map +1 -0
  194. package/dist/testing/env.d.ts +66 -0
  195. package/dist/testing/env.d.ts.map +1 -0
  196. package/dist/testing/env.js +106 -0
  197. package/dist/testing/env.js.map +1 -0
  198. package/dist/testing/gate-runner.d.ts +141 -0
  199. package/dist/testing/gate-runner.d.ts.map +1 -0
  200. package/dist/testing/gate-runner.js +173 -0
  201. package/dist/testing/gate-runner.js.map +1 -0
  202. package/dist/testing/gf19.d.ts +106 -0
  203. package/dist/testing/gf19.d.ts.map +1 -0
  204. package/dist/testing/gf19.js +282 -0
  205. package/dist/testing/gf19.js.map +1 -0
  206. package/dist/testing/index.d.ts +20 -0
  207. package/dist/testing/index.d.ts.map +1 -0
  208. package/dist/testing/index.js +14 -0
  209. package/dist/testing/index.js.map +1 -0
  210. package/dist/testing/red-probe.d.ts +138 -0
  211. package/dist/testing/red-probe.d.ts.map +1 -0
  212. package/dist/testing/red-probe.js +251 -0
  213. package/dist/testing/red-probe.js.map +1 -0
  214. package/dist/testing/temp-repo.d.ts +126 -0
  215. package/dist/testing/temp-repo.d.ts.map +1 -0
  216. package/dist/testing/temp-repo.js +214 -0
  217. package/dist/testing/temp-repo.js.map +1 -0
  218. package/dist/verdict/cause.d.ts +99 -0
  219. package/dist/verdict/cause.d.ts.map +1 -0
  220. package/dist/verdict/cause.js +206 -0
  221. package/dist/verdict/cause.js.map +1 -0
  222. package/dist/verdict/evaluate.d.ts +135 -0
  223. package/dist/verdict/evaluate.d.ts.map +1 -0
  224. package/dist/verdict/evaluate.js +1513 -0
  225. package/dist/verdict/evaluate.js.map +1 -0
  226. package/dist/verdict/index.d.ts +21 -0
  227. package/dist/verdict/index.d.ts.map +1 -0
  228. package/dist/verdict/index.js +20 -0
  229. package/dist/verdict/index.js.map +1 -0
  230. package/dist/verdict/pack-verifiers.d.ts +97 -0
  231. package/dist/verdict/pack-verifiers.d.ts.map +1 -0
  232. package/dist/verdict/pack-verifiers.js +763 -0
  233. package/dist/verdict/pack-verifiers.js.map +1 -0
  234. package/dist/verdict/registry.d.ts +180 -0
  235. package/dist/verdict/registry.d.ts.map +1 -0
  236. package/dist/verdict/registry.js +67 -0
  237. package/dist/verdict/registry.js.map +1 -0
  238. package/dist/waivers/index.d.ts +103 -0
  239. package/dist/waivers/index.d.ts.map +1 -0
  240. package/dist/waivers/index.js +192 -0
  241. package/dist/waivers/index.js.map +1 -0
  242. package/package.json +40 -0
@@ -0,0 +1,1513 @@
1
+ /**
2
+ * Verdict engine (Interface pin #9, ADR 0001 D1–D4): the pure evaluator
3
+ * that turns one obligation plus its run evidence into one of the seven
4
+ * verdicts.
5
+ *
6
+ * Contract (pin #9): `evaluateObligation(obligation, {claims, records,
7
+ * waivers, classification, now}) → {verdict, reason, recordIds}` —
8
+ * deterministic, no I/O, clock injected via `now`.
9
+ *
10
+ * Rules encoded here:
11
+ * - `satisfied` requires a `ui.action` record matching the contract's
12
+ * operation PLUS a service-witnessed `persistence.*` record for the
13
+ * SAME entity that MEETS THE OPERATION'S POSTCONDITION (plan §5.3).
14
+ * Trust asymmetry (2026-08-31 audits): the UI action is suite-asserted
15
+ * — submitted through the run token, stamped claimed-tier at issuance
16
+ * — and only anchors the entity/operation. The satisfaction weight is
17
+ * the persistence record, whose contents the witness observed itself
18
+ * via the engine-side adapter read (`origin: 'engine-observed'`), and
19
+ * the engine grades that observation against the claimed operation
20
+ * with EXPECTATIONS THAT NEVER COME FROM THE SUITE (round 5):
21
+ * create ⇒ engine-observed absence before + presence after; update ⇒
22
+ * an engine-observed before/after field delta; read ⇒ presence;
23
+ * delete ⇒ absent (hard) or matching the classification's
24
+ * owner-declared `archiveFields` (archive). Fabricated persistence
25
+ * records demote to claimed and can never satisfy (D2, GF-23).
26
+ * - `crud:<op>` (UI-semantic, plan Phase 1 item 8 + §3.6) is graded on
27
+ * the SUPERVISED SESSION CHANNEL only: within one witness session the
28
+ * claim needs (i) a provenanced session-bound `ui.action` with the
29
+ * matching operation, (ii) the witness-observed HTTP exchange of that
30
+ * same session — issued only for traffic traversing the session's
31
+ * dedicated proxy INSIDE a witness-kept action interval — attributed
32
+ * by the complete host route inventory, (iii) a session-bound visible
33
+ * result for the same entity, and (iv) the engine-observed
34
+ * persistence postcondition + exact-value echo on that entity. Every
35
+ * shortcut grades typed-blocking (session binding, unobserved direct
36
+ * mutation, value mismatch, missing visible result). Contracts
37
+ * outside the persistence/crud namespaces still fail closed.
38
+ * - Records whose provenance does not verify — a recordId that does not
39
+ * recompute from the record's own contents (sha256 over the canonical
40
+ * identity) — are demoted to `claimed` regardless of their `trust`
41
+ * field (pin #7, GF-23).
42
+ * - Same-entity enforcement (invariant 3): every satisfying record carries
43
+ * an `entityId` equal to the UI action's entity. Composite identity is a
44
+ * column-keyed object whose keys are exactly the `primaryKey` columns
45
+ * (D3); single-column resources require a scalar id.
46
+ * - Internal resources carry no CRUD obligations; their claims are invalid
47
+ * (ADR 0001, matching the policy engine's claim assessment).
48
+ * - Unclassified resources block as `unclassified` (invariant 1).
49
+ * Unresolved resources never reach this evaluator: they generate no
50
+ * obligations — the policy engine emits blocking entries for them.
51
+ * - A waiver matching the exact (resourceId, fingerprint) pair that is
52
+ * unexpired yields `waived`; an expired waiver yields `invalid` (D4);
53
+ * a waiver whose owner is stale yields `stale` (GF-17).
54
+ */
55
+ import { z } from 'zod';
56
+ import { capabilityFor, registerContractCapabilities, registerContractVerifier, verifierFor, } from './registry.js';
57
+ import { registerPackVerifiers, interpretObservedPath, resolveHttpRoute } from './pack-verifiers.js';
58
+ import { causeForVerdict } from './cause.js';
59
+ import { canonicalJson } from '../canonical-json.js';
60
+ import { fingerprint } from '../fingerprints.js';
61
+ import { compareStrings } from '../graph/util.js';
62
+ import { isProvenancedRecord } from '../provenance.js';
63
+ import { ClassificationSchema } from '../schemas/classification.js';
64
+ import { ClaimSchema } from '../schemas/claim.js';
65
+ import { ObligationSchema } from '../schemas/obligation.js';
66
+ import { WaiverSchema } from '../schemas/waiver.js';
67
+ import { CRUD_CONTRACT_PREFIX, PERSISTENCE_CONTRACT_PREFIX } from '../policy/index.js';
68
+ /** Evidence kinds the built-in CRUD contract speaks (plan §5.3). */
69
+ const UI_ACTION_KIND = 'ui.action';
70
+ const UI_VISIBLE_KIND = 'ui.visible-result';
71
+ const PERSISTENCE_KIND_PREFIX = 'persistence.';
72
+ /**
73
+ * The server-witnessed persistence channel (product-gap fix: backend-only
74
+ * tables — e.g. a transactional outbox — can never honestly appear in a
75
+ * UI, so their `persistence:*` obligations were unprovable by design).
76
+ * A `persistence.entity` record the witness stamped from its OWN
77
+ * server-side adapter probe carries `payload.channel: 'server'` plus
78
+ * `payload.declaredKind: 'server-e2e'`; both ride in the payload, so the
79
+ * provenance hash AND the v2 attestation MAC cover them exactly like the
80
+ * rest of the witnessed ledger.
81
+ */
82
+ const SERVER_CHANNEL = 'server';
83
+ /**
84
+ * The mapping kind that unlocks the server channel: the witness stamps
85
+ * `declaredKind` only for obligations the trusted supervisor registered
86
+ * as `server-e2e` on the verifier-key surface (`POST
87
+ * /runs/server-e2e-declarations`), so a provenance-verified record
88
+ * carrying the stamp IS the "claim declared server-e2e" fact. WHY via
89
+ * record metadata: the test-map `kind` resolves in the CLI mapping layer
90
+ * and does not reach core grading as a claim field today, so the fact is
91
+ * threaded through the metadata the witness already stamps — enforced at
92
+ * issuance, verified here, and impossible for the suite to self-declare.
93
+ */
94
+ const SERVER_E2E_KIND = 'server-e2e';
95
+ /** Verdicts that block a run (exit code 1). Clean: satisfied, waived. */
96
+ export const BLOCKING_VERDICTS = [
97
+ 'missing',
98
+ 'invalid',
99
+ 'unclassified',
100
+ 'unresolved',
101
+ 'stale',
102
+ ];
103
+ /**
104
+ * Fail-closed verdict-engine error: raised only for engine-internal
105
+ * contract violations (malformed obligation, invalid `now`) — never for
106
+ * adversary-controlled evidence, which must degrade to a verdict.
107
+ */
108
+ export class GateforgeVerdictError extends Error {
109
+ constructor(message) {
110
+ super(message);
111
+ this.name = 'GateforgeVerdictError';
112
+ }
113
+ }
114
+ /**
115
+ * Normalizes the injected clock to a Date. Accepts Date or ISO-8601
116
+ * string; anything else is an engine-internal contract violation.
117
+ *
118
+ * Args:
119
+ * now: the injected clock instant.
120
+ *
121
+ * Returns:
122
+ * Date: the parsed instant.
123
+ *
124
+ * Throws:
125
+ * GateforgeVerdictError: when `now` is not a valid ISO-8601 instant.
126
+ */
127
+ export function parseInstant(now) {
128
+ if (now instanceof Date) {
129
+ if (Number.isNaN(now.getTime())) {
130
+ throw new GateforgeVerdictError('now: invalid Date (NaN time)');
131
+ }
132
+ return now;
133
+ }
134
+ const parsed = z.iso.datetime().safeParse(now);
135
+ if (!parsed.success) {
136
+ throw new GateforgeVerdictError(`now: expected an ISO-8601 instant, got ${JSON.stringify(now)}`);
137
+ }
138
+ return new Date(parsed.data);
139
+ }
140
+ /**
141
+ * Reads one evidence entry into the lenient view; non-objects are
142
+ * ignored (a hostile reporter may emit anything).
143
+ */
144
+ function asRecord(value) {
145
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
146
+ return null;
147
+ }
148
+ const record = value;
149
+ return {
150
+ recordId: record['recordId'],
151
+ runId: record['runId'],
152
+ trust: record['trust'],
153
+ obligationId: record['obligationId'],
154
+ testId: record['testId'],
155
+ kind: record['kind'],
156
+ origin: record['origin'],
157
+ payload: record['payload'],
158
+ };
159
+ }
160
+ /**
161
+ * Derives the trust tier of a record (D2 + pin #7): `witnessed` only when
162
+ * the record asserts the witnessed tier AND its provenance verifies —
163
+ * the 64-hex recordId must recompute from the record's own contents
164
+ * (`sha256` over the canonical identity; pin #1). Everything else is
165
+ * claimed-tier: GF-23 fabricated bundles and transplanted-but-never-
166
+ * issued ids demote here. Issuance membership (the recordId appearing
167
+ * in the witness-issued manifest set) is enforced separately by the
168
+ * CLI's provenance gate, which owns the run manifest.
169
+ */
170
+ function trustOf(record) {
171
+ return record.trust === 'witnessed' && isProvenancedRecord(record) ? 'witnessed' : 'claimed';
172
+ }
173
+ /**
174
+ * Stable label for a record in reasons, provenance-aware: unprovenanced
175
+ * records (the interesting adversarial case) label themselves as such.
176
+ * Shared by every record-citing reason so test assertions stay in
177
+ * lockstep with emitted text.
178
+ */
179
+ function labelOf(record) {
180
+ return typeof record.recordId === 'string' && record.recordId.length > 0
181
+ ? record.recordId
182
+ : '<unprovenanced>';
183
+ }
184
+ function isPlainObject(value) {
185
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
186
+ }
187
+ function isPrimitive(value) {
188
+ if (typeof value === 'number')
189
+ return Number.isFinite(value);
190
+ return typeof value === 'string' || typeof value === 'boolean';
191
+ }
192
+ function isJsonValue(value) {
193
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
194
+ return true;
195
+ }
196
+ if (typeof value === 'number')
197
+ return Number.isFinite(value);
198
+ if (Array.isArray(value))
199
+ return value.every(isJsonValue);
200
+ if (isPlainObject(value))
201
+ return Object.values(value).every(isJsonValue);
202
+ return false;
203
+ }
204
+ /**
205
+ * Extracts the operation a persistence-level contract requires
206
+ * (`persistence:update` → `update`); null for anything else.
207
+ *
208
+ * Dispatch:
209
+ * - `persistence:<op>` — UI-independent CRUD, graded on the witness's
210
+ * own engine-side observations with owner/classification-owned
211
+ * expectations;
212
+ * - `crud:<op>` — UI-SEMANTIC: graded by the session-channel verifier
213
+ * below (supervised witness session + observed exchange + interval +
214
+ * persistence echo);
215
+ * - everything else — no semantic verifier registered, fail closed.
216
+ */
217
+ function persistenceOperation(contract) {
218
+ if (!contract.startsWith(PERSISTENCE_CONTRACT_PREFIX))
219
+ return null;
220
+ const operation = contract.slice(PERSISTENCE_CONTRACT_PREFIX.length);
221
+ if (operation === 'create' || operation === 'read' || operation === 'update' || operation === 'delete') {
222
+ return operation;
223
+ }
224
+ return null;
225
+ }
226
+ /** The payload of a record when it is a plain object, else undefined. */
227
+ function payloadOf(record) {
228
+ return isPlainObject(record.payload) ? record.payload : undefined;
229
+ }
230
+ /**
231
+ * Deduplicates string ids into a sorted array without Set: string-keyed
232
+ * membership uses a Record lookup so the seen-table serializes and
233
+ * diffs like any other literal object.
234
+ */
235
+ function sortedUnique(ids) {
236
+ const seen = {};
237
+ const unique = [];
238
+ for (const id of ids) {
239
+ if (id.length === 0 || id in seen)
240
+ continue;
241
+ seen[id] = true;
242
+ unique.push(id);
243
+ }
244
+ return unique.sort(compareStrings);
245
+ }
246
+ /**
247
+ * Validates an entityId against the classification's primaryKey (D3) and
248
+ * returns its canonical comparable form. Single-column resources require
249
+ * a scalar id; composite resources require a column-keyed object whose
250
+ * key set is exactly the primaryKey columns with primitive values.
251
+ * Missing or unknown key parts are checkable violations (ADR 0001 D3).
252
+ *
253
+ * Args:
254
+ * entityId: the raw entityId from a record payload.
255
+ * primaryKey: the ordered primary-key columns of the classification.
256
+ *
257
+ * Returns:
258
+ * {ok: true, key} when valid — `key` is the canonical JSON used for
259
+ * equality comparison and reasons; {ok: false, detail} otherwise.
260
+ */
261
+ function normalizeEntityId(entityId, primaryKey) {
262
+ if (primaryKey.length === 1) {
263
+ if (!isPrimitive(entityId)) {
264
+ return {
265
+ ok: false,
266
+ detail: isPlainObject(entityId) || Array.isArray(entityId)
267
+ ? `scalar entityId is required for single-column primary key '${primaryKey[0]}' (composite identity is column-keyed per ADR 0001 D3)`
268
+ : 'entityId must be a string/number/boolean scalar',
269
+ };
270
+ }
271
+ if (typeof entityId === 'string' && entityId.length === 0) {
272
+ return { ok: false, detail: 'entityId must not be empty' };
273
+ }
274
+ return { ok: true, key: canonicalJson(entityId) };
275
+ }
276
+ if (!isPlainObject(entityId)) {
277
+ return {
278
+ ok: false,
279
+ detail: `composite entityId must be a column-keyed object with keys ` +
280
+ `[${primaryKey.join(', ')}] (ADR 0001 D3)`,
281
+ };
282
+ }
283
+ const keys = Object.keys(entityId);
284
+ const missing = primaryKey.filter((column) => !keys.includes(column));
285
+ if (missing.length > 0) {
286
+ return { ok: false, detail: `entityId is missing key parts: [${missing.join(', ')}]` };
287
+ }
288
+ const unknownKeys = keys.filter((key) => !primaryKey.includes(key));
289
+ if (unknownKeys.length > 0) {
290
+ return {
291
+ ok: false,
292
+ detail: `entityId carries unknown key parts: [${unknownKeys.sort().join(', ')}]`,
293
+ };
294
+ }
295
+ for (const column of primaryKey) {
296
+ if (!isPrimitive(entityId[column])) {
297
+ return {
298
+ ok: false,
299
+ detail: `entityId column '${column}' must be a string/number/boolean primitive`,
300
+ };
301
+ }
302
+ }
303
+ return { ok: true, key: canonicalJson(entityId) };
304
+ }
305
+ /**
306
+ * When both visible and persisted field maps are present, verifies that
307
+ * every shared key agrees (plan §5.3: visible and persisted fields must
308
+ * agree). Returns the first disagreement description, or null.
309
+ */
310
+ function fieldsDisagreement(visible, persisted) {
311
+ if (!isPlainObject(visible) || !isPlainObject(persisted))
312
+ return null;
313
+ const shared = Object.keys(visible).filter((key) => key in persisted);
314
+ for (const key of shared) {
315
+ const a = visible[key];
316
+ const b = persisted[key];
317
+ if (!isJsonValue(a) || !isJsonValue(b))
318
+ continue;
319
+ if (canonicalJson(a) !== canonicalJson(b)) {
320
+ return `'${key}': visible ${canonicalJson(a)} vs persisted ${canonicalJson(b)}`;
321
+ }
322
+ }
323
+ return null;
324
+ }
325
+ /**
326
+ * Compares owner-declared expected field values against the
327
+ * engine-observed persisted fields: every expected key must exist and
328
+ * agree (canonical-JSON equality). Returns the first mismatch
329
+ * description, or null. The EXPECTATIONS come from the classification
330
+ * (owner-owned) — never from the tested suite (audit round 5).
331
+ */
332
+ function declaredFieldsMatchFailure(expected, observed, what) {
333
+ if (!isPlainObject(expected) || Object.keys(expected).length === 0) {
334
+ return `${what} postcondition cannot be evaluated: the classification declares no expected fields`;
335
+ }
336
+ if (!isPlainObject(observed)) {
337
+ return `${what} postcondition violated: the engine observed no persisted fields`;
338
+ }
339
+ for (const key of Object.keys(expected).sort()) {
340
+ const expectedValue = expected[key];
341
+ if (!isJsonValue(expectedValue))
342
+ continue;
343
+ const observedValue = observed[key];
344
+ if (!isJsonValue(observedValue) || canonicalJson(expectedValue) !== canonicalJson(observedValue)) {
345
+ return (`${what} postcondition violated: persisted fields do not match the classification-declared ` +
346
+ `state on '${key}' (expected ${canonicalJson(expectedValue)}, persisted ` +
347
+ `${isJsonValue(observedValue) ? canonicalJson(observedValue) : '<none>'})`);
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+ /**
353
+ * Exact-value echo (plan §3.6, adopted from the consumer precedent):
354
+ * for a create/update obligation whose journey collects input in the UI,
355
+ * the witnessed `ui.action` record's DECLARED INPUT fields (what the
356
+ * journey entered) must be echoed exactly by the independently fetched
357
+ * persisted fields on the SAME entity the engine holds. Both sides are
358
+ * engine-held records past provenance (the witness issued them under the
359
+ * supervisor-bound session) — the suite cannot forge either. A 2xx
360
+ * status or row presence alone is insufficient: an echoed value that
361
+ * differs fails the obligation with `EVIDENCE_VALUE_MISMATCH` even when
362
+ * the status was 200. Delete (archive) postconditions are owner-graded
363
+ * via `archiveFields` and carry no entered-input echo.
364
+ *
365
+ * Returns:
366
+ * string | null: the first echo-violation description, or null when
367
+ * every declared input value is echoed exactly by the persisted state.
368
+ */
369
+ function exactValueEchoFailure(operation, actionRecord, persistenceRecord) {
370
+ const entered = payloadOf(actionRecord)?.['fields'];
371
+ if (!isPlainObject(entered) || Object.keys(entered).length === 0) {
372
+ return (`exact-value echo violation (EVIDENCE_VALUE_MISMATCH): the '${UI_ACTION_KIND}' record ` +
373
+ `'${labelOf(actionRecord)}' declares no input fields, so the persisted state cannot be ` +
374
+ `echo-checked for the '${operation}' obligation (plan §3.6 requires the journey's ` +
375
+ 'entered values to come back exactly on the same entity)');
376
+ }
377
+ const persisted = payloadOf(persistenceRecord)?.['fields'];
378
+ if (!isPlainObject(persisted)) {
379
+ return (`exact-value echo violation (EVIDENCE_VALUE_MISMATCH): the persistence record ` +
380
+ `'${labelOf(persistenceRecord)}' observed no persisted fields to echo the ` +
381
+ `'${operation}' input against`);
382
+ }
383
+ for (const key of Object.keys(entered).sort()) {
384
+ const enteredValue = entered[key];
385
+ if (!isJsonValue(enteredValue))
386
+ continue;
387
+ const persistedValue = persisted[key];
388
+ if (!isJsonValue(persistedValue) || canonicalJson(persistedValue) !== canonicalJson(enteredValue)) {
389
+ return (`exact-value echo violation (EVIDENCE_VALUE_MISMATCH): the '${UI_ACTION_KIND}' declared ` +
390
+ `input ${key}=${canonicalJson(enteredValue)} but the engine-observed persisted fields on ` +
391
+ `the same entity carry ${isJsonValue(persistedValue) ? canonicalJson(persistedValue) : '<none>'} — a 2xx status or row presence alone is insufficient (plan §3.6)`);
392
+ }
393
+ }
394
+ return null;
395
+ }
396
+ /**
397
+ * The operation-specific postcondition a witnessed persistence record
398
+ * must meet for the claim to be satisfiable. Every EXPECTATION is
399
+ * owner-owned (classification) or engine-observed (pre-observation
400
+ * delta) — never suite-supplied (audit round 5):
401
+ * - create: the engine observed the entity ABSENT before (a witness
402
+ * id-set pre-observation bound to this read) and PRESENT after.
403
+ * - update: a witness entity pre-observation exists, and the engine
404
+ * observed an actual field delta between it and the post-action read.
405
+ * - read: the entity is present in the engine-observed state.
406
+ * - delete: hard delete ⇒ entity absent; archive ⇒ entity present and
407
+ * matching the classification's `archiveFields`.
408
+ *
409
+ * Args:
410
+ * obligation: the obligation under grading (lifecycle expectations).
411
+ * operation: the CRUD operation the contract requires (from
412
+ * `persistence:<op>` or `crud:<op>`).
413
+ * record: the witnessed persistence record being graded.
414
+ * actionEntityKey: canonical entityId key of the anchoring UI action.
415
+ *
416
+ * Returns:
417
+ * string | null: the first postcondition failure, or null when met.
418
+ */
419
+ function persistencePostconditionFailure(obligation, operation, record, actionEntityKey) {
420
+ const payload = payloadOf(record);
421
+ if (payload === undefined) {
422
+ return `persistence record '${labelOf(record)}' carries no payload to evaluate`;
423
+ }
424
+ if (typeof payload['found'] !== 'boolean') {
425
+ return (`persistence record '${labelOf(record)}' carries no engine-observed presence ` +
426
+ `observation ('found'), so the '${obligation.contract}' postcondition cannot be evaluated`);
427
+ }
428
+ const found = payload['found'];
429
+ const before = payload['before'];
430
+ if (operation === 'create') {
431
+ if (!isPlainObject(before) || before['entityAbsent'] !== true) {
432
+ return 'create postcondition violated: no engine-observed pre-observation shows the entity absent before the action';
433
+ }
434
+ if (!found) {
435
+ return 'create postcondition violated: entity still absent after the action';
436
+ }
437
+ return null;
438
+ }
439
+ if (operation === 'update') {
440
+ if (!isPlainObject(before) ||
441
+ before['found'] !== true ||
442
+ !isPlainObject(before['fields'])) {
443
+ return 'update postcondition violated: no engine-observed before-state (a witness pre-observation of the entity is required)';
444
+ }
445
+ if (!found) {
446
+ return 'update postcondition violated: entity absent after the action';
447
+ }
448
+ // Owner-owned relevance (audit round 6): the delta must touch at
449
+ // least one classification-declared updateable field. Bookkeeping
450
+ // columns (e.g. `updated_at`) drifting on an untouched entity can
451
+ // never satisfy.
452
+ const updateable = obligation.lifecycle.updateableFields;
453
+ if (!Array.isArray(updateable) || updateable.length === 0) {
454
+ return 'update postcondition cannot be evaluated: the classification declares no updateableFields';
455
+ }
456
+ if (!isPlainObject(payload['fields'])) {
457
+ return 'update postcondition violated: the engine observed no persisted fields';
458
+ }
459
+ const delta = [];
460
+ const after = payload['fields'];
461
+ const beforeFields = before['fields'];
462
+ for (const key of new Set([...Object.keys(beforeFields), ...Object.keys(after)])) {
463
+ const beforeValue = beforeFields[key];
464
+ const afterValue = after[key];
465
+ if (!isJsonValue(beforeValue) || !isJsonValue(afterValue))
466
+ continue;
467
+ if (canonicalJson(beforeValue) !== canonicalJson(afterValue))
468
+ delta.push(key);
469
+ }
470
+ const qualifying = delta.filter((key) => updateable.includes(key));
471
+ if (qualifying.length === 0) {
472
+ return ('update postcondition violated: the engine-observed delta ' +
473
+ `[${[...delta].sort().join(', ')}] touches no classification-declared ` +
474
+ `updateable field (updateableFields: [${[...updateable].sort().join(', ')}])`);
475
+ }
476
+ return null;
477
+ }
478
+ if (operation === 'read') {
479
+ if (!found) {
480
+ return 'read postcondition violated: entity absent';
481
+ }
482
+ return null;
483
+ }
484
+ if (operation === 'delete') {
485
+ if (obligation.lifecycle.deleteSemantics === 'archive') {
486
+ if (!found) {
487
+ return 'archive postcondition violated: entity absent (archived entities stay present)';
488
+ }
489
+ return declaredFieldsMatchFailure(obligation.lifecycle.archiveFields, payload['fields'], 'archive');
490
+ }
491
+ if (found) {
492
+ return 'delete postcondition violated: entity still present after a hard delete';
493
+ }
494
+ return null;
495
+ }
496
+ return null;
497
+ }
498
+ /**
499
+ * Evaluates one claim's evidence against the obligation's contract:
500
+ * a ui.action (any tier — suite-asserted) matching the operation →
501
+ * entityId extraction and D3 validation → a WITNESSED (engine-observed)
502
+ * persistence.* record for the same entity meeting the operation's
503
+ * postcondition.
504
+ *
505
+ * Dispatch (ADR 0004 D8, plan phase 5 + Phase 1 item 8):
506
+ * - `crud:<op>` — UI-semantic, graded by the session-channel verifier
507
+ * ({@link crudClaimVerifier}): supervised session binding, witnessed
508
+ * interval-gated exchange, visible result, persistence echo;
509
+ * - `persistence:<op>` — graded on the witness's own observations with
510
+ * OWNER-owned expectations (classification `archiveFields`) and
511
+ * engine-observed before/after deltas. The tested suite never supplies
512
+ * expectations.
513
+ * - everything else — no semantic verifier registered, fail closed.
514
+ */
515
+ /**
516
+ * Per-claim dispatch (ADR 0004 D8, plan phase 5): every contract
517
+ * namespace is graded by exactly one registered semantic verifier;
518
+ * unknown namespaces stay fail-closed blocking. The built-in
519
+ * persistence/crud grader keeps its historical behavior verbatim.
520
+ */
521
+ function evaluateClaimEvidence(claim, evidence, obligation, primaryKey, resource, httpRoutes) {
522
+ const verifier = verifierFor(obligation.contract);
523
+ if (verifier === null) {
524
+ return {
525
+ status: 'missing',
526
+ reason: `no semantic verifier is registered for contract '${obligation.contract}'; the generic ` +
527
+ `CRUD evidence rule does not apply to non-persistence contracts, so '${obligation.id}' stays ` +
528
+ 'blocking until its pack-specific verifier grades the evidence',
529
+ };
530
+ }
531
+ return verifier({ claim, obligation, evidence, primaryKey, resource, httpRoutes });
532
+ }
533
+ // Built-in registrations: persistence/crud semantics stay owned by this
534
+ // module; pack namespaces register through './pack-verifiers.js'.
535
+ registerContractVerifier('crud', (input) => crudClaimVerifier(input));
536
+ registerContractVerifier('persistence', (input) => persistenceClaimVerifier(input.claim, input.evidence, input.obligation, input.primaryKey));
537
+ registerPackVerifiers();
538
+ /**
539
+ * Capability metadata for the persistence namespace (plan Phase 0 item 7,
540
+ * ADR 0005; Phase 1 implements the echo): implemented over the witness
541
+ * persistence adapter (engine-observed same-entity state reads) plus the
542
+ * supervisor-bound session channel. The metadata text carries the
543
+ * exact-value echo requirement (plan §3.6): for a UI-collected mutation,
544
+ * the independent persistence evidence must echo the user-entered values
545
+ * exactly on the same entity identity — a 2xx status or row presence
546
+ * alone is insufficient, and a mismatched echo fails with
547
+ * `EVIDENCE_VALUE_MISMATCH` even when the status was 2xx.
548
+ */
549
+ const PERSISTENCE_CAPABILITY = {
550
+ namespace: 'persistence',
551
+ contracts: [
552
+ 'persistence:create',
553
+ 'persistence:read',
554
+ 'persistence:update',
555
+ 'persistence:delete',
556
+ ],
557
+ unavailableContracts: [],
558
+ observer: 'witness persistence adapter: engine-observed pre/post state reads on the SAME entity ' +
559
+ "identity the UI action produced; exact-value echo required and enforced (plan §3.6) — " +
560
+ 'persisted field values must echo the user-entered input exactly on the same entity, and a ' +
561
+ 'mismatched echo fails with EVIDENCE_VALUE_MISMATCH even when the status was 2xx. ' +
562
+ 'Backend-only state additionally admits the server-witnessed channel: the witness runs the ' +
563
+ "resource's adapter server probe (probeServer) ITSELF and stamps `channel: 'server'` " +
564
+ "records carrying `declaredKind: 'server-e2e'` — admissible without the ui.action browser " +
565
+ 'anchor only for obligations the supervisor registered server-e2e',
566
+ testKinds: ['browser-e2e', 'server-e2e', 'api-e2e'],
567
+ availability: { status: 'available' },
568
+ };
569
+ /**
570
+ * Capability metadata for the UI-semantic crud namespace (plan Phase 0
571
+ * item 3, Phase 1 item 4/8 + §3.6): AVAILABLE through the engine-owned
572
+ * browser action/observation channel. The engine creates the browser
573
+ * context, executes the constrained surface operations itself, observes
574
+ * the rendered result and the captured application exchange, and issues
575
+ * engine-observed (witnessed-trust) action/visible-result records.
576
+ * Suite-submitted UI records and origin-attributed proxy exchanges can
577
+ * never substitute for it: the anchor and visible-result rules below
578
+ * require witnessed trust, so worker-side replays grade invalid/missing
579
+ * instead of satisfying.
580
+ */
581
+ const CRUD_CAPABILITY = {
582
+ namespace: 'crud',
583
+ contracts: ['crud:create', 'crud:read', 'crud:update', 'crud:delete'],
584
+ unavailableContracts: [],
585
+ observer: 'the ENGINE-OWNED browser action/observation channel (plan Phase 1 item 4): the engine ' +
586
+ 'creates the browser context, executes the constrained UI actions itself, observes the ' +
587
+ 'rendered result and the captured application exchange, and issues engine-observed ' +
588
+ "ui.action/ui.visible-result records — suite-submitted UI records and origin-attributed " +
589
+ 'proxy exchanges can never substitute for it (test attribution stays suite-claimed)',
590
+ testKinds: ['browser-e2e'],
591
+ availability: { status: 'available' },
592
+ };
593
+ registerContractCapabilities(PERSISTENCE_CAPABILITY);
594
+ registerContractCapabilities(CRUD_CAPABILITY);
595
+ /**
596
+ * The built-in persistence grader: dispatches between the two evidence
597
+ * channels an obligation's claim may be proven through.
598
+ *
599
+ * - SERVER-WITNESSED channel (`payload.channel: 'server'` + `payload.
600
+ * declaredKind: 'server-e2e'`, both witness-stamped and covered by the
601
+ * record's provenance hash + attestation MAC): satisfies WITHOUT the
602
+ * ui.action browser anchor when the witnessed probe observation meets
603
+ * the operation's postcondition (the SAME
604
+ * {@link persistencePostconditionFailure} semantics as the browser
605
+ * path — create ⇒ observed absent-before + present-after, update ⇒
606
+ * observed before-state + qualifying delta, read ⇒ present,
607
+ * delete ⇒ absent / archive state). The intent that triggered the
608
+ * probe is suite-writable and proves nothing by itself: only the
609
+ * witness-issued record grades, and the witness refused to stamp the
610
+ * channel unless the trusted supervisor registered the obligation
611
+ * `server-e2e`. Records carrying the channel WITHOUT the kind stamp
612
+ * (impossible from an honest witness) are admissible NOWHERE — never
613
+ * server-satisfying and excluded from the browser path (fail closed).
614
+ * - BROWSER channel: the historical ui.action + witnessed persistence
615
+ * rule, byte-identical to its pre-server-channel behavior for any
616
+ * evidence set that could exist without this channel (server-channel
617
+ * records are excluded from its persistence set — they are not browser
618
+ * evidence and must neither satisfy nor invalidate a UI-anchored
619
+ * claim).
620
+ *
621
+ * Aggregation: browser satisfaction wins (it is the stricter channel),
622
+ * then server satisfaction, then the browser outcome verbatim — except
623
+ * that a typed server-channel postcondition failure upgrades a browser
624
+ * `missing` to `invalid` (the witness DID observe the state; the claim
625
+ * declared the operation and lied).
626
+ */
627
+ function persistenceClaimVerifier(claim, evidence, obligation, primaryKey) {
628
+ const requiredOp = persistenceOperation(obligation.contract);
629
+ if (requiredOp === null) {
630
+ return {
631
+ status: 'missing',
632
+ reason: `no semantic verifier is registered for contract '${obligation.contract}'; the generic ` +
633
+ `CRUD evidence rule does not apply to non-persistence contracts, so '${obligation.id}' stays ` +
634
+ 'blocking until its pack-specific verifier grades the evidence',
635
+ };
636
+ }
637
+ if (evidence.length === 0) {
638
+ return {
639
+ status: 'missing',
640
+ reason: `claim '${claim.testId}' declares '${obligation.id}' but produced no evidence records`,
641
+ };
642
+ }
643
+ // Browser channel first (unchanged semantics; server-channel records
644
+ // cannot disturb it).
645
+ const browser = browserAnchoredPersistenceClaim(claim, evidence, obligation, primaryKey, requiredOp);
646
+ if (browser.status === 'satisfied')
647
+ return browser;
648
+ // SERVER-WITNESSED channel: grade every qualifying server record. Each
649
+ // post-intent record is self-contained (the witness consumed the paired
650
+ // pre-intent observation INTO `payload.before` at stamping time), so
651
+ // records are graded independently against their OWN entityId — there
652
+ // is no ui.action anchor to agree with.
653
+ const serverQualified = evidence.filter((entry) => entry.trust === 'witnessed' &&
654
+ typeof entry.record.kind === 'string' &&
655
+ entry.record.kind.startsWith(PERSISTENCE_KIND_PREFIX) &&
656
+ payloadOf(entry.record)?.['channel'] === SERVER_CHANNEL &&
657
+ payloadOf(entry.record)?.['declaredKind'] === SERVER_E2E_KIND);
658
+ let serverFailure = null;
659
+ if (serverQualified.length > 0) {
660
+ const graded = serverQualified
661
+ .map((entry) => ({
662
+ entry,
663
+ entity: normalizeEntityId(payloadOf(entry.record)?.['entityId'], primaryKey),
664
+ }))
665
+ .sort((a, b) => compareStrings(labelOf(a.entry.record), labelOf(b.entry.record)));
666
+ for (const candidate of graded) {
667
+ if (!candidate.entity.ok) {
668
+ // A broken identity on a witnessed server record is a checkable
669
+ // violation (D3), same as on the browser channel.
670
+ if (serverFailure === null) {
671
+ serverFailure =
672
+ `server-witnessed persistence record '${labelOf(candidate.entry.record)}': ${candidate.entity.detail}`;
673
+ }
674
+ continue;
675
+ }
676
+ const failure = persistencePostconditionFailure(obligation, requiredOp, candidate.entry.record, candidate.entity.key);
677
+ if (failure === null) {
678
+ return {
679
+ status: 'satisfied',
680
+ recordIds: sortedUnique([candidate.entry.record.recordId].map((id) => (typeof id === 'string' ? id : ''))),
681
+ };
682
+ }
683
+ if (serverFailure === null)
684
+ serverFailure = failure;
685
+ }
686
+ // No qualifying server record met the postcondition: if the browser
687
+ // channel merely lacks evidence, the witnessed server observation is
688
+ // the sharper diagnosis — return it typed-invalid instead.
689
+ if (browser.status === 'missing' && serverFailure !== null) {
690
+ return { status: 'invalid', reason: `${serverFailure} (obligation '${obligation.id}')` };
691
+ }
692
+ }
693
+ return browser;
694
+ }
695
+ /**
696
+ * The BROWSER channel of the persistence grader — the historical rule
697
+ * kept byte-identical: a provenanced `ui.action` matching the operation
698
+ * anchors the entity; a WITNESSED engine-observed `persistence.*` record
699
+ * for the SAME entity meeting the operation's postcondition satisfies.
700
+ * Server-channel records (`payload.channel: 'server'`) are excluded from
701
+ * its persistence set: they carry no browser anchor and must never be
702
+ * consumed by a UI-anchored claim.
703
+ */
704
+ function browserAnchoredPersistenceClaim(claim, evidence, obligation, primaryKey, requiredOp) {
705
+ // Requirement 1: a ui.action anchoring the entity. The action itself
706
+ // is SUITE-ASSERTED (submitted through the run token; the witness
707
+ // stamps such records claimed-tier at issuance — GF-23 round 3), so
708
+ // any tier anchors — but ONLY records whose provenance verifies:
709
+ // witness-stamped claimed records carry a consistent hash, fabricated
710
+ // ones do not. Satisfaction weight lives in requirement 2, the
711
+ // engine-observed persistence read.
712
+ const actions = evidence.filter((entry) => entry.record.kind === UI_ACTION_KIND);
713
+ const matchingAction = actions.find((entry) => isProvenancedRecord(entry.record) &&
714
+ payloadOf(entry.record)?.['operation'] === requiredOp);
715
+ if (matchingAction === undefined) {
716
+ if (actions.length === 0) {
717
+ return {
718
+ status: 'missing',
719
+ reason: `no '${UI_ACTION_KIND}' evidence for '${obligation.id}'`,
720
+ };
721
+ }
722
+ const fabricated = actions.find((entry) => !isProvenancedRecord(entry.record));
723
+ if (fabricated !== undefined) {
724
+ return {
725
+ status: 'invalid',
726
+ reason: `claimed-tier '${UI_ACTION_KIND}' record '${labelOf(fabricated.record)}' cannot ` +
727
+ `satisfy '${obligation.contract}': only service-witnessed evidence satisfies (GF-23)`,
728
+ };
729
+ }
730
+ const wrongOp = actions.find((entry) => payloadOf(entry.record)?.['operation'] !== requiredOp);
731
+ if (wrongOp !== undefined) {
732
+ const got = String(payloadOf(wrongOp.record)?.['operation'] ?? '<none>');
733
+ return {
734
+ status: 'invalid',
735
+ reason: `'${UI_ACTION_KIND}' record '${labelOf(wrongOp.record)}' has operation ` +
736
+ `'${got}' but '${obligation.contract}' requires '${requiredOp}'`,
737
+ };
738
+ }
739
+ return {
740
+ status: 'missing',
741
+ reason: `no admissible '${UI_ACTION_KIND}' evidence for '${obligation.id}'`,
742
+ };
743
+ }
744
+ const actionPayload = payloadOf(matchingAction.record);
745
+ if (actionPayload?.['entityId'] === undefined) {
746
+ return {
747
+ status: 'invalid',
748
+ reason: `'${UI_ACTION_KIND}' record '${labelOf(matchingAction.record)}' carries no entityId; ` +
749
+ 'same-entity enforcement (invariant 3) is impossible without it',
750
+ };
751
+ }
752
+ const actionEntity = normalizeEntityId(actionPayload['entityId'], primaryKey);
753
+ if (!actionEntity.ok) {
754
+ return {
755
+ status: 'invalid',
756
+ reason: `'${UI_ACTION_KIND}' record '${labelOf(matchingAction.record)}': ${actionEntity.detail}; ` +
757
+ 'same-entity enforcement (invariant 3) is impossible without it',
758
+ };
759
+ }
760
+ // Requirement 2: a WITNESSED persistence record for the same entity
761
+ // whose contents the witness observed engine-side (origin
762
+ // 'engine-observed'), AND whose observed state satisfies the
763
+ // operation's postcondition (2026-08-31 audit round 4): presence
764
+ // alone proves nothing — a claimed delete of a live entity or a
765
+ // "read" nobody ever saw must not satisfy.
766
+ const persistence = evidence.filter((entry) => typeof entry.record.kind === 'string' &&
767
+ entry.record.kind.startsWith(PERSISTENCE_KIND_PREFIX) &&
768
+ payloadOf(entry.record)?.['channel'] !== SERVER_CHANNEL);
769
+ const witnessedPersistence = persistence.filter((entry) => entry.trust === 'witnessed');
770
+ const sameEntity = witnessedPersistence.filter((entry) => {
771
+ const entity = normalizeEntityId(payloadOf(entry.record)?.['entityId'], primaryKey);
772
+ return entity.ok && entity.key === actionEntity.key;
773
+ });
774
+ if (sameEntity.length === 0) {
775
+ const claimedPersistence = persistence.find((entry) => entry.trust === 'claimed');
776
+ if (claimedPersistence !== undefined) {
777
+ return {
778
+ status: 'invalid',
779
+ reason: `claimed-tier '${String(claimedPersistence.record.kind)}' record ` +
780
+ `'${labelOf(claimedPersistence.record)}' cannot satisfy '${obligation.contract}': ` +
781
+ 'only service-witnessed evidence satisfies (GF-23)',
782
+ };
783
+ }
784
+ const mismatched = witnessedPersistence.find((entry) => {
785
+ const entity = normalizeEntityId(payloadOf(entry.record)?.['entityId'], primaryKey);
786
+ return !entity.ok || entity.key !== actionEntity.key;
787
+ });
788
+ if (mismatched !== undefined) {
789
+ const entity = normalizeEntityId(payloadOf(mismatched.record)?.['entityId'], primaryKey);
790
+ const target = entity.ok ? entity.key : entity.detail;
791
+ return {
792
+ status: 'invalid',
793
+ reason: `same-entity violation: persistence record '${labelOf(mismatched.record)}' targets ` +
794
+ `entity ${target} but the '${UI_ACTION_KIND}' targeted ${actionEntity.key}`,
795
+ };
796
+ }
797
+ return {
798
+ status: 'missing',
799
+ reason: `no witnessed '${PERSISTENCE_KIND_PREFIX}*' record for entity ${actionEntity.key} ` +
800
+ `of '${obligation.id}'`,
801
+ };
802
+ }
803
+ // At least one same-entity witnessed record must meet the operation's
804
+ // postcondition; otherwise the first failure explains the block.
805
+ let firstPostconditionFailure = null;
806
+ let matchingPersistence;
807
+ for (const entry of sameEntity) {
808
+ const failure = persistencePostconditionFailure(obligation, requiredOp, entry.record, actionEntity.key);
809
+ if (failure === null) {
810
+ matchingPersistence = entry;
811
+ break;
812
+ }
813
+ if (firstPostconditionFailure === null)
814
+ firstPostconditionFailure = failure;
815
+ }
816
+ if (matchingPersistence === undefined) {
817
+ return {
818
+ status: 'invalid',
819
+ reason: `${firstPostconditionFailure ?? `no witnessed '${PERSISTENCE_KIND_PREFIX}*' record meets ` +
820
+ `the '${obligation.contract}' postcondition`} (obligation '${obligation.id}')`,
821
+ };
822
+ }
823
+ // Exact-value echo (plan §3.6): for UI-collected create/update, the
824
+ // persisted state on the same entity must echo the journey's entered
825
+ // input EXACTLY — engine-record vs engine-record, never a suite
826
+ // expectation. A mismatch blocks with EVIDENCE_VALUE_MISMATCH even
827
+ // when the status was 200 and the row exists.
828
+ if (requiredOp === 'create' || requiredOp === 'update') {
829
+ const echoFailure = exactValueEchoFailure(requiredOp, matchingAction.record, matchingPersistence.record);
830
+ if (echoFailure !== null) {
831
+ return { status: 'invalid', reason: `${echoFailure} (obligation '${obligation.id}')` };
832
+ }
833
+ }
834
+ // Consistency hardening: visible vs persisted fields must agree when a
835
+ // visible-result record for the same entity exists (any tier — the
836
+ // visible side is suite-asserted, so disagreement with the
837
+ // engine-observed persisted fields is a fabrication signal).
838
+ const visible = evidence.find((entry) => {
839
+ if (entry.record.kind !== UI_VISIBLE_KIND)
840
+ return false;
841
+ const entity = normalizeEntityId(payloadOf(entry.record)?.['entityId'], primaryKey);
842
+ return entity.ok && entity.key === actionEntity.key;
843
+ });
844
+ if (visible !== undefined) {
845
+ const disagreement = fieldsDisagreement(payloadOf(visible.record)?.['fields'], payloadOf(matchingPersistence.record)?.['fields']);
846
+ if (disagreement !== null) {
847
+ return {
848
+ status: 'invalid',
849
+ reason: `visible and persisted fields disagree on ${disagreement} ` +
850
+ `(obligation '${obligation.id}')`,
851
+ };
852
+ }
853
+ }
854
+ const used = [
855
+ matchingAction.record,
856
+ matchingPersistence.record,
857
+ ...(visible !== undefined ? [visible.record] : []),
858
+ ]
859
+ .map((record) => (typeof record.recordId === 'string' ? record.recordId : ''));
860
+ return { status: 'satisfied', recordIds: sortedUnique(used) };
861
+ }
862
+ /**
863
+ * The operation a `crud:` contract requires, or null for any other name
864
+ * inside the namespace (unknown `crud:*` names stay typed-blocking).
865
+ */
866
+ function crudOperation(contract) {
867
+ if (!contract.startsWith(CRUD_CONTRACT_PREFIX))
868
+ return null;
869
+ const operation = contract.slice(CRUD_CONTRACT_PREFIX.length);
870
+ if (operation === 'create' || operation === 'read' || operation === 'update' || operation === 'delete') {
871
+ return operation;
872
+ }
873
+ return null;
874
+ }
875
+ /** A non-empty string payload field (the session-binding shape). */
876
+ function payloadSessionId(record) {
877
+ const value = payloadOf(record)?.['sessionId'];
878
+ return typeof value === 'string' && value.length > 0 ? value : null;
879
+ }
880
+ /**
881
+ * Grades the UI-semantic `crud:<op>` contracts on the SUPERVISED SESSION
882
+ * CHANNEL (plan Phase 1 items 5/8, §3.6; review finding "Phase 1 browser
883
+ * proof is absent"). Satisfies ONLY when ALL of the following hold for
884
+ * the claim, all within the SAME witness session:
885
+ *
886
+ * (i) a provenanced session-bound `ui.action` record with the matching
887
+ * operation — the suite-asserted anchor (any trust tier anchors, as
888
+ * in the persistence rule, but ONLY records whose provenance
889
+ * verifies), carrying the session id the witness stamped at
890
+ * issuance;
891
+ * (ii) the WITNESSED `http.request` exchange of that same session,
892
+ * attributed to the obligation's endpoint. Interval guarantee: the
893
+ * witness issues `http.request` records ONLY for exchanges observed
894
+ * through THAT session's dedicated proxy port INSIDE one of its
895
+ * witness-kept action intervals, so the record's existence is the
896
+ * interval proof — setup traffic outside every interval never
897
+ * becomes a record at all. Route attribution runs through the
898
+ * existing `resolveHttpRoute` machinery over the COMPLETE
899
+ * host-derived inventory; without an inventory the claim grades a
900
+ * typed missing naming the gap (never satisfied), and
901
+ * non-unique/nomatch attribution blocks. A `crud:` obligation
902
+ * attaches to a business (entity) resource, so a UNIQUE
903
+ * single-route attribution is accepted without comparing the
904
+ * endpoint's resource id; when the obligation's resource IS an
905
+ * inventoried endpoint id, only the exact match counts;
906
+ * (iii) a provenanced session-bound `ui.visible-result` record for the
907
+ * same entity (the rendered result read back through the fixture);
908
+ * (iv) the engine-observed persistence postcondition + exact-value
909
+ * echo on the same entity — the create/update/read/archive rules
910
+ * reused VERBATIM from the persistence grader (declared fields,
911
+ * updateable delta, archive fields), plus the visible-vs-persisted
912
+ * field agreement.
913
+ *
914
+ * Typed blocking (deterministic reasons, single grading sites):
915
+ * - direct-API/Node-side mutation without a session exchange → `missing`
916
+ * carrying the `HTTP_OBSERVATION_UNTRUSTED`-style session reason;
917
+ * - a borrowed cross-session exchange → `invalid` (the session binding
918
+ * fails it);
919
+ * - 2xx with wrong persisted values → `invalid` `EVIDENCE_VALUE_MISMATCH`
920
+ * (reused verbatim);
921
+ * - no visible-result → `missing` `EVIDENCE_NOT_COLLECTED`.
922
+ *
923
+ * Sessions are graded as groups (an honest bundle carries exactly one):
924
+ * each candidate session from rule (i) is evaluated independently in
925
+ * codepoint order, and `satisfied` beats `invalid` beats `missing`, the
926
+ * same aggregation the obligation level applies across claims.
927
+ *
928
+ * Args:
929
+ * input: the claim plus its attributed evidence, obligation, primary
930
+ * key, graph resource, and the host-derived route inventory.
931
+ *
932
+ * Returns:
933
+ * ClaimOutcome: the per-claim grade.
934
+ */
935
+ /**
936
+ * Grades the UI-semantic `crud:<op>` contracts. The namespace's
937
+ * capability is available through the engine-owned browser channel
938
+ * (see {@link CRUD_CAPABILITY}): only ENGINE-OBSERVED (witnessed-trust)
939
+ * action/visible records can anchor or confirm — suite-submitted UI
940
+ * records grade invalid/missing, never satisfied. The session-channel
941
+ * rules below bind the rendered action, the captured application
942
+ * exchange with route attribution, the rendered visible result, and the
943
+ * engine-observed persistence echo on the same entity in the same
944
+ * session.
945
+ */
946
+ function crudClaimVerifier(input) {
947
+ const { claim, obligation, evidence, primaryKey } = input;
948
+ const requiredOp = crudOperation(obligation.contract);
949
+ if (requiredOp === null) {
950
+ return {
951
+ status: 'missing',
952
+ reason: `contract '${obligation.contract}' is not one of the graded UI-semantic operations ` +
953
+ `(crud:create, crud:read, crud:update, crud:delete), so '${obligation.id}' stays blocking`,
954
+ };
955
+ }
956
+ // Capability gate FIRST (fail closed): crud contracts are reachable
957
+ // only while the engine-owned browser observation channel is
958
+ // available. If a future regression marks it unavailable again, no
959
+ // evidence can satisfy these contracts.
960
+ const capability = capabilityFor(obligation.contract);
961
+ if (capability === null || capability.availability.status === 'unavailable') {
962
+ return {
963
+ status: 'missing',
964
+ reason: `'${obligation.id}': UI-semantic crud contracts fail closed — ` +
965
+ `${capability?.availability.status === 'unavailable' ? capability.availability.reason : 'no independent browser observation channel exists'}. ` +
966
+ `The declaring claim '${claim.testId}' carried ${evidence.length} evidence record(s); none of them ` +
967
+ 'can prove a rendered browser action without the engine-owned browser action/observation ' +
968
+ 'channel (plan Phase 1 item 4) — the gate stays blocking instead of granting browser ' +
969
+ 'credit to suite-submitted records',
970
+ };
971
+ }
972
+ if (evidence.length === 0) {
973
+ return {
974
+ status: 'missing',
975
+ reason: `claim '${claim.testId}' declares '${obligation.id}' but produced no evidence records`,
976
+ };
977
+ }
978
+ // Rule (i): the ENGINE-OBSERVED ui.action anchor. Only
979
+ // witnessed-trust records can anchor: suite-submitted UI records are
980
+ // worker assertions, never browser proof (plan Phase 1 item 4). An
981
+ // unprovenanced or claimed-tier action is a GF-23 violation.
982
+ const actions = evidence.filter((entry) => entry.record.kind === UI_ACTION_KIND);
983
+ const fabricated = actions.find((entry) => !isProvenancedRecord(entry.record));
984
+ const claimedTier = actions.find((entry) => entry.trust !== 'witnessed');
985
+ const badAction = fabricated ?? claimedTier;
986
+ if (badAction !== undefined) {
987
+ return {
988
+ status: 'invalid',
989
+ reason: `claimed-tier '${UI_ACTION_KIND}' record '${labelOf(badAction.record)}' cannot ` +
990
+ `satisfy '${obligation.contract}': only engine-observed browser actions satisfy ` +
991
+ 'UI-semantic contracts (suite-submitted UI records never earn browser credit; GF-23)',
992
+ };
993
+ }
994
+ // Normalize each candidate anchor's entityId up front (D3); a broken
995
+ // identity is a checkable violation, reported for the smallest anchor.
996
+ // Lockstep with the persistence grader: only actions whose payload
997
+ // declares the REQUIRED operation can anchor (a wrong-operation action
998
+ // blocks only when no qualifying anchor exists).
999
+ const anchors = actions
1000
+ .filter((entry) => isProvenancedRecord(entry.record) &&
1001
+ payloadSessionId(entry.record) !== null &&
1002
+ payloadOf(entry.record)?.['operation'] === requiredOp)
1003
+ .map((entry) => ({
1004
+ entry,
1005
+ session: payloadSessionId(entry.record),
1006
+ entity: normalizeEntityId(payloadOf(entry.record)?.['entityId'], primaryKey),
1007
+ }))
1008
+ .sort((a, b) => compareStrings(labelOf(a.entry.record), labelOf(b.entry.record)));
1009
+ const firstBroken = anchors.find((anchor) => !anchor.entity.ok);
1010
+ if (firstBroken !== undefined) {
1011
+ return {
1012
+ status: 'invalid',
1013
+ reason: `'${UI_ACTION_KIND}' record '${labelOf(firstBroken.entry.record)}': ` +
1014
+ `${firstBroken.entity.ok ? '' : firstBroken.entity.detail}; ` +
1015
+ 'same-entity enforcement (invariant 3) is impossible without it',
1016
+ };
1017
+ }
1018
+ const qualifying = anchors.filter((anchor) => anchor.entity.ok);
1019
+ if (qualifying.length === 0) {
1020
+ if (actions.length === 0) {
1021
+ return {
1022
+ status: 'missing',
1023
+ reason: `no session-bound '${UI_ACTION_KIND}' anchor from the declaring test: ` +
1024
+ `'${obligation.id}' requires the supervised witness session the gateforge reporter ` +
1025
+ 'opens per test (records without it never carry a session binding)',
1026
+ };
1027
+ }
1028
+ const unbound = actions.find((entry) => payloadSessionId(entry.record) === null);
1029
+ if (unbound !== undefined) {
1030
+ return {
1031
+ status: 'missing',
1032
+ reason: `'${UI_ACTION_KIND}' record '${labelOf(unbound.record)}' carries no witness session ` +
1033
+ `binding, so it cannot anchor the supervised-session contract '${obligation.contract}': ` +
1034
+ 'the gateforge reporter must open a test session (records without one never carry a ' +
1035
+ 'session id)',
1036
+ };
1037
+ }
1038
+ const wrongOp = actions.find((entry) => payloadOf(entry.record)?.['operation'] !== requiredOp);
1039
+ if (wrongOp !== undefined) {
1040
+ const got = String(payloadOf(wrongOp.record)?.['operation'] ?? '<none>');
1041
+ return {
1042
+ status: 'invalid',
1043
+ reason: `'${UI_ACTION_KIND}' record '${labelOf(wrongOp.record)}' has operation ` +
1044
+ `'${got}' but '${obligation.contract}' requires '${requiredOp}'`,
1045
+ };
1046
+ }
1047
+ return {
1048
+ status: 'missing',
1049
+ reason: `no admissible '${UI_ACTION_KIND}' evidence for '${obligation.id}'`,
1050
+ };
1051
+ }
1052
+ // Rules (ii)-(iv) are evaluated PER SESSION GROUP, in codepoint order.
1053
+ const sessions = sortedUnique(qualifying.map((anchor) => anchor.session));
1054
+ let firstInvalid = null;
1055
+ let firstMissing = null;
1056
+ for (const session of sessions) {
1057
+ const anchor = qualifying.find((candidate) => candidate.session === session);
1058
+ const outcome = gradeCrudSession({
1059
+ session,
1060
+ anchorRecord: anchor.entry.record,
1061
+ anchorEntityKey: anchor.entity.key,
1062
+ requiredOp,
1063
+ primaryKey,
1064
+ input,
1065
+ });
1066
+ if (outcome.status === 'satisfied')
1067
+ return outcome;
1068
+ if (outcome.status === 'invalid' && firstInvalid === null)
1069
+ firstInvalid = outcome.reason;
1070
+ if (outcome.status === 'missing' && firstMissing === null)
1071
+ firstMissing = outcome.reason;
1072
+ }
1073
+ if (firstInvalid !== null)
1074
+ return { status: 'invalid', reason: firstInvalid };
1075
+ return { status: 'missing', reason: firstMissing ?? `no admissible evidence for '${obligation.id}'` };
1076
+ }
1077
+ /**
1078
+ * Grades rules (ii)-(iv) for ONE witness session: the witnessed
1079
+ * session-bound exchange with route attribution, the session-bound
1080
+ * visible result, and the engine-observed persistence echo.
1081
+ *
1082
+ * Args:
1083
+ * params: session id, the anchoring action record, its canonical
1084
+ * entity key, the required operation, and the verifier input.
1085
+ *
1086
+ * Returns:
1087
+ * ClaimOutcome: the session group's grade.
1088
+ */
1089
+ function gradeCrudSession(params) {
1090
+ const { session, anchorRecord, anchorEntityKey, requiredOp, primaryKey, input } = params;
1091
+ const { obligation, evidence } = input;
1092
+ // Rule (ii): the WITNESSED session-bound exchange. The record's
1093
+ // existence proves the interval: the witness issues http.request
1094
+ // records only for exchanges traversing THIS session's dedicated
1095
+ // proxy port inside a witness-kept action interval.
1096
+ const exchanges = evidence.filter((entry) => entry.record.kind === 'http.request');
1097
+ if (exchanges.length === 0) {
1098
+ return {
1099
+ status: 'missing',
1100
+ reason: `'${obligation.id}': no witnessed session-bound 'http.request' exchange was observed ` +
1101
+ `(HTTP_OBSERVATION_UNTRUSTED): a direct API or Node-side mutation never enters the ` +
1102
+ 'supervised session channel, and traffic outside a witness-kept action interval is ' +
1103
+ `never issued as evidence, so the UI-semantic contract '${obligation.contract}' has no ` +
1104
+ 'independently observed transport; drive the mutation through the rendered UI inside ' +
1105
+ 'the fixture\'s recorded action interval',
1106
+ };
1107
+ }
1108
+ const forgedExchange = exchanges.find((entry) => entry.trust !== 'witnessed' || !isProvenancedRecord(entry.record));
1109
+ if (forgedExchange !== undefined) {
1110
+ return {
1111
+ status: 'invalid',
1112
+ reason: `'${obligation.id}': suite-submitted network record '${labelOf(forgedExchange.record)}' ` +
1113
+ `cannot satisfy '${obligation.contract}' (HTTP_OBSERVATION_UNTRUSTED): only a ` +
1114
+ 'witness-issued engine-observed exchange proves transport',
1115
+ };
1116
+ }
1117
+ const foreign = exchanges.find((entry) => payloadSessionId(entry.record) !== session);
1118
+ if (foreign !== undefined) {
1119
+ return {
1120
+ status: 'invalid',
1121
+ reason: `'${obligation.id}': witnessed 'http.request' record '${labelOf(foreign.record)}' was ` +
1122
+ `observed on witness session '${String(payloadSessionId(foreign.record) ?? '<none>')}' ` +
1123
+ `but the declaring '${UI_ACTION_KIND}' anchors session '${session}' — exchanges are ` +
1124
+ 'consumable only by the session whose channel they traversed; borrowed cross-session ' +
1125
+ 'evidence can never satisfy',
1126
+ };
1127
+ }
1128
+ // Inventory gate FIRST (fail closed): without the complete host-derived
1129
+ // route inventory the session exchange can never be attributed.
1130
+ if (input.httpRoutes === null || input.httpRoutes === undefined) {
1131
+ return {
1132
+ status: 'missing',
1133
+ reason: `'${obligation.id}': no route inventory context for '${obligation.contract}': crud ` +
1134
+ 'satisfaction requires the complete host-derived route inventory (every applicable ' +
1135
+ 'http.endpoint resource) so the witnessed session exchange can be attributed to the ' +
1136
+ "obligation's endpoint — without it the claim stays blocking and is never satisfied",
1137
+ };
1138
+ }
1139
+ // Pick the codepoint-smallest witnessed same-session exchange whose
1140
+ // method/url pair is well-formed; malformed ones are checkable violations.
1141
+ const shaped = exchanges
1142
+ .filter((entry) => payloadSessionId(entry.record) === session)
1143
+ .map((entry) => ({ entry, payload: payloadOf(entry.record) }))
1144
+ .sort((a, b) => compareStrings(labelOf(a.entry.record), labelOf(b.entry.record)));
1145
+ const malformed = shaped.find((candidate) => candidate.payload === undefined ||
1146
+ typeof candidate.payload['method'] !== 'string' ||
1147
+ typeof candidate.payload['url'] !== 'string');
1148
+ if (malformed !== undefined) {
1149
+ return {
1150
+ status: 'invalid',
1151
+ reason: `'${obligation.id}': witnessed 'http.request' record '${labelOf(malformed.entry.record)}' ` +
1152
+ 'carries no method/url pair',
1153
+ };
1154
+ }
1155
+ let attributed = null;
1156
+ let exchangeBlock = null;
1157
+ for (const candidate of shaped) {
1158
+ const payload = candidate.payload;
1159
+ const method = payload['method'];
1160
+ const interpreted = interpretObservedPath(payload['url']);
1161
+ if (!interpreted.ok) {
1162
+ exchangeBlock = `'${obligation.id}': witnessed 'http.request' record ` +
1163
+ `'${labelOf(candidate.entry.record)}' carries a noncanonical observed path: ${interpreted.reason}`;
1164
+ continue;
1165
+ }
1166
+ const resolution = resolveHttpRoute(method, interpreted.path, input.httpRoutes, obligation.resourceId);
1167
+ if (resolution.status === 'incomplete') {
1168
+ exchangeBlock = `'${obligation.id}': ${resolution.reason}`;
1169
+ continue;
1170
+ }
1171
+ if (resolution.status === 'nomatch') {
1172
+ exchangeBlock =
1173
+ `'${obligation.id}': witnessed 'http.request' record ` +
1174
+ `'${labelOf(candidate.entry.record)}' ${resolution.reason}`;
1175
+ continue;
1176
+ }
1177
+ if (resolution.status === 'ambiguous') {
1178
+ exchangeBlock =
1179
+ `'${obligation.id}': ambiguous route attribution: observed ` +
1180
+ `${method.toUpperCase()} ${interpreted.path} matches ${resolution.candidates.length} ` +
1181
+ `distinct routes [${resolution.candidates.join('; ')}]; no endpoint-specific claim ` +
1182
+ 'passes on an ambiguous exchange';
1183
+ continue;
1184
+ }
1185
+ // 'match' | 'mismatch': the exchange attributes to EXACTLY one
1186
+ // inventoried route. A crud obligation attaches to a business
1187
+ // (entity) resource — never the endpoint resource itself — so a
1188
+ // unique single-route attribution is the honest endpoint binding;
1189
+ // only when the obligation's resource IS an inventoried endpoint id
1190
+ // must the matched route be that exact endpoint.
1191
+ if (resolution.status === 'mismatch' && obligation.resourceId.startsWith('http.endpoint:')) {
1192
+ exchangeBlock =
1193
+ `'${obligation.id}': witnessed 'http.request' record ` +
1194
+ `'${labelOf(candidate.entry.record)}' observed ${method.toUpperCase()} ` +
1195
+ `${interpreted.path} uniquely matches route ${resolution.matched.resourceId} ` +
1196
+ `but the obligation requires endpoint '${obligation.resourceId}'`;
1197
+ continue;
1198
+ }
1199
+ attributed = { entry: candidate.entry, path: interpreted.path, method };
1200
+ break;
1201
+ }
1202
+ if (attributed === null) {
1203
+ return exchangeBlock === null
1204
+ ? { status: 'missing', reason: `'${obligation.id}': no attributable session exchange` }
1205
+ : { status: 'invalid', reason: exchangeBlock };
1206
+ }
1207
+ // Rule (iii): the ENGINE-OBSERVED session-bound visible result for
1208
+ // the same entity. Suite-submitted visible records are worker
1209
+ // assertions — only the engine's own readback confirms the rendered
1210
+ // outcome (plan Phase 1 item 4).
1211
+ const visibleRecords = evidence.filter((entry) => entry.record.kind === UI_VISIBLE_KIND);
1212
+ const unprovenancedVisible = visibleRecords.find((entry) => !isProvenancedRecord(entry.record) || entry.trust !== 'witnessed');
1213
+ if (unprovenancedVisible !== undefined) {
1214
+ return {
1215
+ status: 'invalid',
1216
+ reason: `claimed-tier '${UI_VISIBLE_KIND}' record '${labelOf(unprovenancedVisible.record)}' ` +
1217
+ 'cannot satisfy: only the engine-observed rendered readback confirms the visible ' +
1218
+ 'outcome (suite-submitted visible records never earn browser credit; GF-23)',
1219
+ };
1220
+ }
1221
+ const sessionVisible = visibleRecords.filter((entry) => payloadSessionId(entry.record) === session);
1222
+ if (sessionVisible.length === 0) {
1223
+ const otherSession = visibleRecords.find((entry) => payloadSessionId(entry.record) !== null);
1224
+ return {
1225
+ status: 'missing',
1226
+ reason: otherSession !== undefined
1227
+ ? `'${obligation.id}': witnessed visible-result evidence exists only on witness ` +
1228
+ `session '${String(payloadSessionId(otherSession.record))}', not the declaring ` +
1229
+ `session '${session}' — the visible result must be read back in the same ` +
1230
+ 'supervised session'
1231
+ : `no witnessed visible-result record for entity ${anchorEntityKey} of '${obligation.id}' ` +
1232
+ `(EVIDENCE_NOT_COLLECTED): the journey must read the rendered result back through ` +
1233
+ 'the fixture\'s visible.confirm inside the same supervised session',
1234
+ };
1235
+ }
1236
+ const matchingVisible = sessionVisible.find((entry) => {
1237
+ const entity = normalizeEntityId(payloadOf(entry.record)?.['entityId'], primaryKey);
1238
+ return entity.ok && entity.key === anchorEntityKey;
1239
+ });
1240
+ if (matchingVisible === undefined) {
1241
+ return {
1242
+ status: 'invalid',
1243
+ reason: `same-entity violation: the '${UI_VISIBLE_KIND}' records of session '${session}' target ` +
1244
+ `other entities than the '${UI_ACTION_KIND}' entity ${anchorEntityKey} ` +
1245
+ `(obligation '${obligation.id}')`,
1246
+ };
1247
+ }
1248
+ // Rule (iv): the engine-observed persistence postcondition + exact-value
1249
+ // echo on the same entity (rules reused verbatim from the persistence
1250
+ // grader), restricted to the same session.
1251
+ const persistence = evidence.filter((entry) => typeof entry.record.kind === 'string' &&
1252
+ entry.record.kind.startsWith(PERSISTENCE_KIND_PREFIX) &&
1253
+ entry.trust === 'witnessed' &&
1254
+ payloadSessionId(entry.record) === session);
1255
+ const sameEntity = persistence.filter((entry) => {
1256
+ const entity = normalizeEntityId(payloadOf(entry.record)?.['entityId'], primaryKey);
1257
+ return entity.ok && entity.key === anchorEntityKey;
1258
+ });
1259
+ if (sameEntity.length === 0) {
1260
+ const claimedPersistence = evidence.find((entry) => typeof entry.record.kind === 'string' &&
1261
+ entry.record.kind.startsWith(PERSISTENCE_KIND_PREFIX) &&
1262
+ entry.trust !== 'witnessed');
1263
+ if (claimedPersistence !== undefined) {
1264
+ return {
1265
+ status: 'invalid',
1266
+ reason: `claimed-tier '${String(claimedPersistence.record.kind)}' record ` +
1267
+ `'${labelOf(claimedPersistence.record)}' cannot satisfy '${obligation.contract}': ` +
1268
+ 'only service-witnessed evidence satisfies (GF-23)',
1269
+ };
1270
+ }
1271
+ return {
1272
+ status: 'missing',
1273
+ reason: `no witnessed '${PERSISTENCE_KIND_PREFIX}*' record for entity ${anchorEntityKey} in ` +
1274
+ `witness session '${session}' of '${obligation.id}' — the engine-observed state read ` +
1275
+ 'must run under the same supervised session as the UI action',
1276
+ };
1277
+ }
1278
+ let firstPostconditionFailure = null;
1279
+ let matchingPersistence;
1280
+ for (const entry of sameEntity) {
1281
+ const failure = persistencePostconditionFailure(obligation, requiredOp, entry.record, anchorEntityKey);
1282
+ if (failure === null) {
1283
+ matchingPersistence = entry.record;
1284
+ break;
1285
+ }
1286
+ if (firstPostconditionFailure === null)
1287
+ firstPostconditionFailure = failure;
1288
+ }
1289
+ if (matchingPersistence === undefined) {
1290
+ return {
1291
+ status: 'invalid',
1292
+ reason: `${firstPostconditionFailure ?? `no witnessed '${PERSISTENCE_KIND_PREFIX}*' record meets ` +
1293
+ `the '${obligation.contract}' postcondition`} (obligation '${obligation.id}')`,
1294
+ };
1295
+ }
1296
+ if (requiredOp === 'create' || requiredOp === 'update') {
1297
+ const echoFailure = exactValueEchoFailure(requiredOp, anchorRecord, matchingPersistence);
1298
+ if (echoFailure !== null) {
1299
+ return { status: 'invalid', reason: `${echoFailure} (obligation '${obligation.id}')` };
1300
+ }
1301
+ }
1302
+ const disagreement = fieldsDisagreement(payloadOf(matchingVisible.record)?.['fields'], payloadOf(matchingPersistence)?.['fields']);
1303
+ if (disagreement !== null) {
1304
+ return {
1305
+ status: 'invalid',
1306
+ reason: `visible and persisted fields disagree on ${disagreement} ` +
1307
+ `(obligation '${obligation.id}')`,
1308
+ };
1309
+ }
1310
+ const used = [anchorRecord, attributed.entry.record, matchingVisible.record, matchingPersistence]
1311
+ .map((record) => (typeof record.recordId === 'string' ? record.recordId : ''));
1312
+ return { status: 'satisfied', recordIds: sortedUnique(used) };
1313
+ }
1314
+ /**
1315
+ * Evaluates ONE obligation against the run's claims, records, waivers,
1316
+ * classification, and injected clock (pin #9). Pure and deterministic:
1317
+ * identical inputs produce identical outcomes.
1318
+ *
1319
+ * Args:
1320
+ * obligation: the obligation under evaluation (validated schema shape).
1321
+ * context: claims, records, waivers, classification, and `now`.
1322
+ *
1323
+ * Returns:
1324
+ * VerdictOutcome: {verdict, reason, recordIds} — reason is null only
1325
+ * for `satisfied`; recordIds is always a sorted array.
1326
+ *
1327
+ * Throws:
1328
+ * GateforgeVerdictError: when the obligation or `now` violates the
1329
+ * engine-internal contract (evidence problems NEVER throw — they
1330
+ * produce `invalid`/`missing` verdicts).
1331
+ */
1332
+ export function evaluateObligation(obligation, context) {
1333
+ const parsedObligation = ObligationSchema.safeParse(obligation);
1334
+ if (!parsedObligation.success) {
1335
+ throw new GateforgeVerdictError(`obligation failed schema validation: ${parsedObligation.error.issues
1336
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
1337
+ .join('; ')}`);
1338
+ }
1339
+ const verified = parsedObligation.data;
1340
+ const now = parseInstant(context.now);
1341
+ // 1. Unclassified resources block (invariant 1); unresolved resources
1342
+ // never reach this evaluator (the policy engine emits blocking
1343
+ // entries because they cannot carry obligations).
1344
+ if (context.classification === null || context.classification === undefined) {
1345
+ return {
1346
+ verdict: 'unclassified',
1347
+ reason: `resource '${verified.resourceId}' has no classification; obligations cannot bind ` +
1348
+ 'evidence until it is classified (invariant 1)',
1349
+ recordIds: [],
1350
+ };
1351
+ }
1352
+ const parsedClassification = ClassificationSchema.safeParse(context.classification);
1353
+ if (!parsedClassification.success) {
1354
+ return {
1355
+ verdict: 'unclassified',
1356
+ reason: `classification for resource '${verified.resourceId}' failed validation: ` +
1357
+ `${parsedClassification.error.issues
1358
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
1359
+ .join('; ')}`,
1360
+ recordIds: [],
1361
+ };
1362
+ }
1363
+ const classification = parsedClassification.data;
1364
+ // 2. Internal resources carry no CRUD obligations; their claims are
1365
+ // invalid (ADR 0001, matching the policy engine's convention).
1366
+ if (classification.exposure === 'internal') {
1367
+ return {
1368
+ verdict: 'invalid',
1369
+ reason: `resource '${verified.resourceId}' is internal; internal resources carry no CRUD ` +
1370
+ 'obligations and their claims are invalid (ADR 0001)',
1371
+ recordIds: [],
1372
+ };
1373
+ }
1374
+ // 3. Waivers: exact (resourceId, fingerprint) scope only (D4).
1375
+ // Precedence: unexpired non-stale → waived; expired → invalid (D4);
1376
+ // stale owner → stale (GF-17). Sorted for determinism.
1377
+ const fp = fingerprint({
1378
+ resourceId: verified.resourceId,
1379
+ contract: verified.contract,
1380
+ policyId: verified.policyId,
1381
+ lifecycle: verified.lifecycle,
1382
+ });
1383
+ const matching = context.waivers
1384
+ .map((entry) => {
1385
+ // Strip the engine-only flag before strict validation; a waiver
1386
+ // entry the schema rejects can never match exactly, so it degrades.
1387
+ const { ownerStale, ...plain } = entry;
1388
+ const parsed = WaiverSchema.safeParse(plain);
1389
+ return parsed.success ? { ownerStale: Boolean(ownerStale), waiver: parsed.data } : null;
1390
+ })
1391
+ .filter((entry) => entry !== null)
1392
+ .filter(({ waiver }) => waiver.scope.kind === 'exact' &&
1393
+ waiver.scope.resourceId === verified.resourceId &&
1394
+ waiver.scope.fingerprint === fp)
1395
+ .sort((a, b) => compareStrings(`${a.waiver.expiresAt}\u0000${a.waiver.owner}`, `${b.waiver.expiresAt}\u0000${b.waiver.owner}`));
1396
+ const unexpired = matching.filter((entry) => now.getTime() < Date.parse(entry.waiver.expiresAt) && !entry.ownerStale);
1397
+ if (unexpired.length > 0 && unexpired[0] !== undefined) {
1398
+ const waiver = unexpired[0].waiver;
1399
+ return {
1400
+ verdict: 'waived',
1401
+ reason: `waived by '${waiver.owner}' until '${waiver.expiresAt}' ` +
1402
+ `(approver '${waiver.approver}', ${waiver.justificationUrl})`,
1403
+ recordIds: [],
1404
+ };
1405
+ }
1406
+ const expired = matching.filter((entry) => now.getTime() >= Date.parse(entry.waiver.expiresAt) && !entry.ownerStale);
1407
+ if (expired.length > 0 && expired[0] !== undefined) {
1408
+ const waiver = expired[0].waiver;
1409
+ return {
1410
+ verdict: 'invalid',
1411
+ reason: `waiver by '${waiver.owner}' expired at '${waiver.expiresAt}'; expired waivers block ` +
1412
+ `as 'invalid' (ADR 0001 D4), obligation '${verified.id}'`,
1413
+ recordIds: [],
1414
+ };
1415
+ }
1416
+ const staleOwner = matching.find((entry) => entry.ownerStale);
1417
+ if (staleOwner !== undefined) {
1418
+ return {
1419
+ verdict: 'stale',
1420
+ reason: `waiver owner '${staleOwner.waiver.owner}' is stale (owner check failed); renewal with a new ` +
1421
+ `review is required (GF-17), obligation '${verified.id}'`,
1422
+ recordIds: [],
1423
+ };
1424
+ }
1425
+ // 4. Claims on this obligation, deterministically ordered.
1426
+ const claims = context.claims
1427
+ .map((claim) => ClaimSchema.safeParse(claim))
1428
+ .filter((parsed) => parsed.success)
1429
+ .map((parsed) => parsed.data)
1430
+ .filter((claim) => claim.obligationId === verified.id)
1431
+ .sort((a, b) => compareStrings(a.testId, b.testId));
1432
+ if (claims.length === 0) {
1433
+ return {
1434
+ verdict: 'missing',
1435
+ reason: `no claim declares '${verified.id}'`,
1436
+ recordIds: [],
1437
+ };
1438
+ }
1439
+ // 5. Records attributed to this obligation (lenient view). Records are
1440
+ // bound to a claim via the witness-issued testId; unattributable
1441
+ // records cannot satisfy anything.
1442
+ const considered = (Array.isArray(context.records) ? context.records : [])
1443
+ .map(asRecord)
1444
+ .filter((record) => record !== null)
1445
+ .filter((record) => record.obligationId === verified.id);
1446
+ const consideredIds = sortedUnique(considered.map((record) => (typeof record.recordId === 'string' ? record.recordId : '')));
1447
+ // 6. Per-claim evidence evaluation with deterministic aggregation:
1448
+ // satisfied beats invalid beats missing.
1449
+ let firstInvalid = null;
1450
+ let firstMissing = null;
1451
+ for (const claim of claims) {
1452
+ const evidence = considered
1453
+ .filter((record) => record.testId === claim.testId)
1454
+ .map((record) => ({ record, trust: trustOf(record) }));
1455
+ const outcome = evaluateClaimEvidence(claim, evidence, verified, classification.primaryKey, context.resource, context.httpRoutes);
1456
+ if (outcome.status === 'satisfied') {
1457
+ return { verdict: 'satisfied', reason: null, recordIds: outcome.recordIds };
1458
+ }
1459
+ if (outcome.status === 'invalid' && firstInvalid === null) {
1460
+ firstInvalid = outcome.reason;
1461
+ }
1462
+ if (outcome.status === 'missing' && firstMissing === null) {
1463
+ firstMissing = outcome.reason;
1464
+ }
1465
+ }
1466
+ if (firstInvalid !== null) {
1467
+ return { verdict: 'invalid', reason: firstInvalid, recordIds: consideredIds };
1468
+ }
1469
+ return {
1470
+ verdict: 'missing',
1471
+ reason: firstMissing ?? `no admissible evidence for '${verified.id}'`,
1472
+ recordIds: consideredIds,
1473
+ };
1474
+ }
1475
+ /**
1476
+ * Evaluates a batch of obligations against one context and returns
1477
+ * report-ready entries sorted by obligation id, each enriched with the
1478
+ * highest trust tier among its records (SARIF properties), optional
1479
+ * detector provenance passthrough, and the plan §5.4 cause code +
1480
+ * next action for the shared report model.
1481
+ *
1482
+ * Args:
1483
+ * obligations: obligations to evaluate.
1484
+ * context: the shared pin-#9 evaluation context.
1485
+ *
1486
+ * Returns:
1487
+ * ObligationVerdict[]: sorted by obligation id; deterministic.
1488
+ */
1489
+ export function evaluateObligations(obligations, context) {
1490
+ parseInstant(context.now);
1491
+ return obligations
1492
+ .map((obligation) => {
1493
+ const outcome = evaluateObligation(obligation, context);
1494
+ const records = (Array.isArray(context.records) ? context.records : [])
1495
+ .map(asRecord)
1496
+ .filter((record) => record !== null)
1497
+ .filter((record) => record.obligationId === obligation.id);
1498
+ const trustTier = records.some((record) => trustOf(record) === 'witnessed')
1499
+ ? 'witnessed'
1500
+ : records.length > 0
1501
+ ? 'claimed'
1502
+ : null;
1503
+ const mapped = causeForVerdict({
1504
+ obligationId: obligation.id,
1505
+ contract: obligation.contract,
1506
+ verdict: outcome.verdict,
1507
+ reason: outcome.reason,
1508
+ });
1509
+ return { obligation, ...outcome, trustTier, cause: mapped.cause, nextAction: mapped.nextAction };
1510
+ })
1511
+ .sort((a, b) => compareStrings(a.obligation.id, b.obligation.id));
1512
+ }
1513
+ //# sourceMappingURL=evaluate.js.map