@gate-forge/pack-playwright 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +16 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +17 -1
- package/dist/constants.js.map +1 -1
- package/dist/discovery/inference.d.ts.map +1 -1
- package/dist/discovery/inference.js +24 -0
- package/dist/discovery/inference.js.map +1 -1
- package/dist/discovery/static-discovery.d.ts +7 -0
- package/dist/discovery/static-discovery.d.ts.map +1 -1
- package/dist/discovery/static-discovery.js +33 -2
- package/dist/discovery/static-discovery.js.map +1 -1
- package/dist/fixture/evidence.d.ts +14 -0
- package/dist/fixture/evidence.d.ts.map +1 -1
- package/dist/fixture/evidence.js +23 -0
- package/dist/fixture/evidence.js.map +1 -1
- package/dist/fixture/witness-client.d.ts +15 -1
- package/dist/fixture/witness-client.d.ts.map +1 -1
- package/dist/fixture/witness-client.js +18 -0
- package/dist/fixture/witness-client.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/supervisor/client.d.ts +16 -1
- package/dist/supervisor/client.d.ts.map +1 -1
- package/dist/supervisor/client.js +19 -0
- package/dist/supervisor/client.js.map +1 -1
- package/dist/supervisor/drain.d.ts +7 -3
- package/dist/supervisor/drain.d.ts.map +1 -1
- package/dist/supervisor/drain.js +70 -2
- package/dist/supervisor/drain.js.map +1 -1
- package/dist/surface.d.ts +67 -8
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +132 -34
- package/dist/surface.js.map +1 -1
- package/dist/witness/adapter-registry.d.ts +16 -0
- package/dist/witness/adapter-registry.d.ts.map +1 -1
- package/dist/witness/adapter-registry.js +103 -0
- package/dist/witness/adapter-registry.js.map +1 -1
- package/dist/witness/behavior-request.d.ts +80 -0
- package/dist/witness/behavior-request.d.ts.map +1 -0
- package/dist/witness/behavior-request.js +277 -0
- package/dist/witness/behavior-request.js.map +1 -0
- package/dist/witness/behavior.d.ts +52 -0
- package/dist/witness/behavior.d.ts.map +1 -0
- package/dist/witness/behavior.js +118 -0
- package/dist/witness/behavior.js.map +1 -0
- package/dist/witness/browser.d.ts +43 -1
- package/dist/witness/browser.d.ts.map +1 -1
- package/dist/witness/browser.js +55 -4
- package/dist/witness/browser.js.map +1 -1
- package/dist/witness/fixture-provider.d.ts +86 -0
- package/dist/witness/fixture-provider.d.ts.map +1 -0
- package/dist/witness/fixture-provider.js +109 -0
- package/dist/witness/fixture-provider.js.map +1 -0
- package/dist/witness/server.d.ts.map +1 -1
- package/dist/witness/server.js +1238 -3
- package/dist/witness/server.js.map +1 -1
- package/dist/witness/types.d.ts +243 -0
- package/dist/witness/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/python/__pycache__/gateforge_persistence_intents.cpython-312.pyc +0 -0
package/dist/witness/server.js
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* | `GET /ledger-attestation` | run token + verifier key (supervisor) |
|
|
29
29
|
* | `POST /runs/expected-set` | run token + verifier key (supervisor) |
|
|
30
30
|
* | `POST /runs/server-e2e-declarations` | run token + verifier key (supervisor)|
|
|
31
|
+
* | `POST /runs/observe-declarations` | run token + verifier key (supervisor) |
|
|
32
|
+
* | `POST /observe/finalize` | run token + verifier key (supervisor) |
|
|
31
33
|
* | `POST /witness/server-persistence` | run token + verifier key (supervisor; |
|
|
32
34
|
* | | the drain forwards intents — the suite |
|
|
33
35
|
* | | can only WRITE spool lines) |
|
|
@@ -88,6 +90,15 @@
|
|
|
88
90
|
* outbox) that can never honestly appear in a UI. Probes run ONLY in
|
|
89
91
|
* this trusted process; missing adapter/probe/declaration and replayed
|
|
90
92
|
* sequences resolve to typed failures, never to satisfaction.
|
|
93
|
+
* - `POST /runs/observe-declarations` — SUPERVISOR ONLY: registers the
|
|
94
|
+
* `observed-e2e` obligations BEFORE the run (same bind-once contract
|
|
95
|
+
* as the server-e2e set).
|
|
96
|
+
* - `POST /observe/finalize` — SUPERVISOR ONLY: resolves one OPEN
|
|
97
|
+
* session's observe-declared claims against its own proxied traffic
|
|
98
|
+
* plus independent adapter reads; stamps witnessed
|
|
99
|
+
* `persistence.observed` records (`channel: 'observe'`) for whatever
|
|
100
|
+
* resolves. Non-resolutions are typed notes — never satisfaction,
|
|
101
|
+
* never a run failure.
|
|
91
102
|
* - `POST /witness/http-observation` — consumes one engine-observed
|
|
92
103
|
* proxied exchange for an http:* claim. Phase 1: the caller must hold
|
|
93
104
|
* a valid OPEN session and the exchange must have been observed
|
|
@@ -114,16 +125,18 @@ import { createServer, request } from 'node:http';
|
|
|
114
125
|
import { createHash, randomUUID } from 'node:crypto';
|
|
115
126
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
116
127
|
import { join, resolve } from 'node:path';
|
|
117
|
-
import { ATTESTATION_VERSION, attestationMac, enumerationDigestOf, recordIdOf, } from '@gate-forge/core';
|
|
128
|
+
import { ATTESTATION_VERSION, attestationMac, behaviorActionDigestOf, BehaviorCatalogSchema, BEHAVIOR_CASE_KIND, enumerationDigestOf, interpretObservedPath, pathMatchesShape, recordIdOf, resolveHttpRoute, } from '@gate-forge/core';
|
|
118
129
|
import { canonicalOf } from '../json.js';
|
|
119
|
-
import { DEFAULT_REQUEST_TIMEOUT_MS, KNOWN_RECORD_KINDS, LOOPBACK_HOSTNAME, PERSISTENCE_KIND, RUN_HEADER, VERIFIER_HEADER, } from '../constants.js';
|
|
130
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, KNOWN_RECORD_KINDS, LOOPBACK_HOSTNAME, OBSERVED_KIND, PERSISTENCE_KIND, RUN_HEADER, VERIFIER_HEADER, } from '../constants.js';
|
|
120
131
|
import { loadAdapters, makeAdapterContext } from './adapter-registry.js';
|
|
121
132
|
import { AttestationError, assertLoopback, envFingerprintMismatch, probeEnvFingerprint, } from './env-attestation.js';
|
|
122
133
|
import { hostResolverRules, pinnedLoopbackIps, pinnedGet } from './loopback-pins.js';
|
|
123
134
|
import { loadClassifications, toClassificationView } from './classifications.js';
|
|
124
135
|
import { EngineBrowserError, EngineBrowserManager, driveEngineAction, readEngineVisible, } from './browser.js';
|
|
125
136
|
import { SURFACE_DESCRIPTOR_VERSION, validateSurface, } from '../surface.js';
|
|
126
|
-
import {
|
|
137
|
+
import { validateScopeSnapshot } from './behavior.js';
|
|
138
|
+
import { BEHAVIOR_BODY_LIMIT_BYTES, BehaviorDriverError, driveBehaviorRequest } from './behavior-request.js';
|
|
139
|
+
import { OBSERVE_CHANNEL, OBSERVED_E2E_TEST_KIND, SERVER_CHANNEL, SERVER_E2E_TEST_KIND } from '../constants.js';
|
|
127
140
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
128
141
|
const OBLIGATION_ID_PATTERN = /^[^:]+:.+$/;
|
|
129
142
|
/**
|
|
@@ -133,6 +146,16 @@ const OBLIGATION_ID_PATTERN = /^[^:]+:.+$/;
|
|
|
133
146
|
* streams to the browser unbuffered — the snapshot is a tap, not a gate.
|
|
134
147
|
*/
|
|
135
148
|
const OBSERVED_BODY_SNAPSHOT_BYTES = 16384;
|
|
149
|
+
/**
|
|
150
|
+
* Bounded request-body snapshot the observation proxy keeps per
|
|
151
|
+
* forwarded exchange (Observe channel, Phase 2): the request body is
|
|
152
|
+
* already buffered for forwarding, so retaining a capped copy costs one
|
|
153
|
+
* slice. Bodies beyond the cap are flagged truncated — an observe
|
|
154
|
+
* finalize can never echo what it cannot see, so oversized intents
|
|
155
|
+
* grade typed-missing instead of satisfying on a prefix. Binary-safe:
|
|
156
|
+
* stored raw; the finalize path parses JSON/form text from it.
|
|
157
|
+
*/
|
|
158
|
+
const OBSERVED_REQUEST_BODY_BYTES = 65536;
|
|
136
159
|
/** Fail-closed witness configuration/startup error. */
|
|
137
160
|
export class WitnessStartupError extends Error {
|
|
138
161
|
constructor(message) {
|
|
@@ -179,6 +202,19 @@ function normalizeMountPath(raw) {
|
|
|
179
202
|
}
|
|
180
203
|
return path;
|
|
181
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Lowercases a request content-type header to its media type without
|
|
207
|
+
* parameters (`'Application/JSON; charset=utf-8'` → `'application/json'`),
|
|
208
|
+
* or null when absent/unparseable. The Observe finalize path uses it to
|
|
209
|
+
* decide body parsing (JSON vs form); anything else is ineligible.
|
|
210
|
+
*/
|
|
211
|
+
function contentTypeOf(raw) {
|
|
212
|
+
const first = Array.isArray(raw) ? raw[0] : raw;
|
|
213
|
+
if (typeof first !== 'string')
|
|
214
|
+
return null;
|
|
215
|
+
const media = first.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
216
|
+
return media.length > 0 ? media : null;
|
|
217
|
+
}
|
|
182
218
|
/**
|
|
183
219
|
* Strips the declared mount prefix from a proxied request URL (path
|
|
184
220
|
* plus possible query/fragment), returning the backend-facing URL the
|
|
@@ -284,6 +320,10 @@ async function startObservedProxy(state, sessionId) {
|
|
|
284
320
|
seq: (state.observedSeq += 1),
|
|
285
321
|
bodySha256: createHash('sha256').update(Buffer.concat(snapshot)).digest('hex'),
|
|
286
322
|
bodyBytes: totalBytes,
|
|
323
|
+
requestBody: body.length === 0 ? null : Buffer.from(body.subarray(0, OBSERVED_REQUEST_BODY_BYTES)),
|
|
324
|
+
requestTruncated: body.length > OBSERVED_REQUEST_BODY_BYTES,
|
|
325
|
+
requestBytes: body.length,
|
|
326
|
+
requestContentType: contentTypeOf(req.headers['content-type']),
|
|
287
327
|
sessionId,
|
|
288
328
|
tick: (state.tick += 1),
|
|
289
329
|
});
|
|
@@ -464,6 +504,8 @@ export async function startWitness(options) {
|
|
|
464
504
|
serverE2eDeclarations: null,
|
|
465
505
|
serverPreObservations: new Map(),
|
|
466
506
|
serverIntentSequences: new Map(),
|
|
507
|
+
observeDeclarations: null,
|
|
508
|
+
observeSnapshots: new Map(),
|
|
467
509
|
observed: [],
|
|
468
510
|
observedSeq: 0,
|
|
469
511
|
runContext: null,
|
|
@@ -471,6 +513,8 @@ export async function startWitness(options) {
|
|
|
471
513
|
proxyInFlight: 0,
|
|
472
514
|
expectedTests: new Map(),
|
|
473
515
|
enumerationDigest: null,
|
|
516
|
+
behaviorCatalog: null,
|
|
517
|
+
caseExecutions: new Map(),
|
|
474
518
|
tick: 0,
|
|
475
519
|
sessions: new Map(),
|
|
476
520
|
workerSessions: new Map(),
|
|
@@ -890,6 +934,14 @@ async function handleBrowserAction(state, res, body) {
|
|
|
890
934
|
seq: (state.observedSeq += 1),
|
|
891
935
|
bodySha256: createHash('sha256').update(exchange.body).digest('hex'),
|
|
892
936
|
bodyBytes: exchange.body.length,
|
|
937
|
+
// Engine-captured exchanges carry no request body (the engine
|
|
938
|
+
// typed the input; entered fields ride the ui.action record) —
|
|
939
|
+
// they can never serve an observe finalize, which requires the
|
|
940
|
+
// proxied request bytes.
|
|
941
|
+
requestBody: null,
|
|
942
|
+
requestTruncated: false,
|
|
943
|
+
requestBytes: 0,
|
|
944
|
+
requestContentType: null,
|
|
893
945
|
sessionId: session.sessionId,
|
|
894
946
|
tick: (state.tick += 1),
|
|
895
947
|
});
|
|
@@ -1040,10 +1092,30 @@ async function handleRequest(state, req, res) {
|
|
|
1040
1092
|
await handleExpectedSet(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
|
|
1041
1093
|
return;
|
|
1042
1094
|
}
|
|
1095
|
+
if (req.method === 'POST' && path === '/runs/behavior-catalog') {
|
|
1096
|
+
await handleBehaviorCatalog(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (req.method === 'POST' && path === '/behavior/execute') {
|
|
1100
|
+
await handleBehaviorExecute(state, res, (await readBody(req)));
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (req.method === 'POST' && path === '/behavior/principal') {
|
|
1104
|
+
await handleBehaviorPrincipal(state, res, (await readBody(req)));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1043
1107
|
if (req.method === 'POST' && path === '/runs/server-e2e-declarations') {
|
|
1044
1108
|
await handleServerE2eDeclarations(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
|
|
1045
1109
|
return;
|
|
1046
1110
|
}
|
|
1111
|
+
if (req.method === 'POST' && path === '/runs/observe-declarations') {
|
|
1112
|
+
await handleObserveDeclarations(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (req.method === 'POST' && path === '/observe/finalize') {
|
|
1116
|
+
await handleObserveFinalize(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1047
1119
|
if (req.method === 'GET' && path === '/runs/execution-trace') {
|
|
1048
1120
|
requireSupervisor(state, req.headers[VERIFIER_HEADER]);
|
|
1049
1121
|
sendJson(res, 200, executionTraceOf(state));
|
|
@@ -1231,6 +1303,653 @@ async function handleExpectedSet(state, res, verifier, body) {
|
|
|
1231
1303
|
state.enumerationDigest = digest;
|
|
1232
1304
|
sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
|
|
1233
1305
|
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Binds the compiled behavior catalog plus allowed case/test assignments
|
|
1308
|
+
* (plan 2026-09-19 §4.7, Phase 4): supervisor-only, one-time, before any
|
|
1309
|
+
* session opens. The worker can never register a catalog or assign
|
|
1310
|
+
* itself cases — `/behavior/execute` resolves everything from this
|
|
1311
|
+
* binding.
|
|
1312
|
+
*/
|
|
1313
|
+
async function handleBehaviorCatalog(state, res, verifier, body) {
|
|
1314
|
+
requireSupervisor(state, verifier);
|
|
1315
|
+
if (!isPlainObject(body) || !('catalog' in body) || !isPlainObject(body['assignments'])) {
|
|
1316
|
+
throw new HttpError(400, 'behavior-catalog body must be {catalog, assignments: {testId: [caseId, ...]}, routes: [...], authorityProfileDigest}');
|
|
1317
|
+
}
|
|
1318
|
+
const parsed = BehaviorCatalogSchema.safeParse(body['catalog']);
|
|
1319
|
+
if (!parsed.success) {
|
|
1320
|
+
const issue = parsed.error.issues[0];
|
|
1321
|
+
const path = issue === undefined ? '' : ` at '${issue.path.map(String).join('.')}':`;
|
|
1322
|
+
throw new HttpError(400, `behavior catalog is invalid${path} ${issue?.message ?? 'unknown schema error'}`);
|
|
1323
|
+
}
|
|
1324
|
+
const catalog = parsed.data;
|
|
1325
|
+
const rawRoutes = body['routes'];
|
|
1326
|
+
if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) {
|
|
1327
|
+
throw new HttpError(400, 'behavior-catalog routes must be the non-empty complete route inventory — principal attribution needs every applicable route (no any-endpoint fallback)');
|
|
1328
|
+
}
|
|
1329
|
+
const routes = [];
|
|
1330
|
+
for (let index = 0; index < rawRoutes.length; index += 1) {
|
|
1331
|
+
const row = rawRoutes[index];
|
|
1332
|
+
if (typeof row !== 'object' ||
|
|
1333
|
+
row === null ||
|
|
1334
|
+
typeof row['resourceId'] !== 'string' ||
|
|
1335
|
+
row['resourceId'].length === 0 ||
|
|
1336
|
+
typeof row['method'] !== 'string' ||
|
|
1337
|
+
row['method'].length === 0 ||
|
|
1338
|
+
typeof row['canonicalPath'] !== 'string' ||
|
|
1339
|
+
row['canonicalPath'].length === 0) {
|
|
1340
|
+
throw new HttpError(400, `behavior-catalog routes[${String(index)}] must be {resourceId, method, canonicalPath}`);
|
|
1341
|
+
}
|
|
1342
|
+
routes.push({
|
|
1343
|
+
resourceId: row['resourceId'],
|
|
1344
|
+
method: row['method'].toUpperCase(),
|
|
1345
|
+
canonicalPath: row['canonicalPath'],
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
routes.sort((a, b) => (a.resourceId < b.resourceId ? -1 : a.resourceId > b.resourceId ? 1 : 0));
|
|
1349
|
+
const authorityProfileDigest = body['authorityProfileDigest'];
|
|
1350
|
+
if (typeof authorityProfileDigest !== 'string' || !/^[0-9a-f]{64}$/.test(authorityProfileDigest)) {
|
|
1351
|
+
throw new HttpError(400, 'behavior-catalog authorityProfileDigest must be 64-char lowercase hex');
|
|
1352
|
+
}
|
|
1353
|
+
// Approved surface descriptors (Phase 6): optional map of surface key
|
|
1354
|
+
// to descriptor. Each descriptor is structurally validated NOW —
|
|
1355
|
+
// worker-supplied surfaces are never resolved at drive time.
|
|
1356
|
+
const surfaces = new Map();
|
|
1357
|
+
const rawSurfaces = body['surfaces'];
|
|
1358
|
+
if (rawSurfaces !== undefined) {
|
|
1359
|
+
if (!isPlainObject(rawSurfaces)) {
|
|
1360
|
+
throw new HttpError(400, 'behavior-catalog surfaces must be a map of surface key to descriptor');
|
|
1361
|
+
}
|
|
1362
|
+
for (const [surfaceKey, descriptor] of Object.entries(rawSurfaces)) {
|
|
1363
|
+
if (surfaceKey.length === 0) {
|
|
1364
|
+
throw new HttpError(400, 'behavior-catalog surface keys must be non-empty');
|
|
1365
|
+
}
|
|
1366
|
+
try {
|
|
1367
|
+
surfaces.set(surfaceKey, validateSurface(descriptor));
|
|
1368
|
+
}
|
|
1369
|
+
catch (error) {
|
|
1370
|
+
throw new HttpError(400, `behavior-catalog surface '${surfaceKey}' is invalid: ${error.message}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
const assignments = new Map();
|
|
1375
|
+
for (const [testId, ids] of Object.entries(body['assignments'])) {
|
|
1376
|
+
if (testId.length === 0 || !Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === 'string')) {
|
|
1377
|
+
throw new HttpError(400, `behavior-catalog assignment for test '${testId}' must be a non-empty array of case ids`);
|
|
1378
|
+
}
|
|
1379
|
+
const resolved = new Set();
|
|
1380
|
+
for (const id of ids) {
|
|
1381
|
+
const found = catalog.cases.find((item) => item.caseId === id);
|
|
1382
|
+
if (found === undefined) {
|
|
1383
|
+
throw new HttpError(400, `behavior-catalog assignment for test '${testId}' names unknown case '${id}' — cases resolve by canonical case id only`);
|
|
1384
|
+
}
|
|
1385
|
+
resolved.add(found.caseId);
|
|
1386
|
+
}
|
|
1387
|
+
assignments.set(testId, resolved);
|
|
1388
|
+
}
|
|
1389
|
+
if (state.behaviorCatalog !== null) {
|
|
1390
|
+
if (state.behaviorCatalog.catalogDigest === catalog.catalogDigest) {
|
|
1391
|
+
const response = {
|
|
1392
|
+
bound: true,
|
|
1393
|
+
caseCount: catalog.cases.length,
|
|
1394
|
+
assignmentCount: assignments.size,
|
|
1395
|
+
routeCount: routes.length,
|
|
1396
|
+
};
|
|
1397
|
+
sendJson(res, 200, response);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
sendJson(res, 409, {
|
|
1401
|
+
error: 'a behavior catalog is already bound to this run and differs; the catalog is a PRE-run ' +
|
|
1402
|
+
'fact and is never relabeled — start a fresh witness for a new invocation',
|
|
1403
|
+
});
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (state.sessions.size > 0) {
|
|
1407
|
+
sendJson(res, 409, {
|
|
1408
|
+
error: 'sessions were already opened on this witness; the behavior catalog must be registered ' +
|
|
1409
|
+
'BEFORE the run — start a fresh witness for a new invocation',
|
|
1410
|
+
});
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
state.behaviorCatalog = { catalog, catalogDigest: catalog.catalogDigest, assignments, routes, authorityProfileDigest, surfaces };
|
|
1414
|
+
const response = {
|
|
1415
|
+
bound: true,
|
|
1416
|
+
caseCount: catalog.cases.length,
|
|
1417
|
+
assignmentCount: assignments.size,
|
|
1418
|
+
routeCount: routes.length,
|
|
1419
|
+
};
|
|
1420
|
+
sendJson(res, 200, response);
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Executes one required behavior case through the witness-owned lifecycle
|
|
1424
|
+
* (plan 2026-09-19 §4.6, Phase 4): the worker names an allowed caseId and
|
|
1425
|
+
* nothing else. Actor credentials, expectations, before-state, and origin
|
|
1426
|
+
* resolve from the supervisor-bound catalog and the trusted fixture
|
|
1427
|
+
* provider — input/actor overrides are request errors, never proof.
|
|
1428
|
+
*
|
|
1429
|
+
* Phase 4 executes fixture preparation plus the authoritative before
|
|
1430
|
+
* snapshot; the principal operation driver lands in Phase 5, so a
|
|
1431
|
+
* successful call leaves the execution at `before-snapshot-complete`
|
|
1432
|
+
* with a redacted reference (no credentials, no subjects).
|
|
1433
|
+
*/
|
|
1434
|
+
async function handleBehaviorExecute(state, res, body) {
|
|
1435
|
+
if (!isPlainObject(body)) {
|
|
1436
|
+
throw new HttpError(400, 'behavior/execute body must be {sessionId, sessionToken, caseId}');
|
|
1437
|
+
}
|
|
1438
|
+
const keys = Object.keys(body).sort();
|
|
1439
|
+
if (keys.length !== 3 || keys[0] !== 'caseId' || keys[1] !== 'sessionId' || keys[2] !== 'sessionToken') {
|
|
1440
|
+
throw new HttpError(400, 'behavior/execute accepts exactly {sessionId, sessionToken, caseId} — unknown keys are errors; ' +
|
|
1441
|
+
'a worker can name an allowed case but cannot post actor credentials, expectations, or origin');
|
|
1442
|
+
}
|
|
1443
|
+
const caseId = body['caseId'];
|
|
1444
|
+
if (typeof caseId !== 'string' || caseId.length === 0) {
|
|
1445
|
+
throw new HttpError(400, 'behavior/execute requires a non-empty string caseId');
|
|
1446
|
+
}
|
|
1447
|
+
const binding = state.behaviorCatalog;
|
|
1448
|
+
if (binding === null) {
|
|
1449
|
+
throw new HttpError(409, 'no behavior catalog is bound to this run — register POST /runs/behavior-catalog before the run');
|
|
1450
|
+
}
|
|
1451
|
+
const session = requireOpenSession(state, body);
|
|
1452
|
+
const compiled = binding.catalog.cases.find((item) => item.caseId === caseId);
|
|
1453
|
+
if (compiled === undefined) {
|
|
1454
|
+
throw new HttpError(400, `behavior/execute names unknown case '${caseId}' — cases resolve by canonical case id only`);
|
|
1455
|
+
}
|
|
1456
|
+
const allowed = binding.assignments.get(session.testId);
|
|
1457
|
+
if (allowed === undefined || !allowed.has(compiled.caseId)) {
|
|
1458
|
+
throw new HttpError(403, `case '${compiled.definition.id}' is not assigned to test '${session.testId}' — a test executes only its own allowed cases`);
|
|
1459
|
+
}
|
|
1460
|
+
if (state.caseExecutions.has(compiled.caseId)) {
|
|
1461
|
+
throw new HttpError(409, `case '${compiled.definition.id}' was already executed in this run — one execution per required case per run`);
|
|
1462
|
+
}
|
|
1463
|
+
const provider = state.options.fixtureProvider ?? null;
|
|
1464
|
+
if (provider === null) {
|
|
1465
|
+
throw new HttpError(409, 'no trusted fixture provider is configured — strong behavior cases block (suite-supplied fixtures are never a fallback)');
|
|
1466
|
+
}
|
|
1467
|
+
let lease;
|
|
1468
|
+
try {
|
|
1469
|
+
lease = await provider.prepare({
|
|
1470
|
+
recipe: compiled.definition.fixture,
|
|
1471
|
+
runId: state.options.runId,
|
|
1472
|
+
caseId: compiled.caseId,
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
catch (error) {
|
|
1476
|
+
throw new HttpError(500, `fixture preparation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (lease === null ||
|
|
1479
|
+
typeof lease !== 'object' ||
|
|
1480
|
+
typeof lease.namespace !== 'string' ||
|
|
1481
|
+
lease.namespace.length === 0) {
|
|
1482
|
+
try {
|
|
1483
|
+
await provider.release(lease?.leaseId ?? 'unknown');
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
// Release is best-effort; the preparation failure below is the verdict.
|
|
1487
|
+
}
|
|
1488
|
+
throw new HttpError(500, 'fixture preparation returned a malformed lease (fail closed)');
|
|
1489
|
+
}
|
|
1490
|
+
const executionId = randomUUID();
|
|
1491
|
+
const execution = {
|
|
1492
|
+
executionId,
|
|
1493
|
+
caseId: compiled.caseId,
|
|
1494
|
+
testId: session.testId,
|
|
1495
|
+
sessionId: session.sessionId,
|
|
1496
|
+
state: 'fixture-prepared',
|
|
1497
|
+
lease: lease,
|
|
1498
|
+
beforeSnapshots: [],
|
|
1499
|
+
beforeCheckpoints: {},
|
|
1500
|
+
detail: null,
|
|
1501
|
+
};
|
|
1502
|
+
state.caseExecutions.set(compiled.caseId, execution);
|
|
1503
|
+
const failExecution = (detail) => {
|
|
1504
|
+
execution.state = 'failed';
|
|
1505
|
+
execution.detail = detail;
|
|
1506
|
+
// Namespace cleanup runs on errors without changing the verdict.
|
|
1507
|
+
// Fire-and-forget with a floor: the thrown error carries the failure.
|
|
1508
|
+
void Promise.resolve(provider.release(execution.lease.leaseId)).catch(() => { });
|
|
1509
|
+
return new HttpError(409, detail);
|
|
1510
|
+
};
|
|
1511
|
+
// Authoritative before snapshots over every declared effect scope. Any
|
|
1512
|
+
// incomplete collection fails the execution with a diagnostic fact —
|
|
1513
|
+
// never a satisfying observation.
|
|
1514
|
+
const checkpoints = {};
|
|
1515
|
+
for (const effect of compiled.effects) {
|
|
1516
|
+
const adapter = state.adapters.get(effect.adapter);
|
|
1517
|
+
if (adapter === undefined) {
|
|
1518
|
+
throw failExecution(`observation incomplete: no reviewed adapter '${effect.adapter}' for scope '${effect.scope}' (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1519
|
+
}
|
|
1520
|
+
if (typeof adapter.snapshotScope !== 'function') {
|
|
1521
|
+
throw failExecution(`observation incomplete: adapter '${effect.adapter}' cannot observe scope '${effect.scope}' — snapshotScope is unavailable (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1522
|
+
}
|
|
1523
|
+
const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl ?? '';
|
|
1524
|
+
const ctx = makeAdapterContext(baseUrl, effect.resourceId, () => {
|
|
1525
|
+
throw new Error('snapshot observations must not use the candidate GET transport');
|
|
1526
|
+
}, state.options.adapterReadAuthorization
|
|
1527
|
+
? { authorization: state.options.adapterReadAuthorization }
|
|
1528
|
+
: undefined);
|
|
1529
|
+
let snapshot;
|
|
1530
|
+
try {
|
|
1531
|
+
snapshot = await adapter.snapshotScope(ctx, { scope: effect.scope, fixtureNamespace: execution.lease.namespace });
|
|
1532
|
+
}
|
|
1533
|
+
catch (error) {
|
|
1534
|
+
throw failExecution(`observation incomplete: scope '${effect.scope}' collection failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1535
|
+
}
|
|
1536
|
+
const validated = validateScopeSnapshot(snapshot, {
|
|
1537
|
+
scope: effect.scope,
|
|
1538
|
+
fixtureNamespace: execution.lease.namespace,
|
|
1539
|
+
identityFields: effect.identityFields,
|
|
1540
|
+
fields: effect.fields,
|
|
1541
|
+
});
|
|
1542
|
+
if (!validated.ok) {
|
|
1543
|
+
throw failExecution(`observation incomplete: ${validated.detail} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1544
|
+
}
|
|
1545
|
+
checkpoints[effect.scope] = validated.snapshot.checkpoint;
|
|
1546
|
+
execution.beforeSnapshots.push(snapshot);
|
|
1547
|
+
}
|
|
1548
|
+
execution.beforeCheckpoints = checkpoints;
|
|
1549
|
+
execution.state = 'before-snapshot-complete';
|
|
1550
|
+
const firstCheckpoint = Object.values(checkpoints).sort()[0] ?? null;
|
|
1551
|
+
const response = {
|
|
1552
|
+
caseId: compiled.caseId,
|
|
1553
|
+
executionId,
|
|
1554
|
+
namespace: execution.lease.namespace,
|
|
1555
|
+
beforeCheckpoint: firstCheckpoint,
|
|
1556
|
+
state: execution.state,
|
|
1557
|
+
};
|
|
1558
|
+
sendJson(res, 200, response);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Drives one surface action through the engine-owned browser (plan
|
|
1562
|
+
* 2026-09-19 Phase 6): resolves the surface descriptor from the
|
|
1563
|
+
* supervisor-bound bundle (never a worker object), resolves
|
|
1564
|
+
* subject/fields from the trusted lease, drives with the existing
|
|
1565
|
+
* engine browser drivers, and reads the rendered result back itself.
|
|
1566
|
+
* Error banners, navigation, and state checks are engine observations —
|
|
1567
|
+
* never inferred from test source.
|
|
1568
|
+
*/
|
|
1569
|
+
async function driveSurfacePrincipal(state, binding, execution, compiled, action, failExecution, session) {
|
|
1570
|
+
const descriptor = binding.surfaces.get(action.surface);
|
|
1571
|
+
if (descriptor === undefined) {
|
|
1572
|
+
throw failExecution(`surface '${action.surface}' has no approved descriptor in the bound bundle — strong cases resolve surfaces from the approved bundle, not worker objects (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1573
|
+
}
|
|
1574
|
+
if (action.files !== undefined && Object.keys(action.files).length > 0) {
|
|
1575
|
+
throw failExecution('surface file inputs need fixture file materialization, unsupported in this profile — declare the upload as an explicit engine-http multipart case or omit files (OBSERVATION_SCOPE_INCOMPLETE)');
|
|
1576
|
+
}
|
|
1577
|
+
const resolveField = (raw, what) => {
|
|
1578
|
+
if (raw.from === 'literal') {
|
|
1579
|
+
if (typeof raw.value !== 'string') {
|
|
1580
|
+
throw failExecution(`surface ${what} literal must be a string (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1581
|
+
}
|
|
1582
|
+
return raw.value;
|
|
1583
|
+
}
|
|
1584
|
+
if (raw.from === 'fixture' && typeof raw.key === 'string') {
|
|
1585
|
+
const segments = raw.key.split('.');
|
|
1586
|
+
let current = execution.lease.subjects;
|
|
1587
|
+
for (const segment of segments) {
|
|
1588
|
+
if (typeof current !== 'object' || current === null || Array.isArray(current)) {
|
|
1589
|
+
throw failExecution(`surface ${what} fixture key '${raw.key}' does not resolve (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1590
|
+
}
|
|
1591
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
1592
|
+
throw failExecution(`surface ${what} fixture key '${raw.key}' does not resolve (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1593
|
+
}
|
|
1594
|
+
current = current[segment];
|
|
1595
|
+
}
|
|
1596
|
+
if (typeof current !== 'string') {
|
|
1597
|
+
throw failExecution(`surface ${what} fixture key '${raw.key}' is not a string (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1598
|
+
}
|
|
1599
|
+
return current;
|
|
1600
|
+
}
|
|
1601
|
+
throw failExecution(`surface ${what} uses an unsupported value source (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1602
|
+
};
|
|
1603
|
+
let subject;
|
|
1604
|
+
if (action.subject !== undefined) {
|
|
1605
|
+
subject = resolveField(action.subject, 'subject');
|
|
1606
|
+
}
|
|
1607
|
+
const fields = {};
|
|
1608
|
+
for (const [name, raw] of Object.entries(action.fields)) {
|
|
1609
|
+
fields[name] = resolveField(raw, `field '${name}'`);
|
|
1610
|
+
}
|
|
1611
|
+
let appBase;
|
|
1612
|
+
try {
|
|
1613
|
+
appBase = requireTrustedUiBase(state);
|
|
1614
|
+
}
|
|
1615
|
+
catch (error) {
|
|
1616
|
+
throw failExecution(error instanceof Error ? error.message : String(error));
|
|
1617
|
+
}
|
|
1618
|
+
let page;
|
|
1619
|
+
try {
|
|
1620
|
+
page = await state.engineBrowser.pageFor(session.sessionId);
|
|
1621
|
+
}
|
|
1622
|
+
catch (error) {
|
|
1623
|
+
throw failExecution(`engine browser unavailable: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1624
|
+
}
|
|
1625
|
+
let observation;
|
|
1626
|
+
try {
|
|
1627
|
+
observation = await driveEngineAction(page, appBase, descriptor, action.operation, {
|
|
1628
|
+
...(Object.keys(fields).length > 0 ? { fields } : {}),
|
|
1629
|
+
...(subject !== undefined ? { entityId: subject } : {}),
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
catch (error) {
|
|
1633
|
+
throw failExecution(`engine surface ${action.operation} failed: ${error instanceof Error ? error.message : String(error)} (BEHAVIOR_EFFECT_MISMATCH)`);
|
|
1634
|
+
}
|
|
1635
|
+
let visible;
|
|
1636
|
+
try {
|
|
1637
|
+
visible = await readEngineVisible(page, appBase, descriptor, action.operation, observation.entityId);
|
|
1638
|
+
}
|
|
1639
|
+
catch (error) {
|
|
1640
|
+
throw failExecution(`engine visible read failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1641
|
+
}
|
|
1642
|
+
let url;
|
|
1643
|
+
try {
|
|
1644
|
+
url = page.url();
|
|
1645
|
+
}
|
|
1646
|
+
catch (error) {
|
|
1647
|
+
throw failExecution(`engine page url unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1648
|
+
}
|
|
1649
|
+
return {
|
|
1650
|
+
operationId: randomUUID(),
|
|
1651
|
+
browserObservation: { url, entityId: observation.entityId, visibleFields: { ...visible } },
|
|
1652
|
+
submittedValues: {
|
|
1653
|
+
surface: action.surface,
|
|
1654
|
+
operation: action.operation,
|
|
1655
|
+
...(subject !== undefined ? { subject } : {}),
|
|
1656
|
+
fields: { ...fields },
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Drives the principal operation of a prepared case (plan 2026-09-19
|
|
1662
|
+
* §4.6, Phase 5): resolves the execution, runs the witness-owned
|
|
1663
|
+
* engine-http request driver for `request` actions, attributes the
|
|
1664
|
+
* captured attempt against the bound route inventory, resolves the
|
|
1665
|
+
* completion barrier, observes after-state, and issues the sealed
|
|
1666
|
+
* `behavior.case` record(s) — one per compiled obligation. Grading
|
|
1667
|
+
* happens core-side, never here.
|
|
1668
|
+
*
|
|
1669
|
+
* `surface` actions need the Phase 6 browser driver; `deliver` and
|
|
1670
|
+
* `sequence` need Phase 8 — all block with an explicit cause.
|
|
1671
|
+
*/
|
|
1672
|
+
async function handleBehaviorPrincipal(state, res, body) {
|
|
1673
|
+
if (!isPlainObject(body)) {
|
|
1674
|
+
throw new HttpError(400, 'behavior/principal body must be {sessionId, sessionToken, executionId}');
|
|
1675
|
+
}
|
|
1676
|
+
const keys = Object.keys(body).sort();
|
|
1677
|
+
if (keys.length !== 3 || keys[0] !== 'executionId' || keys[1] !== 'sessionId' || keys[2] !== 'sessionToken') {
|
|
1678
|
+
throw new HttpError(400, 'behavior/principal accepts exactly {sessionId, sessionToken, executionId}');
|
|
1679
|
+
}
|
|
1680
|
+
const executionId = body['executionId'];
|
|
1681
|
+
if (typeof executionId !== 'string' || executionId.length === 0) {
|
|
1682
|
+
throw new HttpError(400, 'behavior/principal requires a non-empty string executionId');
|
|
1683
|
+
}
|
|
1684
|
+
const binding = state.behaviorCatalog;
|
|
1685
|
+
if (binding === null) {
|
|
1686
|
+
throw new HttpError(409, 'no behavior catalog is bound to this run');
|
|
1687
|
+
}
|
|
1688
|
+
const session = requireOpenSession(state, body);
|
|
1689
|
+
const execution = [...state.caseExecutions.values()].find((item) => item.executionId === executionId);
|
|
1690
|
+
if (execution === undefined) {
|
|
1691
|
+
throw new HttpError(400, `behavior/principal names unknown execution '${executionId}'`);
|
|
1692
|
+
}
|
|
1693
|
+
if (execution.sessionId !== session.sessionId) {
|
|
1694
|
+
throw new HttpError(403, 'behavior/principal session does not own this execution — executions belong to the session that prepared them');
|
|
1695
|
+
}
|
|
1696
|
+
if (execution.state === 'sealed') {
|
|
1697
|
+
throw new HttpError(409, 'behavior/principal execution is already sealed — one execution per required case per run');
|
|
1698
|
+
}
|
|
1699
|
+
if (execution.state === 'failed') {
|
|
1700
|
+
throw new HttpError(409, `behavior/principal execution failed: ${execution.detail ?? 'unknown failure'}`);
|
|
1701
|
+
}
|
|
1702
|
+
if (execution.state !== 'before-snapshot-complete') {
|
|
1703
|
+
throw new HttpError(409, `behavior/principal execution is in state '${execution.state}', not ready for the principal`);
|
|
1704
|
+
}
|
|
1705
|
+
const compiled = binding.catalog.cases.find((item) => item.caseId === execution.caseId);
|
|
1706
|
+
if (compiled === undefined) {
|
|
1707
|
+
throw new HttpError(409, 'behavior/principal case vanished from the bound catalog (fail closed)');
|
|
1708
|
+
}
|
|
1709
|
+
const failExecution = (detail) => {
|
|
1710
|
+
execution.state = 'failed';
|
|
1711
|
+
execution.detail = detail;
|
|
1712
|
+
const provider = state.options.fixtureProvider ?? null;
|
|
1713
|
+
if (provider !== null) {
|
|
1714
|
+
void Promise.resolve(provider.release(execution.lease.leaseId)).catch(() => { });
|
|
1715
|
+
}
|
|
1716
|
+
return new HttpError(409, detail);
|
|
1717
|
+
};
|
|
1718
|
+
const action = compiled.definition.action;
|
|
1719
|
+
const actorProfile = compiled.definition.actor;
|
|
1720
|
+
const provider = state.options.fixtureProvider ?? null;
|
|
1721
|
+
// The sealed principal evidence: exactly one of the two drivers fills
|
|
1722
|
+
// its half. Request cases seal attempts + request observations;
|
|
1723
|
+
// surface cases seal the browser observation.
|
|
1724
|
+
let attempts = [];
|
|
1725
|
+
let requestObservations = [];
|
|
1726
|
+
let browserObservation = undefined;
|
|
1727
|
+
let submittedValues;
|
|
1728
|
+
let operationId;
|
|
1729
|
+
execution.state = 'principal-executing';
|
|
1730
|
+
if (action.kind === 'request') {
|
|
1731
|
+
const requestAction = action;
|
|
1732
|
+
if (compiled.definition.channel !== 'engine-http') {
|
|
1733
|
+
throw failExecution(`case '${compiled.definition.id}' requires the '${compiled.definition.channel}' channel — the Phase 8 task driver produces that evidence`);
|
|
1734
|
+
}
|
|
1735
|
+
if (provider === null || typeof provider.resolveCredential !== 'function') {
|
|
1736
|
+
throw failExecution('no trusted credential resolver is configured — the principal cannot authenticate engine-side');
|
|
1737
|
+
}
|
|
1738
|
+
const origin = state.options.targetBaseUrl ?? null;
|
|
1739
|
+
if (origin === null || origin === '') {
|
|
1740
|
+
throw failExecution('no approved subject origin is configured for the principal driver');
|
|
1741
|
+
}
|
|
1742
|
+
let driven;
|
|
1743
|
+
try {
|
|
1744
|
+
driven = await driveBehaviorRequest({
|
|
1745
|
+
action: {
|
|
1746
|
+
kind: 'request',
|
|
1747
|
+
method: requestAction.method,
|
|
1748
|
+
pathTemplate: requestAction.pathTemplate,
|
|
1749
|
+
path: requestAction.path,
|
|
1750
|
+
query: requestAction.query,
|
|
1751
|
+
body: requestAction.body,
|
|
1752
|
+
credentialVariant: requestAction.credentialVariant,
|
|
1753
|
+
...(requestAction.signatureProfile !== undefined ? { signatureProfile: requestAction.signatureProfile } : {}),
|
|
1754
|
+
},
|
|
1755
|
+
lease: execution.lease,
|
|
1756
|
+
actorProfile,
|
|
1757
|
+
origin,
|
|
1758
|
+
resolveCredential: (credentialRef) => provider.resolveCredential(credentialRef),
|
|
1759
|
+
timeoutMs: state.options.requestTimeoutMs,
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
catch (error) {
|
|
1763
|
+
if (error instanceof BehaviorDriverError) {
|
|
1764
|
+
throw failExecution(`principal driver: ${error.message} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1765
|
+
}
|
|
1766
|
+
throw failExecution(`principal driver failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1767
|
+
}
|
|
1768
|
+
execution.state = 'principal-captured';
|
|
1769
|
+
// Attribute the captured attempt against the bound inventory with the
|
|
1770
|
+
// compiled endpoint as expected — the grader re-resolves
|
|
1771
|
+
// independently; a misdirected principal fails here with a diagnostic
|
|
1772
|
+
// instead of sealing evidence for the wrong endpoint.
|
|
1773
|
+
const interpreted = interpretObservedPath(driven.path);
|
|
1774
|
+
if (!interpreted.ok) {
|
|
1775
|
+
throw failExecution(`principal captured a noncanonical path: ${interpreted.reason} (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1776
|
+
}
|
|
1777
|
+
const expectedEndpoint = compiled.endpointResourceId ?? compiled.resourceId;
|
|
1778
|
+
const resolution = resolveHttpRoute(driven.method, interpreted.path, binding.routes, expectedEndpoint);
|
|
1779
|
+
if (resolution.status !== 'match') {
|
|
1780
|
+
const reason = resolution.status === 'ambiguous'
|
|
1781
|
+
? `ambiguous route attribution for ${driven.method} ${interpreted.path} (BEHAVIOR_BINDING_MISMATCH)`
|
|
1782
|
+
: resolution.status === 'mismatch'
|
|
1783
|
+
? `principal reached '${resolution.matched.resourceId}', not the required endpoint '${expectedEndpoint}' (BEHAVIOR_BINDING_MISMATCH)`
|
|
1784
|
+
: resolution.status === 'nomatch'
|
|
1785
|
+
? `principal ${driven.method} ${interpreted.path} matches no inventoried route (BEHAVIOR_BINDING_MISMATCH)`
|
|
1786
|
+
: resolution.reason;
|
|
1787
|
+
throw failExecution(reason);
|
|
1788
|
+
}
|
|
1789
|
+
operationId = driven.engineRequestId;
|
|
1790
|
+
attempts = [
|
|
1791
|
+
{
|
|
1792
|
+
engineRequestId: driven.engineRequestId,
|
|
1793
|
+
method: driven.method,
|
|
1794
|
+
path: driven.path,
|
|
1795
|
+
endpointResourceId: resolution.matched.resourceId,
|
|
1796
|
+
actorRef: actorProfile,
|
|
1797
|
+
requestDigest: driven.requestDigest,
|
|
1798
|
+
status: driven.status,
|
|
1799
|
+
responseDigest: driven.responseDigest,
|
|
1800
|
+
},
|
|
1801
|
+
];
|
|
1802
|
+
requestObservations = [
|
|
1803
|
+
{
|
|
1804
|
+
engineRequestId: driven.engineRequestId,
|
|
1805
|
+
method: driven.method,
|
|
1806
|
+
path: driven.path,
|
|
1807
|
+
query: { ...driven.query },
|
|
1808
|
+
body: driven.body,
|
|
1809
|
+
status: driven.status,
|
|
1810
|
+
responseBody: driven.responseBody,
|
|
1811
|
+
},
|
|
1812
|
+
];
|
|
1813
|
+
submittedValues = { path: driven.path, query: { ...driven.query }, body: driven.body };
|
|
1814
|
+
}
|
|
1815
|
+
else if (action.kind === 'surface') {
|
|
1816
|
+
const surfaceAction = action;
|
|
1817
|
+
const surfaceChannel = compiled.definition.channel;
|
|
1818
|
+
if (surfaceChannel !== 'engine-browser') {
|
|
1819
|
+
throw failExecution(`surface case '${compiled.definition.id}' requires the engine-browser channel — blocked, never satisfied`);
|
|
1820
|
+
}
|
|
1821
|
+
const driven = await driveSurfacePrincipal(state, binding, execution, compiled, surfaceAction, failExecution, session);
|
|
1822
|
+
operationId = driven.operationId;
|
|
1823
|
+
browserObservation = driven.browserObservation;
|
|
1824
|
+
submittedValues = driven.submittedValues;
|
|
1825
|
+
execution.state = 'principal-captured';
|
|
1826
|
+
}
|
|
1827
|
+
else {
|
|
1828
|
+
throw failExecution(`case '${compiled.definition.id}' uses a '${action.kind}' action — the Phase 8 task driver produces that evidence`);
|
|
1829
|
+
}
|
|
1830
|
+
// Completion barrier: immediate effects resolve at once; barrier
|
|
1831
|
+
// effects need a real checkpoint from an adapter observer.
|
|
1832
|
+
const barrierEffects = compiled.effects.filter((effect) => effect.completion === 'barrier');
|
|
1833
|
+
if (barrierEffects.length > 0) {
|
|
1834
|
+
for (const effect of barrierEffects) {
|
|
1835
|
+
const adapter = state.adapters.get(effect.adapter);
|
|
1836
|
+
if (adapter === undefined || typeof adapter.awaitBarrier !== 'function') {
|
|
1837
|
+
throw failExecution(`observation incomplete: no barrier observer for scope '${effect.scope}' (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1838
|
+
}
|
|
1839
|
+
const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl ?? '';
|
|
1840
|
+
const ctx = makeAdapterContext(baseUrl, effect.resourceId, () => {
|
|
1841
|
+
throw new Error('barrier observations must not use the candidate GET transport');
|
|
1842
|
+
}, state.options.adapterReadAuthorization
|
|
1843
|
+
? { authorization: state.options.adapterReadAuthorization }
|
|
1844
|
+
: undefined);
|
|
1845
|
+
let barrier;
|
|
1846
|
+
try {
|
|
1847
|
+
barrier = await adapter.awaitBarrier(ctx, {
|
|
1848
|
+
scope: effect.scope,
|
|
1849
|
+
fixtureNamespace: execution.lease.namespace,
|
|
1850
|
+
operationId,
|
|
1851
|
+
deadlineMs: state.options.barrierTimeoutMs ?? 30_000,
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
catch (error) {
|
|
1855
|
+
throw failExecution(`observation incomplete: barrier for scope '${effect.scope}' failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1856
|
+
}
|
|
1857
|
+
if (barrier === null || typeof barrier !== 'object' || barrier.complete !== true) {
|
|
1858
|
+
throw failExecution(`async effect did not complete before the barrier deadline — premature success is blocked (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
execution.state = 'barrier-reached';
|
|
1863
|
+
// Authoritative after snapshots over every declared effect scope.
|
|
1864
|
+
const afterSnapshots = [];
|
|
1865
|
+
const afterCheckpoints = {};
|
|
1866
|
+
for (const effect of compiled.effects) {
|
|
1867
|
+
const adapter = state.adapters.get(effect.adapter);
|
|
1868
|
+
if (adapter === undefined || typeof adapter.snapshotScope !== 'function') {
|
|
1869
|
+
throw failExecution(`observation incomplete: scope '${effect.scope}' lost its observer (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1870
|
+
}
|
|
1871
|
+
const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl ?? '';
|
|
1872
|
+
const ctx = makeAdapterContext(baseUrl, effect.resourceId, () => {
|
|
1873
|
+
throw new Error('snapshot observations must not use the candidate GET transport');
|
|
1874
|
+
}, state.options.adapterReadAuthorization
|
|
1875
|
+
? { authorization: state.options.adapterReadAuthorization }
|
|
1876
|
+
: undefined);
|
|
1877
|
+
let snapshot;
|
|
1878
|
+
try {
|
|
1879
|
+
snapshot = await adapter.snapshotScope(ctx, { scope: effect.scope, fixtureNamespace: execution.lease.namespace });
|
|
1880
|
+
}
|
|
1881
|
+
catch (error) {
|
|
1882
|
+
throw failExecution(`observation incomplete: after-scope '${effect.scope}' collection failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1883
|
+
}
|
|
1884
|
+
const validated = validateScopeSnapshot(snapshot, {
|
|
1885
|
+
scope: effect.scope,
|
|
1886
|
+
fixtureNamespace: execution.lease.namespace,
|
|
1887
|
+
identityFields: effect.identityFields,
|
|
1888
|
+
fields: effect.fields,
|
|
1889
|
+
});
|
|
1890
|
+
if (!validated.ok) {
|
|
1891
|
+
throw failExecution(`observation incomplete: ${validated.detail} (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1892
|
+
}
|
|
1893
|
+
afterCheckpoints[effect.scope] = validated.snapshot.checkpoint;
|
|
1894
|
+
afterSnapshots.push(snapshot);
|
|
1895
|
+
}
|
|
1896
|
+
execution.state = 'after-snapshot-complete';
|
|
1897
|
+
// Seal: strip snapshots to the core strict shape (no witness-local
|
|
1898
|
+
// validation extras), then issue one record per compiled obligation.
|
|
1899
|
+
const stripSnapshot = (snapshot) => ({
|
|
1900
|
+
scope: snapshot.scope,
|
|
1901
|
+
fixtureNamespace: snapshot.fixtureNamespace,
|
|
1902
|
+
complete: snapshot.complete,
|
|
1903
|
+
checkpoint: snapshot.checkpoint,
|
|
1904
|
+
entities: snapshot.entities.map((entity) => ({ entityId: entity.entityId, fields: { ...entity.fields } })),
|
|
1905
|
+
});
|
|
1906
|
+
const actorLease = execution.lease.actors[actorProfile];
|
|
1907
|
+
const payload = {
|
|
1908
|
+
payloadVersion: 1,
|
|
1909
|
+
caseId: compiled.caseId,
|
|
1910
|
+
caseSpecDigest: compiled.specDigest,
|
|
1911
|
+
obligationIds: [...compiled.obligationIds],
|
|
1912
|
+
endpointResourceId: compiled.endpointResourceId,
|
|
1913
|
+
operationId,
|
|
1914
|
+
sessionId: session.sessionId,
|
|
1915
|
+
executionId: execution.executionId,
|
|
1916
|
+
fixtureNamespace: execution.lease.namespace,
|
|
1917
|
+
actor: {
|
|
1918
|
+
principalId: typeof actorLease?.principalId === 'string' ? actorLease.principalId : actorProfile,
|
|
1919
|
+
tenantId: typeof actorLease?.tenantId === 'string' || actorLease?.tenantId === null ? (actorLease?.tenantId ?? null) : null,
|
|
1920
|
+
roles: Array.isArray(actorLease?.roles) ? [...actorLease?.roles] : [],
|
|
1921
|
+
},
|
|
1922
|
+
actionDigest: behaviorActionDigestOf(compiled.definition.action),
|
|
1923
|
+
submittedValues: submittedValues,
|
|
1924
|
+
attempts,
|
|
1925
|
+
requestObservations,
|
|
1926
|
+
...(browserObservation === undefined ? {} : { browserObservation }),
|
|
1927
|
+
fixtureValues: { ...execution.lease.subjects },
|
|
1928
|
+
before: execution.beforeSnapshots.map(stripSnapshot),
|
|
1929
|
+
after: afterSnapshots.map(stripSnapshot),
|
|
1930
|
+
completion: {
|
|
1931
|
+
complete: true,
|
|
1932
|
+
checkpoint: Object.values(afterCheckpoints).sort().join('+') || Object.values(execution.beforeCheckpoints).sort().join('+'),
|
|
1933
|
+
},
|
|
1934
|
+
channel: compiled.definition.channel,
|
|
1935
|
+
authorityProfileDigest: binding.authorityProfileDigest,
|
|
1936
|
+
state: 'sealed',
|
|
1937
|
+
};
|
|
1938
|
+
const recordIds = [];
|
|
1939
|
+
for (const obligationId of compiled.obligationIds) {
|
|
1940
|
+
const issued = issueRecord(state, obligationId, BEHAVIOR_CASE_KIND, session.testId, payload, 'engine-observed');
|
|
1941
|
+
recordIds.push(issued.recordId);
|
|
1942
|
+
}
|
|
1943
|
+
recordIds.sort();
|
|
1944
|
+
execution.state = 'sealed';
|
|
1945
|
+
const response = {
|
|
1946
|
+
caseId: compiled.caseId,
|
|
1947
|
+
executionId: execution.executionId,
|
|
1948
|
+
recordIds,
|
|
1949
|
+
state: execution.state,
|
|
1950
|
+
};
|
|
1951
|
+
sendJson(res, 200, response);
|
|
1952
|
+
}
|
|
1234
1953
|
/**
|
|
1235
1954
|
* Builds the witness-side execution trace (enforcement-review fix 2b):
|
|
1236
1955
|
* per registered expected test, every recorded session with its open/
|
|
@@ -1405,6 +2124,15 @@ async function handleSessionOpen(state, res, body) {
|
|
|
1405
2124
|
await startSessionProxy(state, session);
|
|
1406
2125
|
state.sessions.set(sessionId, session);
|
|
1407
2126
|
state.workerSessions.set(workerIndex, sessionId);
|
|
2127
|
+
// Observe before-snapshots (Phase 2): the witness lists the
|
|
2128
|
+
// observe-declared resources ITSELF at open. Total by construction —
|
|
2129
|
+
// a snapshot failure is finalize data, never an open failure.
|
|
2130
|
+
try {
|
|
2131
|
+
await takeObserveSnapshots(state, session);
|
|
2132
|
+
}
|
|
2133
|
+
catch {
|
|
2134
|
+
state.observeSnapshots.delete(sessionId);
|
|
2135
|
+
}
|
|
1408
2136
|
sendJson(res, 200, sessionView(state, session));
|
|
1409
2137
|
}
|
|
1410
2138
|
/** The session view returned to supervisor and worker (the credential). */
|
|
@@ -1450,6 +2178,11 @@ async function handleSessionClose(state, res, body) {
|
|
|
1450
2178
|
session.sealedTick = (state.tick += 1);
|
|
1451
2179
|
session.outcome = typeof outcome === 'string' ? outcome : null;
|
|
1452
2180
|
state.workerSessions.delete(session.workerIndex);
|
|
2181
|
+
// Observe snapshots die with the session: finalize runs BEFORE seal
|
|
2182
|
+
// (the drain finalizes a passed test, then seals), so anything left
|
|
2183
|
+
// here belongs to a test that never finalized — unsealed evidence
|
|
2184
|
+
// must not linger for a later call to consume.
|
|
2185
|
+
state.observeSnapshots.delete(sessionId);
|
|
1453
2186
|
// The dedicated channel dies with the session: nothing can observe
|
|
1454
2187
|
// (or submit) through it afterwards. The engine browser context dies
|
|
1455
2188
|
// too — a sealed session's pages are never driven again.
|
|
@@ -1967,6 +2700,492 @@ async function handleServerE2eDeclarations(state, res, verifier, body) {
|
|
|
1967
2700
|
obligations: [...obligations].sort(compareStrings),
|
|
1968
2701
|
});
|
|
1969
2702
|
}
|
|
2703
|
+
/**
|
|
2704
|
+
* `POST /runs/observe-declarations` — SUPERVISOR ONLY: registers the
|
|
2705
|
+
* obligation ids the trusted mapping layer declared kind `observed-e2e`
|
|
2706
|
+
* (Observe channel, Phase 2) BEFORE the run. Same binding contract as
|
|
2707
|
+
* the server-e2e set: bound once, identical re-registration idempotent,
|
|
2708
|
+
* any change or late registration refused — the witness stamps
|
|
2709
|
+
* `channel: 'observe'` records for these obligations only.
|
|
2710
|
+
*/
|
|
2711
|
+
async function handleObserveDeclarations(state, res, verifier, body) {
|
|
2712
|
+
requireSupervisor(state, verifier);
|
|
2713
|
+
if (!isPlainObject(body) || !Array.isArray(body['obligations'])) {
|
|
2714
|
+
throw new HttpError(400, 'observe declarations body must be {obligations: [...]}');
|
|
2715
|
+
}
|
|
2716
|
+
const obligations = new Set();
|
|
2717
|
+
for (const entry of body['obligations']) {
|
|
2718
|
+
if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
|
|
2719
|
+
throw new HttpError(400, `observe declarations must be obligation ids '<resourceId>:<contract>' (got '${String(entry)}')`);
|
|
2720
|
+
}
|
|
2721
|
+
obligations.add(entry);
|
|
2722
|
+
}
|
|
2723
|
+
if (state.observeDeclarations !== null) {
|
|
2724
|
+
const identical = state.observeDeclarations.size === obligations.size &&
|
|
2725
|
+
[...obligations].every((id) => state.observeDeclarations?.has(id));
|
|
2726
|
+
if (identical) {
|
|
2727
|
+
sendJson(res, 200, {
|
|
2728
|
+
bound: true,
|
|
2729
|
+
count: state.observeDeclarations.size,
|
|
2730
|
+
obligations: [...state.observeDeclarations].sort(compareStrings),
|
|
2731
|
+
});
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
sendJson(res, 409, {
|
|
2735
|
+
error: 'observe declarations are already bound to this run and differ; the declaration set ' +
|
|
2736
|
+
'is a PRE-run fact and is never relabeled — start a fresh witness for a new invocation',
|
|
2737
|
+
});
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
if (state.ledger.size > 0 || state.sessions.size > 0) {
|
|
2741
|
+
sendJson(res, 409, {
|
|
2742
|
+
error: 'witness already issued evidence or holds open sessions; observe declarations must be ' +
|
|
2743
|
+
'registered BEFORE the run — start a fresh witness for a new invocation',
|
|
2744
|
+
});
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
state.observeDeclarations = obligations;
|
|
2748
|
+
sendJson(res, 200, {
|
|
2749
|
+
bound: true,
|
|
2750
|
+
count: obligations.size,
|
|
2751
|
+
obligations: [...obligations].sort(compareStrings),
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
/** Splits `<resourceId>:<contract>` at the first colon (obligation id grammar). */
|
|
2755
|
+
function resourceIdOfObligation(obligationId) {
|
|
2756
|
+
const colon = obligationId.indexOf(':');
|
|
2757
|
+
return colon === -1 ? obligationId : obligationId.slice(0, colon);
|
|
2758
|
+
}
|
|
2759
|
+
/** The CRUD operation a `persistence:<op>` contract requires; null otherwise. */
|
|
2760
|
+
function observeOperation(contract) {
|
|
2761
|
+
if (!contract.startsWith('persistence:'))
|
|
2762
|
+
return null;
|
|
2763
|
+
const operation = contract.slice('persistence:'.length);
|
|
2764
|
+
if (operation === 'create' || operation === 'read' || operation === 'update' || operation === 'delete') {
|
|
2765
|
+
return operation;
|
|
2766
|
+
}
|
|
2767
|
+
return null;
|
|
2768
|
+
}
|
|
2769
|
+
/**
|
|
2770
|
+
* Takes Observe before-snapshots for one freshly opened session: for
|
|
2771
|
+
* every resource its observe-declared claims name, the witness runs the
|
|
2772
|
+
* resource's adapter `list()` ITSELF and normalizes each body. The
|
|
2773
|
+
* snapshot is the before-state every observe postcondition grades
|
|
2774
|
+
* against — expectations never come from the suite. Per-resource
|
|
2775
|
+
* trouble (no adapter, no list, probe/read/normalize failure) is
|
|
2776
|
+
* stored as an error snapshot: the session still opens and the test
|
|
2777
|
+
* still runs; finalize reports the resource as unobservable instead of
|
|
2778
|
+
* satisfying anything. Total: never throws out of session open.
|
|
2779
|
+
*/
|
|
2780
|
+
async function takeObserveSnapshots(state, session) {
|
|
2781
|
+
if (state.observeDeclarations === null || state.observeDeclarations.size === 0)
|
|
2782
|
+
return;
|
|
2783
|
+
const resources = new Set();
|
|
2784
|
+
for (const claim of session.claims) {
|
|
2785
|
+
if (state.observeDeclarations.has(claim))
|
|
2786
|
+
resources.add(resourceIdOfObligation(claim));
|
|
2787
|
+
}
|
|
2788
|
+
if (resources.size === 0)
|
|
2789
|
+
return;
|
|
2790
|
+
const perSession = new Map();
|
|
2791
|
+
state.observeSnapshots.set(session.sessionId, perSession);
|
|
2792
|
+
for (const resourceId of [...resources].sort(compareStrings)) {
|
|
2793
|
+
perSession.set(resourceId, await snapshotObserveResource(state, resourceId));
|
|
2794
|
+
}
|
|
2795
|
+
session.activity += 1;
|
|
2796
|
+
}
|
|
2797
|
+
/** Snapshots one resource's adapter-listed entities (never throws). */
|
|
2798
|
+
async function snapshotObserveResource(state, resourceId) {
|
|
2799
|
+
const failure = (adapterName, error) => ({
|
|
2800
|
+
resourceId,
|
|
2801
|
+
adapterName,
|
|
2802
|
+
before: new Map(),
|
|
2803
|
+
error,
|
|
2804
|
+
});
|
|
2805
|
+
let adapterName = resourceId;
|
|
2806
|
+
try {
|
|
2807
|
+
const context = await adapterReadContext(state, resourceId);
|
|
2808
|
+
adapterName = context.adapterName;
|
|
2809
|
+
const adapter = context.adapter;
|
|
2810
|
+
if (typeof adapter.list !== 'function') {
|
|
2811
|
+
return failure(adapterName, `adapter '${adapterName}' exports no list() — Observe needs a before-snapshot, so ` +
|
|
2812
|
+
`resource '${resourceId}' is unobservable until the adapter lists its entities`);
|
|
2813
|
+
}
|
|
2814
|
+
const ctx = observeAdapterContext(state, context.baseUrl, resourceId);
|
|
2815
|
+
return { resourceId, adapterName, before: await observeListEntities(adapterName, adapter, ctx), error: null };
|
|
2816
|
+
}
|
|
2817
|
+
catch (error) {
|
|
2818
|
+
const detail = error instanceof HttpError ? error.message : error.message;
|
|
2819
|
+
return failure(adapterName, detail);
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
/** Builds the GET-only adapter transport for observe snapshots/reads. */
|
|
2823
|
+
function observeAdapterContext(state, baseUrl, resourceId) {
|
|
2824
|
+
const headers = state.options.adapterReadAuthorization
|
|
2825
|
+
? { authorization: state.options.adapterReadAuthorization }
|
|
2826
|
+
: undefined;
|
|
2827
|
+
return makeAdapterContext(baseUrl, resourceId, (path) => adapterGet(baseUrl, state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), headers);
|
|
2828
|
+
}
|
|
2829
|
+
/**
|
|
2830
|
+
* Lists + normalizes a resource's entities through its adapter (the
|
|
2831
|
+
* witness's own observation). Throws HttpError (409) on list/normalize
|
|
2832
|
+
* trouble — callers turn it into a typed observe note, never
|
|
2833
|
+
* satisfaction.
|
|
2834
|
+
*/
|
|
2835
|
+
async function observeListEntities(adapterName, adapter, ctx) {
|
|
2836
|
+
if (typeof adapter.list !== 'function') {
|
|
2837
|
+
throw new HttpError(409, `adapter '${adapterName}' exports no list() — Observe needs entity snapshots`);
|
|
2838
|
+
}
|
|
2839
|
+
let listed;
|
|
2840
|
+
try {
|
|
2841
|
+
listed = await adapter.list(ctx);
|
|
2842
|
+
}
|
|
2843
|
+
catch (error) {
|
|
2844
|
+
throw new HttpError(409, `adapter '${adapterName}' list failed: ${error.message}`);
|
|
2845
|
+
}
|
|
2846
|
+
if (!Array.isArray(listed)) {
|
|
2847
|
+
throw new HttpError(409, `adapter '${adapterName}' list must return an array of entities`);
|
|
2848
|
+
}
|
|
2849
|
+
const out = new Map();
|
|
2850
|
+
for (const raw of listed) {
|
|
2851
|
+
let normalized;
|
|
2852
|
+
try {
|
|
2853
|
+
const candidate = adapter.normalize(raw);
|
|
2854
|
+
if (!isPlainObject(candidate) || !('entityId' in candidate) || !('fields' in candidate)) {
|
|
2855
|
+
throw new Error('normalize must return {entityId, fields}');
|
|
2856
|
+
}
|
|
2857
|
+
normalized = { entityId: candidate['entityId'], fields: candidate['fields'] };
|
|
2858
|
+
}
|
|
2859
|
+
catch (error) {
|
|
2860
|
+
throw new HttpError(409, `adapter '${adapterName}' normalize failed: ${error.message}`);
|
|
2861
|
+
}
|
|
2862
|
+
let key;
|
|
2863
|
+
try {
|
|
2864
|
+
key = canonicalOf(normalized.entityId);
|
|
2865
|
+
}
|
|
2866
|
+
catch {
|
|
2867
|
+
throw new HttpError(409, `adapter '${adapterName}' normalized an entity id with no canonical form`);
|
|
2868
|
+
}
|
|
2869
|
+
out.set(key, normalized);
|
|
2870
|
+
}
|
|
2871
|
+
return out;
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Matches a recorded observed path against an adapter observe path
|
|
2875
|
+
* template. `{id}` binds exactly one non-empty segment; every other
|
|
2876
|
+
* segment must be literally equal (case-sensitive). Matching reuses
|
|
2877
|
+
* core's canonical shape semantics (`{id}` → `{}`).
|
|
2878
|
+
*
|
|
2879
|
+
* Returns `{id}` (null for id-less create templates) on match, null
|
|
2880
|
+
* otherwise.
|
|
2881
|
+
*/
|
|
2882
|
+
function matchObserveTemplate(observedPath, template) {
|
|
2883
|
+
const segments = template.split('/').filter((segment) => segment.length > 0);
|
|
2884
|
+
const idIndex = segments.indexOf('{id}');
|
|
2885
|
+
const canonical = segments.map((segment) => (segment === '{id}' ? '{}' : segment)).join('/');
|
|
2886
|
+
if (!pathMatchesShape(observedPath, canonical.startsWith('/') ? canonical : `/${canonical}`)) {
|
|
2887
|
+
return null;
|
|
2888
|
+
}
|
|
2889
|
+
if (idIndex === -1)
|
|
2890
|
+
return { id: null };
|
|
2891
|
+
const observedSegments = observedPath.split('/').filter((segment) => segment.length > 0);
|
|
2892
|
+
const id = observedSegments[idIndex];
|
|
2893
|
+
if (id === undefined || id.length === 0)
|
|
2894
|
+
return null;
|
|
2895
|
+
return { id };
|
|
2896
|
+
}
|
|
2897
|
+
/** Finds a snapshot key for a path id segment (canonical or numeric-string form). */
|
|
2898
|
+
function beforeKeyForSegment(before, segment) {
|
|
2899
|
+
try {
|
|
2900
|
+
const canonical = canonicalOf(segment);
|
|
2901
|
+
if (before.has(canonical))
|
|
2902
|
+
return canonical;
|
|
2903
|
+
}
|
|
2904
|
+
catch {
|
|
2905
|
+
return null;
|
|
2906
|
+
}
|
|
2907
|
+
for (const [key, entry] of before) {
|
|
2908
|
+
if (typeof entry.entityId === 'number' && String(entry.entityId) === segment)
|
|
2909
|
+
return key;
|
|
2910
|
+
}
|
|
2911
|
+
return null;
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* Parses a proxied request body into echoable fields (Observe channel):
|
|
2915
|
+
* JSON objects and form bodies project their top-level scalar
|
|
2916
|
+
* (string/number/boolean) fields — the witness-observed statement of
|
|
2917
|
+
* what the test sent, graded by echo against the adapter read. Nested
|
|
2918
|
+
* envelopes are not entity fields and are skipped (documented); empty,
|
|
2919
|
+
* truncated, oversized, unparsable, or otherwise-typed bodies are
|
|
2920
|
+
* INELIGIBLE (typed error), never echoed from a prefix or a guess.
|
|
2921
|
+
*/
|
|
2922
|
+
function parseObserveBody(exchange) {
|
|
2923
|
+
if (exchange.requestBody === null || exchange.requestBytes === 0) {
|
|
2924
|
+
return { error: 'the proxied exchange carried no request body — there is nothing to echo' };
|
|
2925
|
+
}
|
|
2926
|
+
if (exchange.requestTruncated) {
|
|
2927
|
+
return {
|
|
2928
|
+
error: `the request body exceeds the ${String(OBSERVED_REQUEST_BODY_BYTES)}-byte witness snapshot ` +
|
|
2929
|
+
'cap — oversized intents are never echoed from a prefix',
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
const contentType = exchange.requestContentType;
|
|
2933
|
+
if (contentType === 'application/json') {
|
|
2934
|
+
let parsed;
|
|
2935
|
+
try {
|
|
2936
|
+
parsed = JSON.parse(exchange.requestBody.toString('utf8'));
|
|
2937
|
+
}
|
|
2938
|
+
catch {
|
|
2939
|
+
return { error: 'the request body is not parseable JSON' };
|
|
2940
|
+
}
|
|
2941
|
+
if (!isPlainObject(parsed)) {
|
|
2942
|
+
return { error: 'the JSON request body is not an object' };
|
|
2943
|
+
}
|
|
2944
|
+
const fields = {};
|
|
2945
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
2946
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
2947
|
+
fields[key] = value;
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
if (Object.keys(fields).length === 0) {
|
|
2951
|
+
return { error: 'the JSON request body carries no echoable scalar fields' };
|
|
2952
|
+
}
|
|
2953
|
+
return { fields };
|
|
2954
|
+
}
|
|
2955
|
+
if (contentType === 'application/x-www-form-urlencoded') {
|
|
2956
|
+
const fields = {};
|
|
2957
|
+
for (const [key, value] of new URLSearchParams(exchange.requestBody.toString('utf8'))) {
|
|
2958
|
+
fields[key] = value;
|
|
2959
|
+
}
|
|
2960
|
+
if (Object.keys(fields).length === 0) {
|
|
2961
|
+
return { error: 'the form request body carries no fields' };
|
|
2962
|
+
}
|
|
2963
|
+
return { fields };
|
|
2964
|
+
}
|
|
2965
|
+
return {
|
|
2966
|
+
error: `unsupported request content-type '${contentType ?? '<none>'}' — observe echoes JSON and ` +
|
|
2967
|
+
'form bodies only',
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Reads one entity through its adapter at finalize time (the
|
|
2972
|
+
* witness's own after-observation). Throws HttpError (409) on
|
|
2973
|
+
* adapter/normalize trouble — callers note it, never satisfy on it.
|
|
2974
|
+
*/
|
|
2975
|
+
async function readObserveEntity(state, resourceId, id) {
|
|
2976
|
+
const { adapterName, adapter, baseUrl } = await adapterReadContext(state, resourceId);
|
|
2977
|
+
const ctx = observeAdapterContext(state, baseUrl, resourceId);
|
|
2978
|
+
let bodyRaw;
|
|
2979
|
+
try {
|
|
2980
|
+
bodyRaw = await adapter.read(ctx, id);
|
|
2981
|
+
}
|
|
2982
|
+
catch (error) {
|
|
2983
|
+
throw new HttpError(409, `adapter '${adapterName}' read failed: ${error.message}`);
|
|
2984
|
+
}
|
|
2985
|
+
const found = bodyRaw !== null && bodyRaw !== undefined;
|
|
2986
|
+
if (!found)
|
|
2987
|
+
return { adapterName, found: false, fields: null, entityId: id };
|
|
2988
|
+
try {
|
|
2989
|
+
const candidate = adapter.normalize(bodyRaw);
|
|
2990
|
+
if (!isPlainObject(candidate) || !('entityId' in candidate) || !('fields' in candidate)) {
|
|
2991
|
+
throw new Error('normalize must return {entityId, fields}');
|
|
2992
|
+
}
|
|
2993
|
+
return { adapterName, found: true, fields: candidate['fields'], entityId: candidate['entityId'] };
|
|
2994
|
+
}
|
|
2995
|
+
catch (error) {
|
|
2996
|
+
throw new HttpError(409, `adapter '${adapterName}' normalize failed: ${error.message}`);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* `POST /observe/finalize` — SUPERVISOR ONLY: resolves one OPEN
|
|
3001
|
+
* session's observe-declared claims against the session's own proxied
|
|
3002
|
+
* traffic plus independent adapter reads, stamping witnessed
|
|
3003
|
+
* `persistence.observed` records for whatever resolves. The session
|
|
3004
|
+
* must be OPEN (the drain finalizes after a passed test, before seal);
|
|
3005
|
+
* sealed/unknown sessions are refused, so records are never injected
|
|
3006
|
+
* after the test ended. Every non-resolution is a typed NOTE in the
|
|
3007
|
+
* response — never satisfaction, never a run failure (the obligation
|
|
3008
|
+
* stays blocking through verdicts, which is the honest outcome).
|
|
3009
|
+
*
|
|
3010
|
+
* Per obligation (`<resourceId>:persistence:<op>`):
|
|
3011
|
+
* - adapter binding + before-snapshot must exist (else typed note);
|
|
3012
|
+
* - exactly one 2xx session exchange must match the binding (zero →
|
|
3013
|
+
* missing-traffic note; several → ambiguity note);
|
|
3014
|
+
* - create resolves its id from the list-diff (exactly one new entity);
|
|
3015
|
+
* read/update/delete bind `{id}` from the path against the snapshot;
|
|
3016
|
+
* - create/update echo the parsed request-body scalars against the
|
|
3017
|
+
* adapter read (the record carries both; the ENGINE grades the echo);
|
|
3018
|
+
* - the matched exchange is consumed single-use.
|
|
3019
|
+
*/
|
|
3020
|
+
async function handleObserveFinalize(state, res, verifier, body) {
|
|
3021
|
+
requireSupervisor(state, verifier);
|
|
3022
|
+
if (!isPlainObject(body) || typeof body['sessionId'] !== 'string' || body['sessionId'].length === 0) {
|
|
3023
|
+
throw new HttpError(400, 'observe finalize body must be {sessionId}');
|
|
3024
|
+
}
|
|
3025
|
+
const sessionId = body['sessionId'];
|
|
3026
|
+
const session = state.sessions.get(sessionId);
|
|
3027
|
+
if (session === undefined) {
|
|
3028
|
+
throw new HttpError(400, `session '${sessionId}' is unknown (never opened on this witness)`);
|
|
3029
|
+
}
|
|
3030
|
+
if (session.status !== 'open') {
|
|
3031
|
+
throw new HttpError(409, `session '${sessionId}' is sealed — observe finalizes before seal, never after (records cannot be injected after the test ended)`);
|
|
3032
|
+
}
|
|
3033
|
+
if (state.observeDeclarations === null) {
|
|
3034
|
+
throw new HttpError(409, 'observe declarations are not bound on this witness — register them before the run');
|
|
3035
|
+
}
|
|
3036
|
+
const finalized = [];
|
|
3037
|
+
const notes = [];
|
|
3038
|
+
const claims = session.claims.filter((claim) => state.observeDeclarations?.has(claim));
|
|
3039
|
+
for (const claimId of claims) {
|
|
3040
|
+
const outcome = await finalizeObserveClaim(state, session, claimId);
|
|
3041
|
+
if ('record' in outcome)
|
|
3042
|
+
finalized.push(outcome.record);
|
|
3043
|
+
else
|
|
3044
|
+
notes.push(outcome.note);
|
|
3045
|
+
}
|
|
3046
|
+
const response = { finalized, notes };
|
|
3047
|
+
sendJson(res, 200, response);
|
|
3048
|
+
}
|
|
3049
|
+
/** Resolves one observe-declared claim (record or typed note, never throws). */
|
|
3050
|
+
async function finalizeObserveClaim(state, session, claimId) {
|
|
3051
|
+
const note = (detail) => ({ note: `observe '${claimId}': ${detail}` });
|
|
3052
|
+
const resourceId = resourceIdOfObligation(claimId);
|
|
3053
|
+
const operation = observeOperation(claimId.slice(resourceId.length + 1));
|
|
3054
|
+
if (operation === null) {
|
|
3055
|
+
return note('the Observe channel proves persistence:* contracts only — this claim stays blocking');
|
|
3056
|
+
}
|
|
3057
|
+
let adapterName;
|
|
3058
|
+
let adapter;
|
|
3059
|
+
let adapterBaseUrl;
|
|
3060
|
+
try {
|
|
3061
|
+
const context = await adapterReadContext(state, resourceId);
|
|
3062
|
+
adapterName = context.adapterName;
|
|
3063
|
+
adapter = context.adapter;
|
|
3064
|
+
adapterBaseUrl = context.baseUrl;
|
|
3065
|
+
}
|
|
3066
|
+
catch (error) {
|
|
3067
|
+
return note(error instanceof HttpError ? error.message : error.message);
|
|
3068
|
+
}
|
|
3069
|
+
const binding = adapter.observe?.[operation];
|
|
3070
|
+
if (binding === undefined) {
|
|
3071
|
+
return note(`adapter '${adapterName}' declares no observe binding for '${operation}' — declare it in ` +
|
|
3072
|
+
`'.gateforge/adapters/${adapterName}.mjs' to make this obligation observable`);
|
|
3073
|
+
}
|
|
3074
|
+
const snapshot = state.observeSnapshots.get(session.sessionId)?.get(resourceId);
|
|
3075
|
+
if (snapshot === undefined || snapshot.error !== null) {
|
|
3076
|
+
return note(snapshot?.error !== null && snapshot?.error !== undefined
|
|
3077
|
+
? `no usable before-snapshot: ${snapshot.error}`
|
|
3078
|
+
: 'no before-snapshot for this session — the session opened before observe declarations bound, or the snapshot failed');
|
|
3079
|
+
}
|
|
3080
|
+
// Bind watermark (plan §11.4, same as http-observation): exchanges
|
|
3081
|
+
// that completed before the trusted context bound predate it.
|
|
3082
|
+
const watermark = state.runContext === null ? 0 : state.observedSeqAtBind;
|
|
3083
|
+
const matches = [];
|
|
3084
|
+
for (const exchange of state.observed) {
|
|
3085
|
+
if (exchange.sessionId !== session.sessionId || exchange.seq <= watermark)
|
|
3086
|
+
continue;
|
|
3087
|
+
if (exchange.method !== binding.method)
|
|
3088
|
+
continue;
|
|
3089
|
+
if (exchange.status < 200 || exchange.status > 299)
|
|
3090
|
+
continue;
|
|
3091
|
+
const matched = matchObserveTemplate(exchange.path, binding.path);
|
|
3092
|
+
if (matched === null)
|
|
3093
|
+
continue;
|
|
3094
|
+
matches.push({ exchange, id: matched.id });
|
|
3095
|
+
}
|
|
3096
|
+
if (matches.length === 0) {
|
|
3097
|
+
return note(`no ${binding.method} ${binding.path} exchange (2xx) for this session through the observation ` +
|
|
3098
|
+
'proxy — drive traffic through the session proxy prefix before claiming the obligation');
|
|
3099
|
+
}
|
|
3100
|
+
if (matches.length > 1) {
|
|
3101
|
+
return note(`${String(matches.length)} matching ${binding.method} ${binding.path} exchanges — ambiguous, ` +
|
|
3102
|
+
'refusing to pick one (seed through untracked channels so the mutation stands alone)');
|
|
3103
|
+
}
|
|
3104
|
+
const matched = matches[0];
|
|
3105
|
+
// Resolve the entity id: create diffs the witness-held lists (the new
|
|
3106
|
+
// id is observed, never declared); read/update/delete bind `{id}`
|
|
3107
|
+
// against the session-open snapshot.
|
|
3108
|
+
let entityIdForRead;
|
|
3109
|
+
let before;
|
|
3110
|
+
if (operation === 'create') {
|
|
3111
|
+
let after;
|
|
3112
|
+
try {
|
|
3113
|
+
const ctx = observeAdapterContext(state, adapterBaseUrl, resourceId);
|
|
3114
|
+
after = await observeListEntities(adapterName, adapter, ctx);
|
|
3115
|
+
}
|
|
3116
|
+
catch (error) {
|
|
3117
|
+
return note(error instanceof HttpError ? error.message : error.message);
|
|
3118
|
+
}
|
|
3119
|
+
const fresh = [...after.keys()].filter((key) => !snapshot.before.has(key));
|
|
3120
|
+
if (fresh.length !== 1) {
|
|
3121
|
+
return note(`expected exactly one new entity after the observed create, found ${String(fresh.length)} — ` +
|
|
3122
|
+
'the creation is ambiguous, so no record is issued');
|
|
3123
|
+
}
|
|
3124
|
+
const created = after.get(fresh[0]);
|
|
3125
|
+
entityIdForRead = created.entityId;
|
|
3126
|
+
before = { entityAbsent: true };
|
|
3127
|
+
}
|
|
3128
|
+
else {
|
|
3129
|
+
if (matched.id === null) {
|
|
3130
|
+
return note('the observe binding carries no {id} segment for a non-create operation');
|
|
3131
|
+
}
|
|
3132
|
+
const beforeKey = beforeKeyForSegment(snapshot.before, matched.id);
|
|
3133
|
+
if (beforeKey === null) {
|
|
3134
|
+
return note(`entity '${matched.id}' was not in the session-open snapshot — observe binds {id} ` +
|
|
3135
|
+
'against witness-held before-state, never against suite-declared ids');
|
|
3136
|
+
}
|
|
3137
|
+
const beforeEntry = snapshot.before.get(beforeKey);
|
|
3138
|
+
entityIdForRead = beforeEntry.entityId;
|
|
3139
|
+
if (operation === 'update')
|
|
3140
|
+
before = { found: true, fields: beforeEntry.fields };
|
|
3141
|
+
}
|
|
3142
|
+
// Echo source (create/update only): the witness-observed request
|
|
3143
|
+
// fields. Read/delete carry no echo — presence/absence grades them.
|
|
3144
|
+
let observedFields = {};
|
|
3145
|
+
if (operation === 'create' || operation === 'update') {
|
|
3146
|
+
const parsed = parseObserveBody(matched.exchange);
|
|
3147
|
+
if ('error' in parsed)
|
|
3148
|
+
return note(parsed.error);
|
|
3149
|
+
observedFields = parsed.fields;
|
|
3150
|
+
}
|
|
3151
|
+
let read;
|
|
3152
|
+
try {
|
|
3153
|
+
read = await readObserveEntity(state, resourceId, entityIdForRead);
|
|
3154
|
+
}
|
|
3155
|
+
catch (error) {
|
|
3156
|
+
return note(error instanceof HttpError ? error.message : error.message);
|
|
3157
|
+
}
|
|
3158
|
+
const payload = {
|
|
3159
|
+
resourceId,
|
|
3160
|
+
entityId: read.entityId,
|
|
3161
|
+
found: read.found,
|
|
3162
|
+
...(read.found ? { fields: read.fields ?? {} } : {}),
|
|
3163
|
+
...(before !== undefined ? { before } : {}),
|
|
3164
|
+
observedFields,
|
|
3165
|
+
exchange: {
|
|
3166
|
+
method: matched.exchange.method,
|
|
3167
|
+
path: matched.exchange.path,
|
|
3168
|
+
status: matched.exchange.status,
|
|
3169
|
+
seq: matched.exchange.seq,
|
|
3170
|
+
},
|
|
3171
|
+
sessionId: session.sessionId,
|
|
3172
|
+
channel: OBSERVE_CHANNEL,
|
|
3173
|
+
};
|
|
3174
|
+
// The record binds runId/claimId/testId and rides the same ledger
|
|
3175
|
+
// attestation MAC as every witnessed record. Contents are
|
|
3176
|
+
// witness-produced (proxy capture + adapter read) — `engine-observed`
|
|
3177
|
+
// origin, witnessed trust; the suite-driven-browser distinction rides
|
|
3178
|
+
// `channel: 'observe'`, which the grader keys off explicitly.
|
|
3179
|
+
const issued = issueRecord(state, claimId, OBSERVED_KIND, session.testId, payload, 'engine-observed');
|
|
3180
|
+
// Single-use: the matched exchange can never credit another claim.
|
|
3181
|
+
const consumed = state.observed.indexOf(matched.exchange);
|
|
3182
|
+
if (consumed !== -1)
|
|
3183
|
+
state.observed.splice(consumed, 1);
|
|
3184
|
+
session.activity += 1;
|
|
3185
|
+
return {
|
|
3186
|
+
record: { obligationId: claimId, recordId: issued.recordId, operation, entityId: read.entityId },
|
|
3187
|
+
};
|
|
3188
|
+
}
|
|
1970
3189
|
/**
|
|
1971
3190
|
* `POST /witness/server-persistence` — SUPERVISOR ONLY (run token +
|
|
1972
3191
|
* verifier key; the trusted CLI drain forwards intents the supervised
|
|
@@ -2469,6 +3688,22 @@ async function stopWitness(state) {
|
|
|
2469
3688
|
for (const session of state.sessions.values()) {
|
|
2470
3689
|
await stopSessionProxy(session);
|
|
2471
3690
|
}
|
|
3691
|
+
// Phase 4 lifecycle shutdown: release every live fixture lease namespace.
|
|
3692
|
+
// Timeouts/failures release only their own namespace and never flip a
|
|
3693
|
+
// verdict — releases here are best-effort shutdown hygiene.
|
|
3694
|
+
{
|
|
3695
|
+
const provider = state.options.fixtureProvider ?? null;
|
|
3696
|
+
if (provider !== null) {
|
|
3697
|
+
for (const execution of state.caseExecutions.values()) {
|
|
3698
|
+
try {
|
|
3699
|
+
await provider.release(execution.lease.leaseId);
|
|
3700
|
+
}
|
|
3701
|
+
catch {
|
|
3702
|
+
// Best-effort: shutdown must complete.
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
2472
3707
|
await state.engineBrowser.closeAll();
|
|
2473
3708
|
await new Promise((resolveClose) => {
|
|
2474
3709
|
state.server.close(() => resolveClose());
|