@gate-forge/pack-playwright 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/constants.d.ts +16 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +17 -1
  4. package/dist/constants.js.map +1 -1
  5. package/dist/discovery/inference.d.ts.map +1 -1
  6. package/dist/discovery/inference.js +24 -0
  7. package/dist/discovery/inference.js.map +1 -1
  8. package/dist/discovery/static-discovery.d.ts +7 -0
  9. package/dist/discovery/static-discovery.d.ts.map +1 -1
  10. package/dist/discovery/static-discovery.js +33 -2
  11. package/dist/discovery/static-discovery.js.map +1 -1
  12. package/dist/fixture/evidence.d.ts +14 -0
  13. package/dist/fixture/evidence.d.ts.map +1 -1
  14. package/dist/fixture/evidence.js +23 -0
  15. package/dist/fixture/evidence.js.map +1 -1
  16. package/dist/fixture/witness-client.d.ts +15 -1
  17. package/dist/fixture/witness-client.d.ts.map +1 -1
  18. package/dist/fixture/witness-client.js +18 -0
  19. package/dist/fixture/witness-client.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/supervisor/client.d.ts +16 -1
  25. package/dist/supervisor/client.d.ts.map +1 -1
  26. package/dist/supervisor/client.js +19 -0
  27. package/dist/supervisor/client.js.map +1 -1
  28. package/dist/supervisor/drain.d.ts +7 -3
  29. package/dist/supervisor/drain.d.ts.map +1 -1
  30. package/dist/supervisor/drain.js +70 -2
  31. package/dist/supervisor/drain.js.map +1 -1
  32. package/dist/surface.d.ts +67 -8
  33. package/dist/surface.d.ts.map +1 -1
  34. package/dist/surface.js +132 -34
  35. package/dist/surface.js.map +1 -1
  36. package/dist/witness/adapter-registry.d.ts +16 -0
  37. package/dist/witness/adapter-registry.d.ts.map +1 -1
  38. package/dist/witness/adapter-registry.js +103 -0
  39. package/dist/witness/adapter-registry.js.map +1 -1
  40. package/dist/witness/behavior-request.d.ts +80 -0
  41. package/dist/witness/behavior-request.d.ts.map +1 -0
  42. package/dist/witness/behavior-request.js +277 -0
  43. package/dist/witness/behavior-request.js.map +1 -0
  44. package/dist/witness/behavior.d.ts +52 -0
  45. package/dist/witness/behavior.d.ts.map +1 -0
  46. package/dist/witness/behavior.js +118 -0
  47. package/dist/witness/behavior.js.map +1 -0
  48. package/dist/witness/browser.d.ts +43 -1
  49. package/dist/witness/browser.d.ts.map +1 -1
  50. package/dist/witness/browser.js +55 -4
  51. package/dist/witness/browser.js.map +1 -1
  52. package/dist/witness/fixture-provider.d.ts +86 -0
  53. package/dist/witness/fixture-provider.d.ts.map +1 -0
  54. package/dist/witness/fixture-provider.js +109 -0
  55. package/dist/witness/fixture-provider.js.map +1 -0
  56. package/dist/witness/server.d.ts.map +1 -1
  57. package/dist/witness/server.js +1238 -3
  58. package/dist/witness/server.js.map +1 -1
  59. package/dist/witness/types.d.ts +243 -0
  60. package/dist/witness/types.d.ts.map +1 -1
  61. package/package.json +2 -2
  62. package/python/__pycache__/gateforge_persistence_intents.cpython-312.pyc +0 -0
@@ -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 { SERVER_CHANNEL, SERVER_E2E_TEST_KIND } from '../constants.js';
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());