@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.
- package/LICENSE +202 -0
- package/dist/baselines/adoption.d.ts +76 -0
- package/dist/baselines/adoption.d.ts.map +1 -0
- package/dist/baselines/adoption.js +163 -0
- package/dist/baselines/adoption.js.map +1 -0
- package/dist/baselines/index.d.ts +102 -0
- package/dist/baselines/index.d.ts.map +1 -0
- package/dist/baselines/index.js +186 -0
- package/dist/baselines/index.js.map +1 -0
- package/dist/canonical-json.d.ts +51 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +112 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/classifier/bind.d.ts +64 -0
- package/dist/classifier/bind.d.ts.map +1 -0
- package/dist/classifier/bind.js +188 -0
- package/dist/classifier/bind.js.map +1 -0
- package/dist/classifier/classify.d.ts +154 -0
- package/dist/classifier/classify.d.ts.map +1 -0
- package/dist/classifier/classify.js +1207 -0
- package/dist/classifier/classify.js.map +1 -0
- package/dist/classifier/glob.d.ts +48 -0
- package/dist/classifier/glob.d.ts.map +1 -0
- package/dist/classifier/glob.js +104 -0
- package/dist/classifier/glob.js.map +1 -0
- package/dist/classifier/index.d.ts +10 -0
- package/dist/classifier/index.d.ts.map +1 -0
- package/dist/classifier/index.js +21 -0
- package/dist/classifier/index.js.map +1 -0
- package/dist/classifier/schema.d.ts +150 -0
- package/dist/classifier/schema.d.ts.map +1 -0
- package/dist/classifier/schema.js +139 -0
- package/dist/classifier/schema.js.map +1 -0
- package/dist/config/index.d.ts +259 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +523 -0
- package/dist/config/index.js.map +1 -0
- package/dist/fingerprints.d.ts +56 -0
- package/dist/fingerprints.d.ts.map +1 -0
- package/dist/fingerprints.js +59 -0
- package/dist/fingerprints.js.map +1 -0
- package/dist/graph/build.d.ts +16 -0
- package/dist/graph/build.d.ts.map +1 -0
- package/dist/graph/build.js +423 -0
- package/dist/graph/build.js.map +1 -0
- package/dist/graph/index.d.ts +12 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +10 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/schema.d.ts +448 -0
- package/dist/graph/schema.d.ts.map +1 -0
- package/dist/graph/schema.js +237 -0
- package/dist/graph/schema.js.map +1 -0
- package/dist/graph/symbols.d.ts +129 -0
- package/dist/graph/symbols.d.ts.map +1 -0
- package/dist/graph/symbols.js +177 -0
- package/dist/graph/symbols.js.map +1 -0
- package/dist/graph/util.d.ts +40 -0
- package/dist/graph/util.d.ts.map +1 -0
- package/dist/graph/util.js +41 -0
- package/dist/graph/util.js.map +1 -0
- package/dist/index.d.ts +683 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +563 -0
- package/dist/index.js.map +1 -0
- package/dist/mapping/resolve.d.ts +177 -0
- package/dist/mapping/resolve.d.ts.map +1 -0
- package/dist/mapping/resolve.js +523 -0
- package/dist/mapping/resolve.js.map +1 -0
- package/dist/policy/coverage.d.ts +79 -0
- package/dist/policy/coverage.d.ts.map +1 -0
- package/dist/policy/coverage.js +119 -0
- package/dist/policy/coverage.js.map +1 -0
- package/dist/policy/evaluate.d.ts +275 -0
- package/dist/policy/evaluate.d.ts.map +1 -0
- package/dist/policy/evaluate.js +382 -0
- package/dist/policy/evaluate.js.map +1 -0
- package/dist/policy/index.d.ts +15 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +14 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/trusted.d.ts +59 -0
- package/dist/policy/trusted.d.ts.map +1 -0
- package/dist/policy/trusted.js +96 -0
- package/dist/policy/trusted.js.map +1 -0
- package/dist/provenance.d.ts +170 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +306 -0
- package/dist/provenance.js.map +1 -0
- package/dist/receipt/index.d.ts +74 -0
- package/dist/receipt/index.d.ts.map +1 -0
- package/dist/receipt/index.js +154 -0
- package/dist/receipt/index.js.map +1 -0
- package/dist/report/index.d.ts +100 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +355 -0
- package/dist/report/index.js.map +1 -0
- package/dist/schemas/adoption.d.ts +40 -0
- package/dist/schemas/adoption.d.ts.map +1 -0
- package/dist/schemas/adoption.js +87 -0
- package/dist/schemas/adoption.js.map +1 -0
- package/dist/schemas/baseline.d.ts +19 -0
- package/dist/schemas/baseline.d.ts.map +1 -0
- package/dist/schemas/baseline.js +45 -0
- package/dist/schemas/baseline.js.map +1 -0
- package/dist/schemas/claim.d.ts +32 -0
- package/dist/schemas/claim.d.ts.map +1 -0
- package/dist/schemas/claim.js +34 -0
- package/dist/schemas/claim.js.map +1 -0
- package/dist/schemas/classification-policy.d.ts +84 -0
- package/dist/schemas/classification-policy.d.ts.map +1 -0
- package/dist/schemas/classification-policy.js +130 -0
- package/dist/schemas/classification-policy.js.map +1 -0
- package/dist/schemas/classification-signal.d.ts +125 -0
- package/dist/schemas/classification-signal.d.ts.map +1 -0
- package/dist/schemas/classification-signal.js +126 -0
- package/dist/schemas/classification-signal.js.map +1 -0
- package/dist/schemas/classification.d.ts +120 -0
- package/dist/schemas/classification.d.ts.map +1 -0
- package/dist/schemas/classification.js +154 -0
- package/dist/schemas/classification.js.map +1 -0
- package/dist/schemas/common.d.ts +73 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +60 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/schemas/coverage-policy.d.ts +89 -0
- package/dist/schemas/coverage-policy.d.ts.map +1 -0
- package/dist/schemas/coverage-policy.js +81 -0
- package/dist/schemas/coverage-policy.js.map +1 -0
- package/dist/schemas/evidence.d.ts +65 -0
- package/dist/schemas/evidence.d.ts.map +1 -0
- package/dist/schemas/evidence.js +64 -0
- package/dist/schemas/evidence.js.map +1 -0
- package/dist/schemas/execution-result.d.ts +214 -0
- package/dist/schemas/execution-result.d.ts.map +1 -0
- package/dist/schemas/execution-result.js +240 -0
- package/dist/schemas/execution-result.js.map +1 -0
- package/dist/schemas/gate-receipt.d.ts +86 -0
- package/dist/schemas/gate-receipt.d.ts.map +1 -0
- package/dist/schemas/gate-receipt.js +193 -0
- package/dist/schemas/gate-receipt.js.map +1 -0
- package/dist/schemas/index.d.ts +22 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +22 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/obligation.d.ts +37 -0
- package/dist/schemas/obligation.d.ts.map +1 -0
- package/dist/schemas/obligation.js +46 -0
- package/dist/schemas/obligation.js.map +1 -0
- package/dist/schemas/plugin.d.ts +19 -0
- package/dist/schemas/plugin.d.ts.map +1 -0
- package/dist/schemas/plugin.js +20 -0
- package/dist/schemas/plugin.js.map +1 -0
- package/dist/schemas/policy.d.ts +73 -0
- package/dist/schemas/policy.d.ts.map +1 -0
- package/dist/schemas/policy.js +46 -0
- package/dist/schemas/policy.js.map +1 -0
- package/dist/schemas/resource.d.ts +26 -0
- package/dist/schemas/resource.d.ts.map +1 -0
- package/dist/schemas/resource.js +29 -0
- package/dist/schemas/resource.js.map +1 -0
- package/dist/schemas/run-manifest.d.ts +72 -0
- package/dist/schemas/run-manifest.d.ts.map +1 -0
- package/dist/schemas/run-manifest.js +125 -0
- package/dist/schemas/run-manifest.js.map +1 -0
- package/dist/schemas/runner-adapter.d.ts +169 -0
- package/dist/schemas/runner-adapter.d.ts.map +1 -0
- package/dist/schemas/runner-adapter.js +2 -0
- package/dist/schemas/runner-adapter.js.map +1 -0
- package/dist/schemas/test-catalog.d.ts +472 -0
- package/dist/schemas/test-catalog.d.ts.map +1 -0
- package/dist/schemas/test-catalog.js +311 -0
- package/dist/schemas/test-catalog.js.map +1 -0
- package/dist/schemas/test-map.d.ts +99 -0
- package/dist/schemas/test-map.d.ts.map +1 -0
- package/dist/schemas/test-map.js +103 -0
- package/dist/schemas/test-map.js.map +1 -0
- package/dist/schemas/verdict.d.ts +73 -0
- package/dist/schemas/verdict.d.ts.map +1 -0
- package/dist/schemas/verdict.js +92 -0
- package/dist/schemas/verdict.js.map +1 -0
- package/dist/schemas/waiver.d.ts +36 -0
- package/dist/schemas/waiver.d.ts.map +1 -0
- package/dist/schemas/waiver.js +42 -0
- package/dist/schemas/waiver.js.map +1 -0
- package/dist/supervision/index.d.ts +170 -0
- package/dist/supervision/index.d.ts.map +1 -0
- package/dist/supervision/index.js +354 -0
- package/dist/supervision/index.js.map +1 -0
- package/dist/testing/clock.d.ts +50 -0
- package/dist/testing/clock.d.ts.map +1 -0
- package/dist/testing/clock.js +75 -0
- package/dist/testing/clock.js.map +1 -0
- package/dist/testing/env.d.ts +66 -0
- package/dist/testing/env.d.ts.map +1 -0
- package/dist/testing/env.js +106 -0
- package/dist/testing/env.js.map +1 -0
- package/dist/testing/gate-runner.d.ts +141 -0
- package/dist/testing/gate-runner.d.ts.map +1 -0
- package/dist/testing/gate-runner.js +173 -0
- package/dist/testing/gate-runner.js.map +1 -0
- package/dist/testing/gf19.d.ts +106 -0
- package/dist/testing/gf19.d.ts.map +1 -0
- package/dist/testing/gf19.js +282 -0
- package/dist/testing/gf19.js.map +1 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +14 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/red-probe.d.ts +138 -0
- package/dist/testing/red-probe.d.ts.map +1 -0
- package/dist/testing/red-probe.js +251 -0
- package/dist/testing/red-probe.js.map +1 -0
- package/dist/testing/temp-repo.d.ts +126 -0
- package/dist/testing/temp-repo.d.ts.map +1 -0
- package/dist/testing/temp-repo.js +214 -0
- package/dist/testing/temp-repo.js.map +1 -0
- package/dist/verdict/cause.d.ts +99 -0
- package/dist/verdict/cause.d.ts.map +1 -0
- package/dist/verdict/cause.js +206 -0
- package/dist/verdict/cause.js.map +1 -0
- package/dist/verdict/evaluate.d.ts +135 -0
- package/dist/verdict/evaluate.d.ts.map +1 -0
- package/dist/verdict/evaluate.js +1513 -0
- package/dist/verdict/evaluate.js.map +1 -0
- package/dist/verdict/index.d.ts +21 -0
- package/dist/verdict/index.d.ts.map +1 -0
- package/dist/verdict/index.js +20 -0
- package/dist/verdict/index.js.map +1 -0
- package/dist/verdict/pack-verifiers.d.ts +97 -0
- package/dist/verdict/pack-verifiers.d.ts.map +1 -0
- package/dist/verdict/pack-verifiers.js +763 -0
- package/dist/verdict/pack-verifiers.js.map +1 -0
- package/dist/verdict/registry.d.ts +180 -0
- package/dist/verdict/registry.d.ts.map +1 -0
- package/dist/verdict/registry.js +67 -0
- package/dist/verdict/registry.js.map +1 -0
- package/dist/waivers/index.d.ts +103 -0
- package/dist/waivers/index.d.ts.map +1 -0
- package/dist/waivers/index.js +192 -0
- package/dist/waivers/index.js.map +1 -0
- 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
|