@openwop/openwop-conformance 2.37.0 → 2.38.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 (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -3
  3. package/dist/cli.js +8 -18
  4. package/dist/lib/certification-bundle-v3.js +46 -18
  5. package/dist/lib/jcs.js +274 -0
  6. package/dist/lib/requirement-ledger.js +44 -3
  7. package/dist/lib/scenario-disposition.js +37 -8
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirements.json +323 -57
  11. package/scenario-majors.json +7 -3
  12. package/schemas/CORPUS-STAMP.json +30 -30
  13. package/src/cli.ts +8 -16
  14. package/src/lib/certification-bundle-v3.ts +42 -17
  15. package/src/lib/front-mux.ts +44 -2
  16. package/src/lib/jcs.ts +229 -0
  17. package/src/lib/llm-cache-key-recipe.ts +10 -20
  18. package/src/lib/requirement-ledger.ts +82 -4
  19. package/src/lib/scenario-disposition.ts +41 -3
  20. package/src/lib/scoped-receiver.ts +223 -0
  21. package/src/lib/triggerBridge.ts +49 -0
  22. package/src/scenarios/auth-subject-link.test.ts +18 -1
  23. package/src/scenarios/jcs-vectors.test.ts +108 -0
  24. package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
  25. package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
  26. package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
  27. package/src/scenarios/v2-a2a-operation-map.test.ts +28 -0
  28. package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
  29. package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
  30. package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
  31. package/src/scenarios/v2-idempotency-in-flight.test.ts +81 -26
  32. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
  33. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
  34. package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
  35. package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
  36. package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
  37. package/src/setup.ts +24 -4
  38. package/vectors/jcs-v1.json +294 -0
  39. package/vectors/semantic-request-digest-v2.json +52 -0
@@ -27,12 +27,11 @@
27
27
  * @see RFCS/0187-host-found-bindings.md §A
28
28
  */
29
29
  import { describe, it, expect } from 'vitest';
30
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
31
30
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
32
31
  import { v2Discovery, gateFamily } from '../lib/v2.js';
33
32
  import { readErrorCode } from '../lib/error-envelope.js';
34
33
  import { softSkip } from '../lib/soft-skip.js';
35
- import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
34
+ import { noDeliveryCause, startScopedReceiver, unservedDestination, type ScopedReceiver } from '../lib/scoped-receiver.js';
36
35
  import { scaledTimeoutMs } from '../lib/polling.js';
37
36
  import { req } from '../lib/requirement-ids.js';
38
37
  import { projectBoundId, BOUND_ID } from '../lib/bound-id.js';
@@ -50,20 +49,21 @@ const PER_KIND = 'openwop.requirement.0187.bound-id-kinds.per-kind';
50
49
  const WEBHOOK_EMITTED = 'openwop.requirement.0187.bound-id-kinds.webhook-emitted';
51
50
 
52
51
  type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
53
- /** The suite's receiver, bound the way every webhook scenario binds it (pinned port / public front honoured). */
54
- async function startReceiver(): Promise<{ server: Server; url: string; deliveries: Delivery[] }> {
52
+ /**
53
+ * The suite's receiver for THIS exercise — pinned port and public front
54
+ * honoured, plus a per-exercise nonce path (2.37.0). Four webhook files used to
55
+ * register the same byte-identical front URL, and a webhook subscription
56
+ * outlives the file that made it, so a sibling's leftovers arrived here
57
+ * indistinguishable from this run's deliveries.
58
+ */
59
+ async function startReceiver(): Promise<ScopedReceiver & { deliveries: Delivery[] }> {
55
60
  const deliveries: Delivery[] = [];
56
- const server = createServer((request: IncomingMessage, res: ServerResponse) => {
57
- const chunks: Buffer[] = [];
58
- request.on('data', (c: Buffer) => chunks.push(c));
59
- request.on('end', () => { deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers }); res.writeHead(204); res.end(); });
61
+ const rx = await startScopedReceiver((hit, res) => {
62
+ deliveries.push({ body: hit.body, headers: hit.headers });
63
+ res.writeHead(204);
64
+ res.end();
60
65
  });
61
- const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
62
- const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
63
- const binding = receiverBinding();
64
- await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
65
- const addr = server.address();
66
- return { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/hook`, deliveries };
66
+ return { ...rx, deliveries };
67
67
  }
68
68
  const header = (d: Delivery, name: string): string | undefined => { const v = d.headers[name]; return Array.isArray(v) ? v[0] : v; };
69
69
 
@@ -89,13 +89,21 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
89
89
  it('subscriptionId: POST /webhooks mints a bound webhookId, the projected segment is accepted, and a foreign tenant segment is refused 403', async () => {
90
90
  if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
91
91
  if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised — no subscription to mint (gate recorded under openwop.family.webhooks)');
92
- // Route the mint through `resolveRegistrationUrl` so an operator who sets
92
+ // Route the mint through the operator's front so an operator who sets
93
93
  // OPENWOP_WEBHOOK_RECEIVER_URL actually gets a reachable registration here.
94
- // Before this, the blocked note TOLD them to set that variable and this file
94
+ // Before that, the blocked note TOLD them to set the variable and this file
95
95
  // never read it — inoperative advice in a suite where one blocked row denies
96
96
  // every claimed profile (RFC 0168 §E.1). The fallback stays a reserved
97
97
  // `.invalid` host: this leg only needs the mint, never a delivery.
98
- const registration = resolveRegistrationUrl('https://subscriber.invalid/hook');
98
+ //
99
+ // `unservedDestination`, not `resolveRegistrationUrl` (2.37.0): the latter
100
+ // returns the front VERBATIM, so this subscription had the same identity as
101
+ // every other exercise's, and it is live from the 201 until the DELETE
102
+ // below. Anything the host fanned out in that window landed on whichever
103
+ // listener held the pinned port and was read as THAT exercise's traffic. The
104
+ // nonce is served by nobody on purpose — this leg wants no delivery, and a
105
+ // delivery to it is now answered 404 instead of absorbed.
106
+ const registration = unservedDestination('https://subscriber.invalid/hook');
99
107
  const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
100
108
  if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
101
109
  if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') return softSkip('blocked', `host SSRF guard rejected the registration URL ${registration.url}${registration.tunnelled ? ' (from OPENWOP_WEBHOOK_RECEIVER_URL)' : ' — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https receiver, which THIS leg now honours'}`);
@@ -136,11 +144,14 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
136
144
  const rx = await startReceiver();
137
145
  let webhookId: string | null = null;
138
146
  try {
139
- const registration = resolveRegistrationUrl(rx.url);
140
- const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
147
+ // `rx.url` is already this exercise's destination — the front (when
148
+ // wired) plus its nonce path — so it is not run through
149
+ // `resolveRegistrationUrl`, which returns the front VERBATIM and would
150
+ // drop the path that makes the subscription ours.
151
+ const reg = await http(() => driver.post('/webhooks', { url: rx.url, events: ['run.completed'] }));
141
152
  if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
142
153
  if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
143
- return softSkip('blocked', `host SSRF guard rejected the suite receiver ${registration.url} (webhooks.md §Egress requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
154
+ return softSkip('blocked', `host SSRF guard rejected the suite receiver ${rx.url} (webhooks.md §Egress requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
144
155
  }
145
156
  const minted = (reg.json as { webhookId?: unknown } | null)?.webhookId;
146
157
  if (reg.status !== 201 || typeof minted !== 'string') return softSkip('blocked', `POST /webhooks answered ${reg.status} without a webhookId — the mint leg above owns that obligation; this leg needs an id to compare`);
@@ -155,7 +166,7 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
155
166
  const deadline = Date.now() + scaledTimeoutMs(20_000);
156
167
  while (ours().length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
157
168
  const delivery = ours()[0];
158
- if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed`);
169
+ if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed. ${noDeliveryCause(rx, `run.completed delivery for run ${runId}`)}`);
159
170
  expect(
160
171
  header(delivery, 'openwop-webhook-id'),
161
172
  req(WEBHOOK_EMITTED, 'webhooks.md §Headers (RFC 0187 §A.1)', `the delivery's OpenWOP-Webhook-Id MUST equal the tenant-bound webhookId the host minted (${minted}) — a subscriber identifies its deliveries by the header, not by the 201 it received once`),
@@ -172,7 +183,9 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
172
183
  }
173
184
  } finally {
174
185
  if (webhookId !== null) await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId as string)}`));
175
- await new Promise<void>((ok) => rx.server.close(() => ok()));
186
+ // Closed through the receiver: `close()` also drops this exercise's
187
+ // nonce from the front-mux registry.
188
+ await rx.close();
176
189
  }
177
190
  });
178
191
 
@@ -21,6 +21,11 @@
21
21
  * `Content-Language` names the tag (case-insensitively, RFC 5646 §2.1.1) and
22
22
  * the section carries the overlay.
23
23
  *
24
+ * Gate: the family is advertised by the PRESENCE of its record
25
+ * (`familyAdvertised`, RFC 0169 §A.2) — never by a `supported` flag, which the
26
+ * closed v2 `content` record cannot carry. `v2-family-gate-no-supported.test.ts`
27
+ * (a suite self-test) keeps that gate from coming back.
28
+ *
24
29
  * Sabotage: a host that 400s the extended-tag write, or that serves only the
25
30
  * base locale for it, fails the leg (write status / `Content-Language` /
26
31
  * overlay assertion respectively).
@@ -52,7 +57,11 @@ describe('v2-content-locale-keys (RFC 0206)', () => {
52
57
  const doc = await v2Discovery().catch(() => null);
53
58
  if (doc === null) return softSkip('blocked', 'v2 discovery unreachable');
54
59
  const content = await familyAdvertised('content');
55
- if (content === null || content['supported'] !== true) return softSkip('inapplicable', 'the host does not advertise the content family (no content surface to deliver an extended locale from)');
60
+ // RFC 0169 §A.2: at major 2 the record's presence IS the claim. The v2
61
+ // `content` record is closed and has no `supported` field, so a gate on
62
+ // `supported === true` recorded `inapplicable` on every conforming host and
63
+ // this row could never execute (corrected 2026-09-24).
64
+ if (content === null) return softSkip('inapplicable', 'the host does not advertise the content family (no content surface to deliver an extended locale from)');
56
65
  const base = typeof content['baseLocale'] === 'string' ? content['baseLocale'] : null;
57
66
  const supported = Array.isArray(content['supportedLocales']) ? content['supportedLocales'].filter((x): x is string => typeof x === 'string') : [];
58
67
  const keyRe = new RegExp(LOCALE_KEY_PATTERN);
@@ -19,8 +19,15 @@
19
19
  * `details.retryAfter*`, and a `Retry-After`, when present, parses.
20
20
  *
21
21
  * Non-vacuity: overlap is not guaranteed. When no request was refused in flight
22
- * the file records `partial-witness` — the 409 branch never ran, so a pass is
23
- * not claimed for it.
22
+ * the LOSER leg records `partial-witness` — the 409 branch never ran, so a pass
23
+ * is not claimed for it.
24
+ *
25
+ * Two `it`s over ONE race (2.37.x). §B names two requirement ids and both were
26
+ * cited from a single `it`, so only the last of them could ever get a ledger
27
+ * row; the winner id was recorded on no host. The race is driven once and
28
+ * memoised — driving it twice would measure two unrelated races and halve the
29
+ * chance that either overlaps — and each leg now carries its own id, so the
30
+ * winner clause keeps its verdict when the 409 branch does not run.
24
31
  *
25
32
  * @see spec/v2/core/idempotency.md
26
33
  * @see RFCS/0213-three-unstated-v2-outcomes.md §B
@@ -43,35 +50,83 @@ async function discovery(): Promise<Record<string, unknown> | null> { try { retu
43
50
  async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
44
51
  function parsesRetryAfter(v: string): boolean { return /^\d+$/.test(v.trim()) || !Number.isNaN(Date.parse(v)); }
45
52
 
53
+ /** The race's outcome, or the reason it could not be driven. */
54
+ type Race =
55
+ | { readonly kind: 'ran'; readonly all: readonly OpenWOPResponse[]; readonly successes: readonly OpenWOPResponse[]; readonly refusals: readonly OpenWOPResponse[] }
56
+ | { readonly kind: 'blocked'; readonly reason: string };
57
+
58
+ /**
59
+ * ONE race, read by both legs.
60
+ *
61
+ * RFC 0213 §B names two requirement ids, and until 2.37.x both were cited from
62
+ * a SINGLE `it()`. The ledger keys on the id and `setup.ts` takes one
63
+ * `explicitId` per test, so only the LAST one cited got a row:
64
+ * `0213.in-flight-one-winner` was never recorded on any host, and nothing said
65
+ * so. (Found by `check-req-only.mjs` rule (d) the moment it learned to resolve a
66
+ * `const` handed to `req()` — it had compared only call-site literals, so the
67
+ * violation was invisible when this file was written.)
68
+ *
69
+ * Splitting the legs must not split the EXERCISE: the record is in flight only
70
+ * while the winning create is being handled, so driving the race twice would
71
+ * measure two unrelated races and halve the chance that either overlaps. The
72
+ * race is therefore memoised here and both legs await the same result — the
73
+ * house pattern from `v2-subject-link-record.test.ts`.
74
+ */
75
+ let race: Promise<Race> | undefined;
76
+ function theRace(): Promise<Race> {
77
+ return (race ??= drive());
78
+ }
79
+
80
+ async function drive(): Promise<Race> {
81
+ if (!(await discovery())) return { kind: 'blocked', reason: 'v2 discovery unreachable' };
82
+ const key = `openwopconf-inflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
83
+ const body = { workflowId: FIXTURE, inputs: { delayMs: 1500 } };
84
+ const results = await Promise.all(Array.from({ length: N }, () => http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }))));
85
+ if (results.some((r) => r === null)) return { kind: 'blocked', reason: 'POST /runs unreachable (fetch failed)' };
86
+ const rs = results as OpenWOPResponse[];
87
+ const first = rs.find((r) => r.status >= 200 && r.status < 300);
88
+ if (first === undefined) {
89
+ const codes = rs.map((r) => `${r.status} ${readErrorCode(r.json) ?? ''}`.trim()).join(', ');
90
+ if (rs.every((r) => r.status === 404 || r.status === 422)) return { kind: 'blocked', reason: `the ${FIXTURE} fixture is not runnable (${codes})` };
91
+ }
92
+ return {
93
+ kind: 'ran',
94
+ all: rs,
95
+ successes: rs.filter((r) => r.status >= 200 && r.status < 300),
96
+ refusals: rs.filter((r) => !(r.status >= 200 && r.status < 300)),
97
+ };
98
+ }
99
+
46
100
  describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)', () => {
47
- it('concurrent same-key creates yield one run; each loser is a marked replay of a final outcome or 409 idempotency_in_flight with no retry timing in details', async () => {
48
- if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
49
- const key = `openwopconf-inflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
50
- const body = { workflowId: FIXTURE, inputs: { delayMs: 1500 } };
51
- const results = await Promise.all(Array.from({ length: N }, () => http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }))));
52
- if (results.some((r) => r === null)) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
53
- const rs = results as OpenWOPResponse[];
54
- const first = rs.find((r) => r.status >= 200 && r.status < 300);
55
- if (first === undefined) {
56
- const codes = rs.map((r) => `${r.status} ${readErrorCode(r.json) ?? ''}`.trim()).join(', ');
57
- if (rs.every((r) => r.status === 404 || r.status === 422)) return softSkip('blocked', `the ${FIXTURE} fixture is not runnable (${codes})`);
58
- }
59
- const successes = rs.filter((r) => r.status >= 200 && r.status < 300);
60
- const refusals = rs.filter((r) => !(r.status >= 200 && r.status < 300));
61
- expect(successes.length, req(ID_ONE, DOC, `at least one of ${N} same-key creates MUST complete (statuses: ${rs.map((r) => r.status).join(',')})`)).toBeGreaterThan(0);
62
- const runIds = new Set(successes.map((r) => (r.json as { runId?: unknown } | null)?.runId).filter((x): x is string => typeof x === 'string'));
101
+ it('concurrent same-key creates yield exactly one run', async () => {
102
+ const r = await theRace();
103
+ if (r.kind === 'blocked') return softSkip('blocked', r.reason);
104
+ expect(r.successes.length, req(ID_ONE, DOC, `at least one of ${N} same-key creates MUST complete (statuses: ${r.all.map((x) => x.status).join(',')})`)).toBeGreaterThan(0);
105
+ const runIds = new Set(r.successes.map((x) => (x.json as { runId?: unknown } | null)?.runId).filter((x): x is string => typeof x === 'string'));
63
106
  expect(runIds.size, req(ID_ONE, DOC, `a host MUST NOT process two same-key requests: distinct runIds ${[...runIds].join(', ')}`)).toBe(1);
64
- const unmarked = successes.filter((r) => r.headers.get('openwop-idempotent-replay') !== 'true');
65
- expect(unmarked.length, req(ID_LOSER, DOC, `exactly one success is the winner; every other MUST carry OpenWOP-Idempotent-Replay: true (${unmarked.length} of ${successes.length} unmarked)`)).toBe(1);
66
- for (const r of refusals) {
67
- const code = readErrorCode(r.json);
68
- expect({ status: r.status, code }, req(ID_LOSER, DOC, `a loser that is not a replay MUST be 409 idempotency_in_flight (got ${r.status} ${String(code)})`)).toEqual({ status: 409, code: 'idempotency_in_flight' });
69
- const details = (r.json as { details?: Record<string, unknown> } | null)?.details ?? {};
107
+ }, 60_000);
108
+
109
+ it('each loser is a marked replay of a final outcome, or 409 idempotency_in_flight with no retry timing in details', async () => {
110
+ const r = await theRace();
111
+ if (r.kind === 'blocked') return softSkip('blocked', r.reason);
112
+ const unmarked = r.successes.filter((x) => x.headers.get('openwop-idempotent-replay') !== 'true');
113
+ expect(unmarked.length, req(ID_LOSER, DOC, `exactly one success is the winner; every other MUST carry OpenWOP-Idempotent-Replay: true (${unmarked.length} of ${r.successes.length} unmarked)`)).toBe(1);
114
+ for (const x of r.refusals) {
115
+ const code = readErrorCode(x.json);
116
+ expect({ status: x.status, code }, req(ID_LOSER, DOC, `a loser that is not a replay MUST be 409 idempotency_in_flight (got ${x.status} ${String(code)})`)).toEqual({ status: 409, code: 'idempotency_in_flight' });
117
+ const details = (x.json as { details?: Record<string, unknown> } | null)?.details ?? {};
70
118
  const timing = Object.keys(details).filter((k) => /^retryAfter/i.test(k));
71
119
  expect(timing, req(ID_LOSER, 'spec/v2/core/errors.md §Retry timing', `retry timing MUST NOT travel in details (found ${timing.join(', ')})`)).toEqual([]);
72
- const ra = r.headers.get('retry-after');
120
+ const ra = x.headers.get('retry-after');
73
121
  if (ra !== null) expect(parsesRetryAfter(ra), req(ID_LOSER, DOC, `a Retry-After that is present MUST parse (got ${ra})`)).toBe(true);
74
122
  }
75
- if (refusals.length === 0) return softSkip('blocked', `no loser was refused in flight — all ${N} answers were successes, so the 409 branch did not run on this host`);
123
+ // Every loser replayed the winner: RFC 0213 §B permits exactly this (a
124
+ // loser MAY wait and receive a final outcome, marked). The 409 branch did
125
+ // not run, so the row is a partial witness — never `blocked`, which would
126
+ // deny certification (RFC 0168 §E.1) to a host that did nothing wrong.
127
+ // (#1525's fix, kept verbatim; `r.refusals` is the split form's spelling of
128
+ // its `refusals`, and it now lands on the LOSER id it is about rather than
129
+ // on a row shared with the winner clause.)
130
+ if (r.refusals.length === 0) return softSkip('inapplicable', `no loser was refused in flight — all ${N} answers were successes (each loser a marked replay, which §B permits), so the 409 branch did not run on this host`);
76
131
  }, 60_000);
77
132
  });
@@ -85,7 +85,13 @@ describe('RFC 0175 §E.1 — mrtr-rounds-ceiling (gated on mcp + mrtr)', () => {
85
85
  if (!loopKnown) {
86
86
  return softSkip('blocked', `the suite MCP fake server cannot loop — its only MRTR tool (needs_input) completes on the first retry; a \`${LOOP_TOOL}\` fixture tool that re-issues input_required ${rounds} times is required to drive maxRounds + 1`);
87
87
  }
88
- expect(res.status, req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'interop.md §The MCP round ceiling', `round ${rounds} (maxRounds + 1) MUST be refused with 422`)).toBe(422);
88
+ // The failure message carries the host's code, the fake's count and the
89
+ // body (2.38.0). The 2026-09-24 public v2-reference cut on 2.37.1 recorded
90
+ // `expected 400 to be 422` and nothing else — the same leg passes on every
91
+ // loopback run of that host — so whether the 400 was the host refusing a
92
+ // round the fake answered, or a round the tunnel failed to carry, could not
93
+ // be read from the record.
94
+ expect(res.status, req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'interop.md §The MCP round ceiling', `round ${rounds} (maxRounds + 1) MUST be refused with 422 (got ${res.status} ${String(readErrorCode(res.json))}; the fake served ${served.length} tools/call; body ${res.text.slice(0, 400)})`)).toBe(422);
89
95
  expect(readErrorCode(res.json), req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'errors.json mcp_mrtr_rounds_exceeded', `the refusal MUST carry ${CODE}`)).toBe(CODE);
90
96
  expect(
91
97
  served.length,
@@ -118,7 +118,13 @@ describe('RFC 0199 §A — v2-oauth-client-pkce-state-iss (host as OAuth client,
118
118
  n = d.codeExchanges().length;
119
119
  const replay = await userAgentGet(cbA!, key);
120
120
  expect([replay.status >= 400, d.codeExchanges().length], req(id, `${DOC} rule 2`, `a replayed (already consumed) state MUST be refused with no token request (got ${replay.status})`)).toEqual([true, n]);
121
- });
121
+ // 120 s, not the 30 s default (2.38.0): this leg makes eight sequential
122
+ // round trips — two grants, two consents, four callbacks — and on a public
123
+ // cut every one crosses the operator's tunnel to the AS double. The
124
+ // 2026-09-24 public v2-reference cut on 2.37.1 timed out here at 30 s while
125
+ // the sibling legs (pkce-s256, iss-validated, same-user-callback), which
126
+ // make fewer trips, passed. No assertion or window inside the leg changes.
127
+ }, 120_000);
122
128
 
123
129
  it('a callback authenticated as another Subject makes no token request and stores nothing', async () => {
124
130
  const r = await ready();
@@ -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);