@openwop/openwop-conformance 2.36.1 → 2.37.1

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 (38) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +4 -3
  3. package/coverage.md +1 -0
  4. package/dist/lib/requirement-ledger.js +44 -3
  5. package/dist/lib/scenario-disposition.js +37 -8
  6. package/dist/spec-artifacts.lock.json +2 -2
  7. package/package.json +2 -2
  8. package/requirements.json +510 -73
  9. package/scenario-majors.json +14 -2
  10. package/schemas/CORPUS-STAMP.json +27 -27
  11. package/src/lib/a2a-error-info.ts +44 -0
  12. package/src/lib/a2a-fake-peer.ts +50 -7
  13. package/src/lib/effect-receiver.ts +135 -0
  14. package/src/lib/front-mux.ts +102 -0
  15. package/src/lib/mcp-fake-server.ts +15 -4
  16. package/src/lib/oidc-issuer.ts +15 -5
  17. package/src/lib/requirement-ledger.ts +82 -4
  18. package/src/lib/scenario-disposition.ts +41 -3
  19. package/src/lib/scoped-receiver.ts +223 -0
  20. package/src/lib/triggerBridge.ts +49 -0
  21. package/src/scenarios/a2a-1-0-agent-card.test.ts +19 -7
  22. package/src/scenarios/auth-subject-link.test.ts +18 -1
  23. package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
  24. package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
  25. package/src/scenarios/v2-a2a-client-error-details.test.ts +70 -0
  26. package/src/scenarios/v2-a2a-operation-map.test.ts +84 -2
  27. package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
  28. package/src/scenarios/v2-durability-recovery.test.ts +83 -35
  29. package/src/scenarios/v2-idempotency-in-flight.test.ts +132 -0
  30. package/src/scenarios/v2-interrupt-resolve-terminal.test.ts +107 -0
  31. package/src/scenarios/v2-mcp-mount-map.test.ts +41 -0
  32. package/src/scenarios/v2-negotiation-authenticated.test.ts +13 -0
  33. package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +133 -0
  34. package/src/scenarios/v2-terminal-event-once.test.ts +15 -21
  35. package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
  36. package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
  37. package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
  38. package/src/setup.ts +24 -4
@@ -44,14 +44,13 @@
44
44
  * @see spec/v2/core/versioning.md §1.2
45
45
  */
46
46
  import { afterEach, describe, it, expect } from 'vitest';
47
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
48
47
  import { readFileSync, readdirSync, statSync } from 'node:fs';
49
48
  import { join } from 'node:path';
50
49
  import { Ajv2020 } from 'ajv/dist/2020.js';
51
50
  import addFormats from 'ajv-formats';
52
51
  import { driver } from '../lib/driver.js';
53
52
  import { v2Discovery, gateFamily } from '../lib/v2.js';
54
- import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
53
+ import { noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
55
54
  import { readErrorCode } from '../lib/error-envelope.js';
56
55
  import { softSkip } from '../lib/soft-skip.js';
57
56
  import { req } from '../lib/requirement-ids.js';
@@ -67,32 +66,23 @@ const EVENT_TYPE = 'run.started';
67
66
  type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
68
67
  type Validator = (doc: unknown) => { ok: boolean; errors: string };
69
68
 
70
- async function startReceiver(): Promise<{ server: Server; url: string; deliveries: Delivery[] }> {
69
+ async function startReceiver(): Promise<ScopedReceiver & { deliveries: Delivery[] }> {
71
70
  const deliveries: Delivery[] = [];
72
- const server = createServer((request: IncomingMessage, res: ServerResponse) => {
73
- const chunks: Buffer[] = [];
74
- request.on('data', (c: Buffer) => chunks.push(c));
75
- request.on('end', () => {
76
- deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers });
77
- res.writeHead(204); res.end();
78
- });
79
- });
80
- // Honour OPENWOP_WEBHOOK_RECEIVER_PORT like `v2-webhook-durable-delivery`
81
- // does. Without it, a tunnelled run registers the tunnel URL here and the
71
+ // `startScopedReceiver` (2.37.0) replaces this file's own `createServer` +
72
+ // pinned-port binding. The comment that stood here described the collision
73
+ // from the inside: "a tunnelled run registers the tunnel URL here and the
82
74
  // tunnel forwards to the PINNED port — held by the other receiver, which
83
75
  // answers 500 by design — so this file's `deliveries` stays empty, its legs
84
76
  // soft-skip, and (because `register()` already asserted) the rows resolve
85
- // `executed-pass`. A wire-shape scenario that never opened a delivery body
86
- // went green. Two major-2 files now want the same pinned port, so the
87
- // webhook lane MUST run with `--max-workers 1`, which is already the
88
- // certification setting.
89
- const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
90
- const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
91
- const binding = receiverBinding();
92
- await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
93
- const addr = server.address();
94
- const port = typeof addr === 'object' && addr ? addr.port : 0;
95
- return { server, url: `http://${binding.advertise}:${port}/hook`, deliveries };
77
+ // `executed-pass`." Four files registered that one byte-identical URL. Each
78
+ // now registers its own nonce path, and `front-mux` routes a delivery to the
79
+ // exercise it was addressed to whichever listener holds the port.
80
+ const rx = await startScopedReceiver((hit, res) => {
81
+ deliveries.push({ body: hit.body, headers: hit.headers });
82
+ res.writeHead(204);
83
+ res.end();
84
+ });
85
+ return { ...rx, deliveries };
96
86
  }
97
87
 
98
88
  /**
@@ -103,8 +93,11 @@ async function startReceiver(): Promise<{ server: Server; url: string; deliverie
103
93
  * for leg 2, not a failure: there is no v1 wire to keep still.
104
94
  */
105
95
  async function register(url: string, major: 1 | 2): Promise<string | null> {
106
- const registration = resolveRegistrationUrl(url);
107
- const reg = await driver.post(major === 2 ? '/webhooks' : '/v1/webhooks', { url: registration.url, events: [EVENT_TYPE] }, { headers: { 'OpenWOP-Version': major === 2 ? '2.0' : '1.0' } });
96
+ // `url` is already the destination for THIS exercise — the public front (when
97
+ // one is wired) plus this receiver's nonce path. It is no longer run through
98
+ // `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
99
+ // the path that makes the subscription this exercise's.
100
+ const reg = await driver.post(major === 2 ? '/webhooks' : '/v1/webhooks', { url, events: [EVENT_TYPE] }, { headers: { 'OpenWOP-Version': major === 2 ? '2.0' : '1.0' } });
108
101
  if (major === 1 && reg.status === 404) {
109
102
  softSkip('inapplicable', 'host serves no 1.x webhook surface (POST /v1/webhooks not_found) — no v1 wire to keep still');
110
103
  return null;
@@ -214,18 +207,22 @@ const V2 = 'https://openwop.dev/spec/v2/';
214
207
  const V1 = 'https://openwop.dev/spec/v1/';
215
208
 
216
209
  describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
217
- let active: Server | null = null;
218
- afterEach(async () => { await unregisterAll(); const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
210
+ // Closed through the receiver, not the raw server: `close()` also drops this
211
+ // exercise's nonce from the front-mux registry, so a retry that arrives after
212
+ // the leg has finished is answered 404 by whoever holds the port rather than
213
+ // being handed to the next exercise's recorder.
214
+ let active: ScopedReceiver | null = null;
215
+ afterEach(async () => { await unregisterAll(); const rx = active; active = null; if (rx) await rx.close(); });
219
216
 
220
217
  it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
221
218
  if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
222
219
  if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
223
- const receiver = await startReceiver(); active = receiver.server;
220
+ const receiver = await startReceiver(); active = receiver;
224
221
  const webhookId = await register(receiver.url, 2);
225
222
  if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
226
223
  const runId = await driveRun();
227
224
  const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
228
- if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
225
+ if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's. ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
229
226
  const v2 = validators(2);
230
227
  const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
231
228
  expect(envelope.ok, req(ID, DOC, `a major-2 delivery MUST validate against webhook-delivery.schema.json (v2) — { runId, workspaceId?, event } with event the verbatim v2 run event. ${envelope.errors}`)).toBe(true);
@@ -245,12 +242,12 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
245
242
  // `versions.supported` that no discovery document has, so this leg was inapplicable on every host.
246
243
  const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
247
244
  if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
248
- const receiver = await startReceiver(); active = receiver.server;
245
+ const receiver = await startReceiver(); active = receiver;
249
246
  const webhookId = await register(receiver.url, 1);
250
247
  if (webhookId === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
251
248
  const runId = await driveRun();
252
249
  const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
253
- if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
250
+ if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s. ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
254
251
  // The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
255
252
  // §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
256
253
  // What the v1 wire cannot carry is the v2 payload's integer `engineVersion` (string on v1) — a fan-out
@@ -269,7 +266,7 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
269
266
  // read null as absence, so this leg was inapplicable on exactly the hosts that could witness it.
270
267
  const gate = era2Gate(disc);
271
268
  if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
272
- const receiver = await startReceiver(); active = receiver.server;
269
+ const receiver = await startReceiver(); active = receiver;
273
270
  const webhookId = await register(receiver.url, 2);
274
271
  if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
275
272
  const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
@@ -279,7 +276,7 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
279
276
  // The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
280
277
  // reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
281
278
  // era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.
282
- if (!d) return softSkip('inapplicable', `no ${EVENT_TYPE} delivery for the seeded era-2 run inside 15s — this host does not fan out seeded history, so the era-2 fan-out branch is unobservable here (recorded under openwop.family.conformance)`);
279
+ if (!d) return softSkip('inapplicable', `no ${EVENT_TYPE} delivery for the seeded era-2 run inside 15s — this host does not fan out seeded history, so the era-2 fan-out branch is unobservable here (recorded under openwop.family.conformance). ${noDeliveryCause(receiver, `${EVENT_TYPE} delivery`)}`);
283
280
  const payload = validators(2).ref(`${V2}run-event-payloads.schema.json#/$defs/runStarted`)(d.event['payload']);
284
281
  expect(payload.ok, req(ID, DOC, `an era-2 row delivered to a major-2 subscriber MUST be projected — the read projection applies at the fan-out as at poll/SSE (events.md §Era-2). ${payload.errors}`)).toBe(true);
285
282
  const owner = ownerOf(d.event);
@@ -28,11 +28,10 @@
28
28
  */
29
29
 
30
30
  import { afterEach, describe, it, expect } from 'vitest';
31
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
32
31
  import { driver } from '../lib/driver.js';
33
32
  import { v2Discovery, gateFamily } from '../lib/v2.js';
34
33
  import { projectBoundId } from '../lib/bound-id.js';
35
- import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
34
+ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
36
35
  import { readErrorCode } from '../lib/error-envelope.js';
37
36
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
38
37
  import { req } from '../lib/requirement-ids.js';
@@ -48,47 +47,48 @@ interface Attempt { readonly key: string; readonly runId: string | null; readonl
48
47
  * (`Infinity` ⇒ always fails). The key is `(webhookId, runId, sequence)` —
49
48
  * the dedup triple webhooks.md §Verification names.
50
49
  */
51
- async function startReceiver(failFirst: number): Promise<{ server: Server; url: string; attempts: Attempt[] }> {
50
+ async function startReceiver(failFirst: number): Promise<ScopedReceiver & { attempts: Attempt[] }> {
52
51
  const attempts: Attempt[] = [];
53
52
  const seen = new Map<string, number>();
54
- const server = createServer((request: IncomingMessage, res: ServerResponse) => {
55
- const chunks: Buffer[] = [];
56
- request.on('data', (c: Buffer) => chunks.push(c));
57
- request.on('end', () => {
58
- const body = Buffer.concat(chunks).toString('utf8');
59
- let runId: string | null = null;
60
- let sequence: unknown = null;
61
- try {
62
- const parsed = JSON.parse(body) as { runId?: unknown; event?: { sequence?: unknown } };
63
- runId = typeof parsed.runId === 'string' ? parsed.runId : null;
64
- sequence = parsed.event?.sequence ?? null;
65
- } catch { /* not JSON — still an attempt */ }
66
- const h = request.headers;
67
- const webhookId = String(h['openwop-webhook-id'] ?? h['x-openwop-webhook-id'] ?? '');
68
- const key = `${webhookId}|${runId ?? ''}|${String(sequence)}`;
69
- const n = (seen.get(key) ?? 0) + 1;
70
- seen.set(key, n);
71
- const status = n <= failFirst ? 500 : 204;
72
- attempts.push({ key, runId, webhookId, status, at: Date.now() });
73
- res.writeHead(status);
74
- res.end();
75
- });
53
+ // `startScopedReceiver` (2.37.0). This receiver answers 500 BY DESIGN, and
54
+ // until now it advertised the same byte-identical destination as the other
55
+ // three webhook files on a tunnelled cut — so on a shared pinned port the
56
+ // exercise that happened to register alongside it saw failures it never
57
+ // caused. The `ours()` filter below was the workaround; the nonce removes the
58
+ // cause. The measured case is in that filter's own comment: a tier-2 host
59
+ // counted 6 attempts against a maxAttempts of 5 because another scenario's
60
+ // subscription delivered into this budget.
61
+ const rx = await startScopedReceiver((hit, res) => {
62
+ let runId: string | null = null;
63
+ let sequence: unknown = null;
64
+ try {
65
+ const parsed = JSON.parse(hit.body) as { runId?: unknown; event?: { sequence?: unknown } };
66
+ runId = typeof parsed.runId === 'string' ? parsed.runId : null;
67
+ sequence = parsed.event?.sequence ?? null;
68
+ } catch { /* not JSON — still an attempt */ }
69
+ const h = hit.headers;
70
+ const webhookId = String(h['openwop-webhook-id'] ?? h['x-openwop-webhook-id'] ?? '');
71
+ const key = `${webhookId}|${runId ?? ''}|${String(sequence)}`;
72
+ const n = (seen.get(key) ?? 0) + 1;
73
+ seen.set(key, n);
74
+ const status = n <= failFirst ? 500 : 204;
75
+ attempts.push({ key, runId, webhookId, status, at: Date.now() });
76
+ res.writeHead(status);
77
+ res.end();
76
78
  });
77
- const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
78
- const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
79
- const binding = receiverBinding();
80
- await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
81
- const addr = server.address();
82
- if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
83
- return { server, url: `http://${binding.advertise}:${addr.port}/`, attempts };
79
+ return { ...rx, attempts };
84
80
  }
85
81
 
86
- let active: Server | null = null;
82
+ // Closed through the receiver: `close()` also drops this exercise's nonce from
83
+ // the front-mux registry, so the retries this file deliberately provokes are
84
+ // answered 404 by whoever next holds the port instead of being handed to the
85
+ // next exercise's recorder.
86
+ let active: ScopedReceiver | null = null;
87
87
  afterEach(async () => {
88
88
  if (active) {
89
- const s = active;
89
+ const rx = active;
90
90
  active = null;
91
- await new Promise<void>((resolve) => s.close(() => resolve()));
91
+ await rx.close();
92
92
  }
93
93
  });
94
94
 
@@ -215,15 +215,18 @@ const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
215
215
  const DEAD_LETTER_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS * 2 + WAIT_SLACK_MS;
216
216
 
217
217
  /** Register the suite receiver; null (with a note) when the host's SSRF guard refuses a loopback URL. */
218
- async function register(url: string): Promise<{ webhookId: string } | null> {
219
- const registration = resolveRegistrationUrl(url);
220
- const reg = await driver.post('/webhooks', { url: registration.url, events: ['run.completed'] });
218
+ async function register(rx: ScopedReceiver): Promise<{ webhookId: string } | null> {
219
+ // `rx.url` is this exercise's own destination already — the front (when
220
+ // wired) plus this receiver's nonce path. It is no longer run through
221
+ // `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
222
+ // the path that makes the subscription ours.
223
+ const reg = await driver.post('/webhooks', { url: rx.url, events: ['run.completed'] });
221
224
  if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
222
- if (!registration.tunnelled) {
225
+ if (!rx.tunnelled) {
223
226
  softSkip('blocked', 'host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel in front of the suite receiver');
224
227
  return null;
225
228
  }
226
- expect.fail(`host rejected the operator-supplied public https receiver (${registration.url}) with webhook_url_rejected — a public https destination is legitimate under webhooks.md §Egress`);
229
+ expect.fail(`host rejected the operator-supplied public https receiver (${rx.url}) with webhook_url_rejected — a public https destination is legitimate under webhooks.md §Egress`);
227
230
  }
228
231
  expect(reg.status, req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
229
232
  const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
@@ -239,8 +242,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
239
242
  if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
240
243
 
241
244
  const receiver = await startReceiver(FAIL_FIRST); // 500, 500, then 204
242
- active = receiver.server;
243
- const sub = await register(receiver.url);
245
+ active = receiver;
246
+ const sub = await register(receiver);
244
247
  if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
245
248
 
246
249
  const create = await driver.post('/runs', { workflowId: FIXTURE });
@@ -258,9 +261,22 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
258
261
  const ours = () => receiver.attempts.filter((a) => a.runId === runId && a.webhookId === sub.webhookId);
259
262
  const retried = await waitFor(() => ours().some((a) => a.status === 204), retryWaitMs(doc));
260
263
  const attempts = ours();
264
+ // A zero that is PROVABLY not a verdict about the host records `blocked`
265
+ // with its cause, not `executed-fail` (2.37.0). `absenceIsUnmeasured` is
266
+ // true only when other traffic reached this listener — the path from the
267
+ // host to this process works, so what is absent is this exercise's
268
+ // IDENTITY, not delivery. That was the ordinary case on a tunnelled cut
269
+ // until this file stopped sharing one byte-identical destination with three
270
+ // others. `blocked` denies certification exactly as a failure does (RFC
271
+ // 0168 §E.1), so nothing is softened. When nothing reached the listener at
272
+ // all the reading is still ambiguous, and the hard assertion below stands —
273
+ // now carrying the address it was waiting on.
274
+ if (attempts.length === 0 && absenceIsUnmeasured(receiver)) {
275
+ return blockedDespiteAssertions(noDeliveryCause(receiver, 'run.completed attempt for this run'));
276
+ }
261
277
  expect(
262
278
  attempts.length,
263
- req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', 'the host MUST attempt delivery of run.completed for THIS run to the registered subscriber'),
279
+ req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Durability', `the host MUST attempt delivery of run.completed for THIS run to the registered subscriber — ${noDeliveryCause(receiver, 'run.completed attempt for this run')}`),
264
280
  ).toBeGreaterThan(0);
265
281
  const failedThenSucceeded = attempts.filter((a) => a.status === 500).length;
266
282
  expect(
@@ -327,8 +343,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
327
343
  if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
328
344
 
329
345
  const receiver = await startReceiver(Number.POSITIVE_INFINITY); // never succeeds
330
- active = receiver.server;
331
- const sub = await register(receiver.url);
346
+ active = receiver;
347
+ const sub = await register(receiver);
332
348
  if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
333
349
 
334
350
  // The row id is the FIRST thing this leg names: `register()` asserts under
@@ -459,8 +475,8 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
459
475
  if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no delivery to exhaust`);
460
476
 
461
477
  const receiver = await startReceiver(Number.POSITIVE_INFINITY); // never succeeds
462
- active = receiver.server;
463
- const sub = await register(receiver.url);
478
+ active = receiver;
479
+ const sub = await register(receiver);
464
480
  if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
465
481
  // Same two traps as the leg above (2.34.1): `register()` asserts under the base
466
482
  // id, so name this row first; and every `blocked` after it must STAND rather
@@ -103,14 +103,14 @@
103
103
  */
104
104
 
105
105
  import { afterEach, describe, expect, it } from 'vitest';
106
- import { softSkip } from '../lib/soft-skip.js';
106
+ import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
107
107
  import { createHmac } from 'node:crypto';
108
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
109
108
  import { driver } from '../lib/driver.js';
110
109
  import { discoveryFamilies } from '../lib/discovery-capabilities.js';
111
110
  import { pollUntilTerminal } from '../lib/polling.js';
112
111
  import { isFixtureAdvertised } from '../lib/fixtures.js';
113
- import { discoverOwnedTenant, receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
112
+ import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
113
+ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
114
114
  import { req } from '../lib/requirement-ids.js';
115
115
 
116
116
  interface DeliveredRequest {
@@ -118,44 +118,43 @@ interface DeliveredRequest {
118
118
  readonly body: string;
119
119
  }
120
120
 
121
- async function startReceiver(): Promise<{ server: Server; url: string; received: DeliveredRequest[] }> {
121
+ async function startReceiver(): Promise<ScopedReceiver & { received: DeliveredRequest[] }> {
122
122
  const received: DeliveredRequest[] = [];
123
- const server = createServer((reqBody: IncomingMessage, res: ServerResponse) => {
124
- const chunks: Buffer[] = [];
125
- reqBody.on('data', (c: Buffer) => chunks.push(c));
126
- reqBody.on('end', () => {
127
- const body = Buffer.concat(chunks).toString('utf8');
128
- const headers: Record<string, string> = {};
129
- for (const [k, v] of Object.entries(reqBody.headers)) {
130
- if (typeof v === 'string') headers[k.toLowerCase()] = v;
131
- else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(',');
132
- }
133
- received.push({ headers, body });
134
- res.writeHead(204);
135
- res.end();
136
- });
137
- });
138
- // Port 0 (ephemeral) by default — nothing outside this process needs to find
139
- // it. But OPENWOP_WEBHOOK_RECEIVER_URL fronts THIS receiver through a tunnel,
140
- // and a tunnel has to be pointed at a port the operator knows in ADVANCE. An
123
+ // `startScopedReceiver` (2.37.0) supplies the listening, the pinned-port
124
+ // binding described below, the public front, AND a nonce path that makes this
125
+ // exercise's destination its own. Four webhook files registered the SAME
126
+ // byte-identical front URL before that, and a webhook subscription outlives
127
+ // the file that made it — so a sibling's retries (one of those files answers
128
+ // 500 by design) arrived here indistinguishable from this run's deliveries.
129
+ //
130
+ // The pinned port is still honoured, and still for the reason it was added:
131
+ // OPENWOP_WEBHOOK_RECEIVER_URL fronts THIS receiver through a tunnel, and a
132
+ // tunnel has to be pointed at a port the operator knows in ADVANCE. An
141
133
  // ephemeral port makes that variable unusable by anyone not reading the port
142
134
  // out of a running process — a gap found by standing up a real TLS front and
143
- // trying to use the feature, not by reading the code. OPENWOP_WEBHOOK_RECEIVER_PORT
144
- // pins it so `ngrok http <port>` (or a proxy) has a stable target.
145
- const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
146
- const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
147
- const binding = receiverBinding();
148
- await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
149
- const addr = server.address();
150
- if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
151
- return { server, url: `http://${binding.advertise}:${addr.port}/`, received };
135
+ // trying to use the feature, not by reading the code.
136
+ const rx = await startScopedReceiver((hit, res) => {
137
+ const headers: Record<string, string> = {};
138
+ for (const [k, v] of Object.entries(hit.headers)) {
139
+ if (typeof v === 'string') headers[k.toLowerCase()] = v;
140
+ else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(',');
141
+ }
142
+ received.push({ headers, body: hit.body });
143
+ res.writeHead(204);
144
+ res.end();
145
+ });
146
+ return { ...rx, received };
152
147
  }
153
148
 
154
- let activeServer: Server | null = null;
149
+ // Closed through the receiver: `close()` also drops this exercise's nonce from
150
+ // the front-mux registry, so a delivery that arrives after the leg has finished
151
+ // is answered 404 rather than handed to the next exercise's recorder.
152
+ let activeServer: ScopedReceiver | null = null;
155
153
  afterEach(async () => {
156
154
  if (activeServer) {
157
- await new Promise<void>((resolve) => activeServer!.close(() => resolve()));
155
+ const rx = activeServer;
158
156
  activeServer = null;
157
+ await rx.close();
159
158
  }
160
159
  });
161
160
 
@@ -179,7 +178,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
179
178
  }
180
179
 
181
180
  const receiver = await startReceiver();
182
- activeServer = receiver.server;
181
+ activeServer = receiver;
183
182
 
184
183
  // Register the webhook.
185
184
  // webhooks.md §Register: `events` + `tenantId` are REQUIRED (empty events → 400).
@@ -190,9 +189,12 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
190
189
  // tenantId is 403'd by a host that scopes subscriptions by membership
191
190
  // (RFC 0093). Single-tenant hosts return undefined ⇒ omit tenantId.
192
191
  const ownedTenant = await discoverOwnedTenant(driver);
193
- const registration = resolveRegistrationUrl(receiver.url);
192
+ // `receiver.url` is this exercise's own destination — the public front (when
193
+ // wired) plus this receiver's nonce path. It is no longer run through
194
+ // `resolveRegistrationUrl`, which returned the front VERBATIM and so dropped
195
+ // the path that makes the subscription ours.
194
196
  const reg = await driver.post('/v1/webhooks', {
195
- url: registration.url,
197
+ url: receiver.url,
196
198
  events: ['run.completed'],
197
199
  ...(ownedTenant ? { tenantId: ownedTenant } : {}),
198
200
  });
@@ -212,7 +214,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
212
214
  if (reg.status === 400) {
213
215
  const body = reg.json as { error?: string };
214
216
  if (body.error === 'webhook_url_rejected') {
215
- if (!registration.tunnelled) {
217
+ if (!receiver.tunnelled) {
216
218
  // eslint-disable-next-line no-console
217
219
  console.warn(
218
220
  '[webhook-signed-delivery] host SSRF guard rejected the loopback receiver; ' +
@@ -222,7 +224,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
222
224
  return softSkip('blocked', 'precondition not met — `body.error === \'webhook_url_rejected\'` returned early (seam, prior step, or fixture unavailable)');
223
225
  }
224
226
  expect.fail(
225
- `host rejected the operator-supplied public https receiver (${registration.url}) with ` +
227
+ `host rejected the operator-supplied public https receiver (${receiver.url}) with ` +
226
228
  'webhook_url_rejected. A public https destination is legitimate under ' +
227
229
  'webhooks.md §"SSRF protection" and RFC 0093 §"Delivery-time egress validation"; ' +
228
230
  'rejecting it is a host defect, not an unmet precondition.',
@@ -307,15 +309,26 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
307
309
  // has a subscriber; if nothing arrived HERE, the delivery went somewhere
308
310
  // this process cannot see and every assertion below it would be vacuous.
309
311
  // It fails — it must never soft-skip.
312
+ //
313
+ // The ONE exception, and it is not a softening (2.37.0): when other traffic
314
+ // DID reach this listener, the path from the host to this process demonstrably
315
+ // works, so a zero is the absence of this exercise's IDENTITY and not of
316
+ // delivery — unmeasured, not unmet. That is recorded `blocked`, which denies
317
+ // certification exactly as a failure does (RFC 0168 §E.1), so the mis-wired
318
+ // front this assertion guards against still cannot read as a pass; a
319
+ // mis-wired front produces no traffic here at all and still fails below.
320
+ if (ourDeliveries.length === 0 && absenceIsUnmeasured(receiver)) {
321
+ return blockedDespiteAssertions(noDeliveryCause(receiver, 'run event for this run'));
322
+ }
310
323
  expect(ourDeliveries.length, req('openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature',
311
324
  'webhooks.md §"Delivery"',
312
- registration.tunnelled
325
+ receiver.tunnelled
313
326
  ? 'host MUST POST at least one event for THIS run to the registered subscriber. ' +
314
327
  'Registration was accepted, so zero deliveries observed on the local receiver means ' +
315
328
  'either the host did not deliver, or OPENWOP_WEBHOOK_RECEIVER_URL does not actually ' +
316
- 'front this process. Both are failures; neither is a skip.'
329
+ `front this process. Both are failures; neither is a skip. ${noDeliveryCause(receiver, 'run event for this run')}`
317
330
  : 'host MUST POST at least one event for THIS run to a registered subscriber within '
318
- + `${DELIVERY_DEADLINE_MS}ms of run.completed`,
331
+ + `${DELIVERY_DEADLINE_MS}ms of run.completed. ${noDeliveryCause(receiver, 'run event for this run')}`,
319
332
  )).toBeGreaterThan(0);
320
333
 
321
334
  // Validate the FIRST delivery's signature contract. Other deliveries
@@ -418,7 +431,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
418
431
  }
419
432
 
420
433
  const receiver = await startReceiver();
421
- activeServer = receiver.server;
434
+ activeServer = receiver;
422
435
 
423
436
  const ownedTenant = await discoverOwnedTenant(driver);
424
437
  const reg = await driver.post('/v1/webhooks', {
package/src/setup.ts CHANGED
@@ -36,7 +36,7 @@ import { basename, join } from 'node:path';
36
36
  import { existsSync, readFileSync } from 'node:fs';
37
37
  import { PKG_ROOT_PATH } from './lib/paths.js';
38
38
  import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
39
- import { requirementIdForFile, resolveFileRecord, resolveItRecord, type FileTestState } from './lib/scenario-disposition.js';
39
+ import { requirementIdForFile, resolveFileRecord, resolveItRecord, type FileTestState, type TestFailure } from './lib/scenario-disposition.js';
40
40
  import { softSkipDisposition, softSkipDispositionSince, softSkipMark } from './lib/soft-skip.js';
41
41
  import { ItIdAllocator, takeExplicitRequirementId } from './lib/requirement-ids.js';
42
42
  import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-coherence.js';
@@ -248,6 +248,8 @@ await maybeStartA2AFakePeer();
248
248
  // Setup-file hooks apply to every file in the worker; state is keyed by file.
249
249
  const _fileStates = new Map<string, FileTestState[]>();
250
250
  const _fileAssertions = new Map<string, number>();
251
+ /** Per file: the failing cases, so the file row can NAME them (2.37.0). */
252
+ const _fileFailures = new Map<string, TestFailure[]>();
251
253
  const _ledgerMarks = new Map<string, number>();
252
254
  // Per-`it` recording (v2 charter Phase 1, suite 1.153.0 — the durable G8 fix
253
255
  // named in scenario-disposition.ts). Each test gets its own ledger row under
@@ -415,6 +417,17 @@ afterEach(({ task }) => {
415
417
  const arr = _fileStates.get(file) ?? [];
416
418
  arr.push(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip');
417
419
  _fileStates.set(file, arr);
420
+ // Which leg failed, and why (2.37.0). The file row used to say only "one or
421
+ // more assertions in the file failed" — a detail that names no case, quotes no
422
+ // message, and cannot be acted on. A tier-2 operator read exactly that row for
423
+ // `v2-run-bulk-cancel` and had to hand-probe every assertion in the file
424
+ // against production to find out what the host had done.
425
+ if (state === 'fail') {
426
+ const msg = ((task.result?.errors ?? [])[0] as { message?: string } | undefined)?.message;
427
+ const fails = _fileFailures.get(file) ?? [];
428
+ fails.push({ name: task.name, ...(msg === undefined ? {} : { message: msg }) });
429
+ _fileFailures.set(file, fails);
430
+ }
418
431
  // RFC 0148 §C assertionCount: how many `expect` calls this test actually made.
419
432
  // A leg that early-returns from a gate makes zero, and a file of such legs is
420
433
  // an `executed-pass` with assertionCount 0 — visible, and unclassified for a
@@ -470,13 +483,19 @@ afterEach(({ task }) => {
470
483
  // `inapplicable` came out `blocked` — which denies certification bundle-wide).
471
484
  const noted = softSkipDispositionSince(file, _itSoftSkipMarks.get(file) ?? 0);
472
485
  const err = (task.result?.errors ?? [])[0] as { message?: string } | undefined;
473
- const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2);
486
+ const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2, task.name);
474
487
  disposition = rec.disposition;
475
488
  detail = rec.detail;
476
489
  }
477
490
  try {
478
491
  const evidence = takeNotedEvidence();
479
- recordRequirement(itId, disposition, detail, { assertionCount: calls, scenarioFile: file, ...(evidence === null ? {} : { evidence }) });
492
+ // `fold: true` — several `it` legs of one file legitimately share ONE
493
+ // explicit id (27 files do, via a module-level `const ID` handed to
494
+ // `req()`). Without the fold the second leg's verdict hit
495
+ // `recordRequirement`'s one-disposition-per-run throw, was swallowed by the
496
+ // catch below, and never reached the JSONL sink: a file could record
497
+ // `executed-fail` while its own requirement row read `executed-pass`.
498
+ recordRequirement(itId, disposition, detail, { assertionCount: calls, scenarioFile: file, fold: true, ...(evidence === null ? {} : { evidence }) });
480
499
  } catch {
481
500
  /* never fail a test for bookkeeping */
482
501
  }
@@ -503,7 +522,7 @@ afterAll(({}, suite) => {
503
522
  // the marker detail — never to a pass. Floors still REJECT that row, so the
504
523
  // honest bundle row and the pressure to say why both survive. The rule is
505
524
  // `resolveFileRecord` (pinned by conformance-execution-witness.test.ts).
506
- const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file), file);
525
+ const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file), file, _fileFailures.get(file) ?? []);
507
526
  const fileRequirementId = requirementIdForFile(file);
508
527
  // A scenario that classified ITSELF wins outright — including its `detail` and
509
528
  // its `assertionCount`.
@@ -530,6 +549,7 @@ afterAll(({}, suite) => {
530
549
  }
531
550
  _fileStates.delete(file);
532
551
  _fileAssertions.delete(file);
552
+ _fileFailures.delete(file);
533
553
  _ledgerMarks.delete(file);
534
554
  _itAllocators.delete(file);
535
555
  _itMarks.delete(file);