@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
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `spec/v2/core/idempotency.md` Concurrency row — the loser of a same-key race
3
+ * (RFC 0213 §B; suite 2.37.0, target major 2; unaided; one run created).
4
+ *
5
+ * Of concurrent same-key requests a host MUST process exactly one. The others
6
+ * MAY wait and receive the winner's response only if it is a final outcome,
7
+ * marked `OpenWOP-Idempotent-Replay: true`; otherwise the host MUST answer
8
+ * `409 idempotency_in_flight` with no retry timing in `details`, and SHOULD set
9
+ * `Retry-After`. The v2 counterpart of the major-1 `highConcurrency.test.ts`.
10
+ *
11
+ * N = 5 parallel `POST /runs` with one key. The record is in flight only
12
+ * while the winning create request is being handled — not while its run
13
+ * executes — so a host that answers create in milliseconds rarely refuses a
14
+ * loser; the fixture choice does not widen that window:
15
+ * - exactly one distinct runId across the successes (two runs = the claim
16
+ * was not honored);
17
+ * - every success but one carries `OpenWOP-Idempotent-Replay: true`;
18
+ * - every refusal is `409 idempotency_in_flight`, carries no
19
+ * `details.retryAfter*`, and a `Retry-After`, when present, parses.
20
+ *
21
+ * Non-vacuity: overlap is not guaranteed. When no request was refused in flight
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.
31
+ *
32
+ * @see spec/v2/core/idempotency.md
33
+ * @see RFCS/0213-three-unstated-v2-outcomes.md §B
34
+ */
35
+
36
+ import { describe, it, expect } from 'vitest';
37
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
38
+ import { v2Discovery } from '../lib/v2.js';
39
+ import { readErrorCode } from '../lib/error-envelope.js';
40
+ import { softSkip } from '../lib/soft-skip.js';
41
+ import { req } from '../lib/requirement-ids.js';
42
+
43
+ const DOC = 'spec/v2/core/idempotency.md Concurrency';
44
+ const ID_ONE = 'openwop.requirement.0213.in-flight-one-winner';
45
+ const ID_LOSER = 'openwop.requirement.0213.in-flight-loser-outcome';
46
+ const FIXTURE = 'conformance-delay';
47
+ const N = 5;
48
+
49
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
50
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
51
+ function parsesRetryAfter(v: string): boolean { return /^\d+$/.test(v.trim()) || !Number.isNaN(Date.parse(v)); }
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
+
100
+ describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)', () => {
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'));
106
+ expect(runIds.size, req(ID_ONE, DOC, `a host MUST NOT process two same-key requests: distinct runIds ${[...runIds].join(', ')}`)).toBe(1);
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 ?? {};
118
+ const timing = Object.keys(details).filter((k) => /^retryAfter/i.test(k));
119
+ expect(timing, req(ID_LOSER, 'spec/v2/core/errors.md §Retry timing', `retry timing MUST NOT travel in details (found ${timing.join(', ')})`)).toEqual([]);
120
+ const ra = x.headers.get('retry-after');
121
+ if (ra !== null) expect(parsesRetryAfter(ra), req(ID_LOSER, DOC, `a Retry-After that is present MUST parse (got ${ra})`)).toBe(true);
122
+ }
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`);
131
+ }, 60_000);
132
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `spec/v2/core/errors.md` §"One code per state" — a resolve after the run
3
+ * ended (RFC 0213 §C; suite 2.37.0, target major 2; unaided; two runs created).
4
+ *
5
+ * A run-scoped resolve against an interrupt whose run is cancelled or completed
6
+ * MUST return `409 interrupt_already_resolved`; `interrupt_cancelled` is
7
+ * registered but names no state of the core resolve surfaces, and a host MUST
8
+ * NOT emit it. A resolve on an unknown run is `404 not_found`.
9
+ *
10
+ * Legs:
11
+ * 1. suspend (`conformance-approval`) → cancel → resolve ⇒ 409
12
+ * interrupt_already_resolved (a 410 `interrupt_cancelled`, a vendor
13
+ * "gone" code, or a 200 that "resumes" a cancelled run all fail);
14
+ * 2. suspend → resolve (accept) → completed → resolve again ⇒ the same 409;
15
+ * 3. a resolve on an unknown own-tenant runId ⇒ 404 not_found.
16
+ *
17
+ * The signed-token surface is a SHOULD and needs a token the suite cannot mint;
18
+ * its terminal outcome is the same row of `interrupt.md` and is not re-tested
19
+ * here.
20
+ *
21
+ * @see spec/v2/core/errors.md §One code per state
22
+ * @see spec/v2/core/interrupt.md §Resolve
23
+ * @see RFCS/0213-three-unstated-v2-outcomes.md §C
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
28
+ import { v2Discovery } from '../lib/v2.js';
29
+ import { readErrorCode } from '../lib/error-envelope.js';
30
+ import { softSkip } from '../lib/soft-skip.js';
31
+ import { req } from '../lib/requirement-ids.js';
32
+
33
+ const DOC = 'spec/v2/core/errors.md §One code per state';
34
+ const ID_CANCELLED = 'openwop.requirement.0213.resolve-after-cancel';
35
+ const ID_COMPLETED = 'openwop.requirement.0213.resolve-after-complete';
36
+ const ID_UNKNOWN = 'openwop.requirement.0213.resolve-unknown-run';
37
+ const FIXTURE = 'conformance-approval';
38
+ const NODE_ID = 'gate';
39
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
40
+
41
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
42
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
43
+ const enc = (id: string): string => encodeURIComponent(id);
44
+
45
+ async function statusOf(runId: string): Promise<string | null> {
46
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
47
+ return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
48
+ }
49
+ async function waitStatus(runId: string, wanted: ReadonlySet<string>, timeoutMs: number): Promise<string | null> {
50
+ const deadline = Date.now() + timeoutMs;
51
+ for (;;) {
52
+ const s = await statusOf(runId);
53
+ if (s !== null && wanted.has(s)) return s;
54
+ if (Date.now() > deadline) return s;
55
+ await new Promise((r) => setTimeout(r, 250));
56
+ }
57
+ }
58
+ async function suspended(): Promise<{ runId: string } | { reason: string }> {
59
+ const res = await http(() => driver.post('/runs', { workflowId: FIXTURE }));
60
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
61
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
62
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${FIXTURE}) answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
63
+ const s = await waitStatus(runId, new Set(['waiting-approval', ...TERMINAL]), 10_000);
64
+ if (s !== 'waiting-approval') return { reason: `the ${FIXTURE} run did not suspend on its approval gate (status ${String(s)})` };
65
+ return { runId };
66
+ }
67
+ const resolve = (runId: string): Promise<OpenWOPResponse | null> => http(() => driver.post(`/runs/${enc(runId)}/interrupts/${enc(NODE_ID)}`, { resumeValue: { action: 'accept' } }));
68
+
69
+ describe('v2 interrupt-resolve-terminal (errors.md §One code per state, RFC 0213 §C)', () => {
70
+ it('a run-scoped resolve after the run was cancelled is 409 interrupt_already_resolved', async () => {
71
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
72
+ const s = await suspended(); if ('reason' in s) return softSkip('blocked', s.reason);
73
+ const cancel = await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
74
+ if (cancel === null || cancel.status !== 200) return softSkip('blocked', `POST /runs/{runId}/cancel answered ${cancel?.status ?? 'nothing'} — cancelRun owns that contract`);
75
+ const settled = await waitStatus(s.runId, new Set(['cancelled']), 10_000);
76
+ if (settled !== 'cancelled') return softSkip('blocked', `the cancelled run did not reach cancelled within 10 s (status ${String(settled)})`);
77
+ const res = await resolve(s.runId);
78
+ if (res === null) return softSkip('blocked', 'the resolve was unreachable (fetch failed)');
79
+ const code = readErrorCode(res.json);
80
+ expect({ status: res.status, code }, req(ID_CANCELLED, DOC, `a resolve against a cancelled run MUST return 409 interrupt_already_resolved — never 410 interrupt_cancelled, a vendor code, or a 200 (got ${res.status} ${String(code)})`)).toEqual({ status: 409, code: 'interrupt_already_resolved' });
81
+ }, 45_000);
82
+
83
+ it('a second run-scoped resolve after the run completed is 409 interrupt_already_resolved', async () => {
84
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
85
+ const s = await suspended(); if ('reason' in s) return softSkip('blocked', s.reason);
86
+ const first = await resolve(s.runId);
87
+ if (first === null || first.status < 200 || first.status >= 300) return softSkip('blocked', `the first resolve answered ${first?.status ?? 'nothing'} ${readErrorCode(first?.json) ?? ''} — interrupt resolution owns that contract`);
88
+ const settled = await waitStatus(s.runId, TERMINAL, 10_000);
89
+ if (settled !== 'completed') return softSkip('blocked', `the accepted run did not complete within 10 s (status ${String(settled)})`);
90
+ const res = await resolve(s.runId);
91
+ if (res === null) return softSkip('blocked', 'the second resolve was unreachable (fetch failed)');
92
+ const code = readErrorCode(res.json);
93
+ expect({ status: res.status, code }, req(ID_COMPLETED, DOC, `a resolve against a completed run MUST return 409 interrupt_already_resolved (got ${res.status} ${String(code)})`)).toEqual({ status: 409, code: 'interrupt_already_resolved' });
94
+ }, 45_000);
95
+
96
+ it('a run-scoped resolve on an unknown run is 404 not_found', async () => {
97
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
98
+ const s = await suspended(); if ('reason' in s) return softSkip('blocked', s.reason);
99
+ await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
100
+ const slash = s.runId.indexOf('/');
101
+ if (slash <= 0) return softSkip('blocked', `the created runId ${s.runId} carries no tenant segment — v2-id-grammar owns that contract`);
102
+ const res = await resolve(`${s.runId.slice(0, slash)}/openwopconformanceunknown${Date.now().toString(36)}`);
103
+ if (res === null) return softSkip('blocked', 'the resolve was unreachable (fetch failed)');
104
+ const code = readErrorCode(res.json);
105
+ expect({ status: res.status, code }, req(ID_UNKNOWN, 'spec/v2/core/interrupt.md §Resolve', `a resolve on an unknown run MUST be 404 not_found (got ${res.status} ${String(code)})`)).toEqual({ status: 404, code: 'not_found' });
106
+ }, 45_000);
107
+ });
@@ -47,6 +47,9 @@ const META_C = 'io.modelcontextprotocol/clientCapabilities';
47
47
  const ERR = { HEADER_MISMATCH: -32020, MISSING_CAPABILITY: -32021, UNSUPPORTED_VERSION: -32022, INVALID_PARAMS: -32602 } as const;
48
48
 
49
49
  const MAP = JSON.parse(readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'interop-map.json'), 'utf8')) as { mcp: { features: Array<{ id: string; requiredFor: string[] }> } };
50
+ // `interruptId` is host-minted and tenant-bound (`<tenant>/<opaque>`); `nodeId` is author-chosen
51
+ // inside a workflow definition and carries no `/`, so the two grammars cannot both admit one string.
52
+ const INTERRUPT_ID = new RegExp((JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'ids.schema.json'), 'utf8')) as { $defs: { interruptId: { pattern: string } } }).$defs.interruptId.pattern);
50
53
  const REQUIRED_FEATURES = MAP.mcp.features.filter((f) => f.requiredFor.includes(PROFILE)).map((f) => f.id).sort();
51
54
 
52
55
  interface RpcError { code: number; message?: string; data?: Record<string, unknown> }
@@ -200,6 +203,44 @@ describe('RFC 0208 — v2-mcp-mount-map (host as MCP 2026-07-28 server, gated on
200
203
  expect(forged.error !== undefined || forged.status >= 400, req(id, 'interop-map.json mcp.mrtr requestState (host as server)', `a requestState that fails integrity verification MUST be refused (got ${forged.status} ${JSON.stringify(forged.result)})`)).toBe(true);
201
204
  });
202
205
 
206
+ it('the MRTR input-request key is the open interrupt’s interruptId, never the node it suspended on', async () => {
207
+ const m = await mount();
208
+ if (!m.ok) return skip(m);
209
+ const missing = needFixtures(['conformance-approval']);
210
+ if (missing) return softSkip('blocked', missing);
211
+ const id = R('mcp-mrtr-input-request-key');
212
+ const TOOL = 'conformance-approval';
213
+ const caps = { elicitation: {} };
214
+ const runs = async (): Promise<string[]> => ((((await driver.get(`/runs?workflowId=${TOOL}&limit=50`)).json as { runs?: Array<{ runId?: string }> } | undefined)?.runs ?? []).map((x) => String(x.runId)));
215
+ const listable = await familyAdvertised('runList');
216
+ const before = listable ? await runs() : [];
217
+ // TWO runs of ONE workflow: both suspend at the SAME node, so a node-keyed host
218
+ // advertises the SAME key for two different outstanding interrupts and the key stops
219
+ // naming the request. (`tasks/get` projects this same key — mcp.tasks.status.)
220
+ const a = await toolCall(m.url, TOOL, {}, { caps });
221
+ const b = await toolCall(m.url, TOOL, {}, { caps });
222
+ expect([a.result?.['resultType'], b.result?.['resultType']], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', `both calls MUST answer InputRequiredResult (got ${JSON.stringify([a.error ?? a.result, b.error ?? b.result])})`)).toEqual(['input_required', 'input_required']);
223
+ const keys = [a, b].map((r) => Object.keys((r.result?.['inputRequests'] ?? {}) as Record<string, unknown>));
224
+ expect(keys.map((k) => k.length), req(id, 'interop-map.json mcp.tasks.status waiting-approval', `inputRequests carries exactly one key per open interrupt (got ${JSON.stringify(keys)})`)).toEqual([1, 1]);
225
+ const [ka, kb] = [keys[0]![0]!, keys[1]![0]!];
226
+ expect([INTERRUPT_ID.test(ka), INTERRUPT_ID.test(kb)], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server); schemas/v2/ids.schema.json interruptId', `the key MUST be the interrupt’s interruptId, which is tenant-bound (${INTERRUPT_ID.source}) and so can never be an author-chosen nodeId (got ${JSON.stringify([ka, kb])})`)).toEqual([true, true]);
227
+ expect(ka === kb, req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', `two outstanding interrupts MUST NOT share a key — these runs suspend at the same node, so an equal key is that node’s id, not either interrupt’s (got ${ka})`)).toBe(false);
228
+ if (listable) {
229
+ // The positive tie: the key is an id the run’s own log minted, not merely a well-formed
230
+ // string. Runs another leg started in parallel only widen the pool, never narrow it.
231
+ const minted = new Set<string>();
232
+ for (const runId of (await runs()).filter((r) => !before.includes(r))) {
233
+ const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
234
+ for (const e of ((poll.json as { events?: Array<{ type?: string; payload?: { interruptId?: unknown } }> } | undefined)?.events ?? [])) if (e.type === 'node.suspended' && typeof e.payload?.interruptId === 'string') minted.add(e.payload.interruptId);
235
+ }
236
+ expect([minted.has(ka), minted.has(kb)], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server); runs.md node.suspended', `each key MUST be an interruptId the run’s own node.suspended carries (keys ${JSON.stringify([ka, kb])}; minted ${JSON.stringify([...minted])})`)).toEqual([true, true]);
237
+ }
238
+ // …and that key is the one the retry answers: the state minted beside it resolves the run.
239
+ const done = await toolCall(m.url, TOOL, { requestState: a.result?.['requestState'], inputResponses: { [ka]: { action: 'accept', content: { action: 'accept' } } } }, { caps });
240
+ expect([done.error, done.result?.['resultType'], done.result?.['isError']], req(id, 'interop-map.json mcp.mrtr inputResponses[key] (host as server)', `the interruptId key MUST be the key inputResponses is read under (got ${JSON.stringify(done.error ?? done.result)})`)).toEqual([undefined, 'complete', false]);
241
+ await toolCall(m.url, TOOL, { requestState: b.result?.['requestState'], inputResponses: { [kb]: { action: 'decline' } } }, { caps });
242
+ });
243
+
203
244
  it('a list that differs per caller is cacheScope private', async () => {
204
245
  const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
205
246
  if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller half cannot run');
@@ -101,6 +101,12 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
101
101
  try {
102
102
  const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
103
103
  if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
104
+ // A SUCCESS is only evidence if the host negotiated with THIS peer — the
105
+ // one that offers only the lower version. Before 2.37.0, on a tunnelled
106
+ // cut, the host reached the shared fake instead and this leg read an
107
+ // untouched peer (`lib/front-mux.ts`). A refusal needs no contact (not
108
+ // calling satisfies the MUST NOT); a success with none is `blocked`.
109
+ if (res.status < 400 && peer.invocations().length === 0) return softSkip('blocked', `the seam answered ${res.status} and the lower-version suite peer at ${peer.hostFacingEndpoint()} received no request — the exchange this row measures did not reach it`);
104
110
  const wire = peer.invocations().filter((i) => i.method !== 'GET').map((i) => String(i.headers['a2a-version'] ?? ''));
105
111
  await assertNotLowered('openwop.requirement.0175.negotiation-authenticated', preferred, A2A_LOWER, res, wire);
106
112
  } finally {
@@ -121,6 +127,13 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
121
127
  try {
122
128
  const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), authenticated: false });
123
129
  if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
130
+ // This leg has no seam knob forcing the lower offer: the ONLY thing that
131
+ // makes the exchange unauthenticated-and-lower is this server. Before
132
+ // 2.37.0 a tunnelled cut sent the host to the shared fake, which offers
133
+ // preferredVersion, so the leg passed having measured nothing
134
+ // (`lib/front-mux.ts`). A refusal needs no contact; a success with none
135
+ // is `blocked`, never a pass.
136
+ if (res.status < 400 && server.invocations().length === 0) return softSkip('blocked', `the seam answered ${res.status} and the lower-revision suite server at ${server.hostFacingEndpoint()} received no request — the exchange this row measures did not reach it`);
124
137
  const wire = server.invocations().map((i) => String(i.headers['mcp-protocol-version'] ?? ''));
125
138
  await assertNotLowered('openwop.requirement.0175.negotiation-authenticated.mcp', preferred, MCP_LOWER, res, wire);
126
139
  } finally {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `spec/v2/core/events.md` §SSE frames — `Last-Event-ID` is an exclusive
3
+ * cursor (RFC 0213 §A; suite 2.37.0, target major 2; unaided; one run created).
4
+ *
5
+ * `Last-Event-ID: N` streams the events with `sequence > N`. When N is at or
6
+ * beyond the last persisted sequence there is no backlog: a terminal run's
7
+ * stream closes without a frame. A host MUST NOT refuse a well-formed
8
+ * non-negative integer because no event carries that sequence; any other value
9
+ * SHOULD be refused `400 validation_error`. The header is evaluated only after
10
+ * the caller is authorized to read the run: for a run the caller cannot read
11
+ * the answer MUST be the one the host gives without the header.
12
+ *
13
+ * Off the core-standard floor: `v2-sse-last-event-id` is the floor scenario and
14
+ * this file adds legs a committed claim was never measured against (rc.59
15
+ * precedent). Promotion waits until the three bundle hosts are measured.
16
+ *
17
+ * Legs, on one completed noop run:
18
+ * 1. a future id (last + 1000) ⇒ 200, zero frames, closed by the server — a
19
+ * host that 400s the id fails the status, one that resumes from 0 fails
20
+ * the frame count;
21
+ * 2. `Last-Event-ID: abc` ⇒ if refused, `400 validation_error` (SHOULD: a
22
+ * host that ignores it is recorded, not failed);
23
+ * 3. an unknown own-tenant runId and a forged foreign-tenant runId answer the
24
+ * same status and error code with and without the header; and — the
25
+ * threat itself — an EXISTING run of a second tenant
26
+ * (`OPENWOP_TEST_TENANT_B_API_KEY`) answers the same with no header, a
27
+ * cursor inside its log and a cursor past it. A host that reads the
28
+ * cursor before authorizing can only be told apart on a run that
29
+ * exists; without the second credential the leg records
30
+ * `partial-witness`, because the nonexistent-run comparisons cannot
31
+ * catch that host.
32
+ *
33
+ * @see spec/v2/core/events.md §SSE frames
34
+ * @see RFCS/0213-three-unstated-v2-outcomes.md §A
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
39
+ import { subscribe, type SseEvent } from '../lib/sse.js';
40
+ import { v2Discovery } from '../lib/v2.js';
41
+ import { readErrorCode } from '../lib/error-envelope.js';
42
+ import { softSkip } from '../lib/soft-skip.js';
43
+ import { req } from '../lib/requirement-ids.js';
44
+
45
+ const DOC = 'spec/v2/core/events.md §SSE frames';
46
+ const ID_CURSOR = 'openwop.requirement.0213.last-event-id-exclusive-cursor';
47
+ const ID_MALFORMED = 'openwop.requirement.0213.last-event-id-malformed';
48
+ const ID_AUTHZ = 'openwop.requirement.0213.last-event-id-after-authorization';
49
+ const NOOP = 'conformance-noop';
50
+ const V2 = { 'OpenWOP-Version': '2.0' };
51
+ const FOREIGN_RUN_ID = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
52
+
53
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
54
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
55
+ const enc = (id: string): string => encodeURIComponent(id);
56
+ function seqOf(f: SseEvent): number | null { if (f.id === null || f.id === '') return null; const n = Number.parseInt(f.id, 10); return Number.isFinite(n) ? n : null; }
57
+
58
+ async function createSettled(bearer?: string): Promise<{ runId: string } | { reason: string }> {
59
+ const as = bearer === undefined ? {} : { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } };
60
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP }, as));
61
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
62
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
63
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
64
+ const t0 = Date.now();
65
+ while (Date.now() - t0 < 10_000) {
66
+ const s = await http(() => driver.get(`/runs/${enc(runId)}`, as));
67
+ if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(String((s.json as { status?: unknown }).status))) return { runId };
68
+ await new Promise((r) => setTimeout(r, 250));
69
+ }
70
+ return { reason: 'the noop run did not settle within 10 s' };
71
+ }
72
+
73
+ /** Status and error code of a stream request, never reading an SSE body (a 200 is reported as-is). */
74
+ async function answer(path: string, lastEventId?: string): Promise<{ status: number; code: string | null }> {
75
+ const probe = await subscribe(path, { timeoutMs: 5_000, extraHeaders: V2, ...(lastEventId === undefined ? {} : { lastEventId }) });
76
+ if (probe.status === 200) return { status: 200, code: null };
77
+ const headers: Record<string, string> = { Accept: 'text/event-stream' };
78
+ if (lastEventId !== undefined) headers['Last-Event-ID'] = lastEventId;
79
+ const res = await http(() => driver.get(path, { headers }));
80
+ return { status: probe.status, code: res === null ? null : (readErrorCode(res.json) ?? null) };
81
+ }
82
+
83
+ describe('v2 sse-last-event-id-cursor (events.md §SSE frames, RFC 0213 §A)', () => {
84
+ it('a Last-Event-ID past the log is an exclusive cursor: a terminal run answers 200 with no frame and closes', async () => {
85
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
86
+ const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
87
+ const path = `/runs/${enc(c.runId)}/events?streamMode=debug`;
88
+ const full = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2 });
89
+ if (full.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — streamRunEvents is a core operation and is not mounted');
90
+ const seqs = full.events.map(seqOf).filter((n): n is number => n !== null);
91
+ if (full.status !== 200 || seqs.length === 0) return softSkip('blocked', `the full stream did not yield numbered frames (status ${full.status}, ${seqs.length} ids) — v2-sse-last-event-id owns that contract`);
92
+ const future = String(Math.max(...seqs) + 1000);
93
+ const past = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: future });
94
+ expect(past.status, req(ID_CURSOR, DOC, `a well-formed Last-Event-ID no event carries (${future}) MUST NOT be refused — got ${past.status}`)).toBe(200);
95
+ expect(past.events.length, req(ID_CURSOR, DOC, `a cursor past the end of a terminal run's log MUST yield no frame — a host resuming from 0 re-sends all ${seqs.length} (got ${past.events.length})`)).toBe(0);
96
+ expect(past.closedBy, req(ID_CURSOR, DOC, 'on a terminal run the host MUST close the empty stream itself')).toBe('server');
97
+ }, 45_000);
98
+
99
+ it('a Last-Event-ID that is not a non-negative integer, when refused, is 400 validation_error', async () => {
100
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
101
+ const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
102
+ const got = await answer(`/runs/${enc(c.runId)}/events?streamMode=debug`, 'abc');
103
+ if (got.status === 200) return softSkip('inapplicable', 'the host accepted Last-Event-ID: abc (the refusal is a SHOULD — recorded, not failed)');
104
+ expect(got.status, req(ID_MALFORMED, DOC, `a refused malformed Last-Event-ID SHOULD be 400 — got ${got.status}`)).toBe(400);
105
+ expect(got.code, req(ID_MALFORMED, 'spec/v2/core/errors.md', `the refusal MUST carry validation_error — got ${String(got.code)}`)).toBe('validation_error');
106
+ }, 30_000);
107
+
108
+ it('the cursor never changes the answer for a run the caller cannot read', async () => {
109
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
110
+ const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
111
+ const slash = c.runId.indexOf('/');
112
+ if (slash <= 0) return softSkip('blocked', `the created runId ${c.runId} carries no tenant segment — v2-id-grammar owns that contract`);
113
+ const unknown = `${c.runId.slice(0, slash)}/openwopconformanceunknown${Date.now().toString(36)}`;
114
+ for (const [label, runId] of [['unknown own-tenant run', unknown], ['foreign-tenant run', FOREIGN_RUN_ID]] as const) {
115
+ const path = `/runs/${enc(runId)}/events?streamMode=debug`;
116
+ const bare = await answer(path);
117
+ const cursor = await answer(path, '0');
118
+ expect(bare.status, req(ID_AUTHZ, 'spec/v2/core/runs.md §Identity', `a ${label} MUST NOT be streamed (got ${bare.status})`)).not.toBe(200);
119
+ expect({ status: cursor.status, code: cursor.code }, req(ID_AUTHZ, DOC, `for a ${label} the answer with Last-Event-ID (${cursor.status} ${String(cursor.code)}) MUST equal the answer without it (${bare.status} ${String(bare.code)})`)).toEqual({ status: bare.status, code: bare.code });
120
+ }
121
+ const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
122
+ if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY is not set — the existing-foreign-run comparison, the only one a cursor-first host fails, did not run');
123
+ const theirs = await createSettled(other); if ('reason' in theirs) return softSkip('blocked', `tenant B could not create a run: ${theirs.reason}`);
124
+ if (theirs.runId.slice(0, theirs.runId.indexOf('/')) === c.runId.slice(0, slash)) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY — the leg needs a second tenant');
125
+ const path = `/runs/${enc(theirs.runId)}/events?streamMode=debug`;
126
+ const bare = await answer(path);
127
+ expect(bare.status, req(ID_AUTHZ, 'spec/v2/core/runs.md §Identity', `another tenant's existing run MUST NOT be streamed (got ${bare.status})`)).not.toBe(200);
128
+ for (const cur of ['0', '1000']) {
129
+ const got = await answer(path, cur);
130
+ expect({ status: got.status, code: got.code }, req(ID_AUTHZ, DOC, `for another tenant's existing run the answer with Last-Event-ID ${cur} (${got.status} ${String(got.code)}) MUST equal the answer without it (${bare.status} ${String(bare.code)})`)).toEqual({ status: bare.status, code: bare.code });
131
+ }
132
+ }, 45_000);
133
+ });
@@ -15,10 +15,20 @@
15
15
  * finish after the first terminal event. Growth after a terminal event is the
16
16
  * violation's own evidence, not an inconclusive read.
17
17
  *
18
+ * 2.37.0 — the duplicate-delivery leg takes its receiver from
19
+ * `lib/effect-receiver.ts` rather than a local copy. Both this file and
20
+ * `v2-durability-recovery.test.ts` drive the same host seam, and both handed it
21
+ * the SAME destination URL; on a tunnelled cut that is one byte-identical
22
+ * string, so the two exercises shared one Layer-2 effect identity and a
23
+ * conformant host deduplicated the second one away. This leg survived it (it
24
+ * asserts on the LOG, and tolerates zero arrivals); `0158.duplicate-delivery`,
25
+ * which counts arrivals, did not, and flapped with vitest's file order. The
26
+ * shared receiver mints a per-exercise nonce so the collision cannot recur.
27
+ *
18
28
  * @see spec/v2/core/events.md §The terminal event
29
+ * @see spec/v1/idempotency.md §"Layer 2 Keying"
19
30
  * @see RFCS/0194-terminal-event-ends-forward-execution.md
20
31
  */
21
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
22
32
  import { describe, expect, it } from 'vitest';
23
33
  import { driver } from '../lib/driver.js';
24
34
  import { v2Discovery } from '../lib/v2.js';
@@ -26,7 +36,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
26
36
  import { softSkip } from '../lib/soft-skip.js';
27
37
  import { scaledTimeoutMs } from '../lib/polling.js';
28
38
  import { req } from '../lib/requirement-ids.js';
29
- import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
39
+ import { startEffectReceiver, waitForFirstArrival } from '../lib/effect-receiver.js';
30
40
  import { terminalShapeViolation, TERMINAL_RUN_EVENTS } from '../lib/terminal-shape.js';
31
41
 
32
42
  const ONCE = 'openwop.requirement.0194.terminal-once';
@@ -104,35 +114,19 @@ describe('v2 terminal event (RFC 0194 §A)', () => {
104
114
  if (probe.status === 404 || probe.status === 405) return softSkip('inapplicable', `no RFC 0158 durability test hook at ${KILL_SEAM} (HTTP ${probe.status}) — a black-box suite cannot make a host redeliver accepted work, so this row needs the hook`);
105
115
  const rx = await startEffectReceiver();
106
116
  try {
107
- const target = resolveRegistrationUrl(rx.url);
108
- const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url }));
117
+ const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: rx.url }));
109
118
  const runId = (fired?.json as { runId?: unknown } | null)?.runId;
110
119
  if (fired === null || fired.status >= 400 || typeof runId !== 'string') return softSkip('blocked', `the durability hook answered ${fired?.status ?? 'no response'} for mode=duplicate-delivery — no redelivered run to read`);
111
120
  const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 60_000);
112
121
  if (s === null || !TERMINAL_STATUS.has(s)) return softSkip('blocked', `the redelivered run did not reach a terminal status (last: ${s ?? 'unreadable'})`);
113
- const deadline = Date.now() + scaledTimeoutMs(20_000);
114
- while (rx.arrivals === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
122
+ await waitForFirstArrival(rx, scaledTimeoutMs(20_000));
115
123
  const types = await settledLog(runId);
116
124
  if (types === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — the log is unobservable');
117
125
  expect(types.some((t) => TERMINAL_RUN_EVENTS.has(t)), req(DUP, DOC, 'the redelivered run\'s log reached a terminal event')).toBe(true);
118
126
  expect(terminalShapeViolation(types), req(DUP, DOC, `work delivered twice MUST leave exactly one terminal run event and nothing forward after it — log: ${types.join(' ')}`)).toBeNull();
119
127
  } finally {
120
- await new Promise<void>((ok) => rx.server.close(() => ok()));
128
+ await rx.close();
121
129
  }
122
130
  }, 180_000);
123
131
  });
124
132
 
125
- async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: number }> {
126
- const state = { arrivals: 0 };
127
- const server = createServer((request: IncomingMessage, res: ServerResponse) => {
128
- request.on('data', () => { /* drain */ });
129
- request.on('end', () => { state.arrivals++; res.writeHead(204); res.end(); });
130
- });
131
- const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
132
- const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
133
- const binding = receiverBinding();
134
- await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
135
- const addr = server.address();
136
- const out = { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/effect`, get arrivals() { return state.arrivals; } };
137
- return out;
138
- }