@openwop/openwop-conformance 2.37.1 → 2.39.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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * RFC 0214 — A2A push delivery, measured at a SUITE-OWNED receiver.
3
+ * Target major 2.
4
+ *
5
+ * Gate: the v2 `a2a` record lists profile `a2a-1.0` AND advertises
6
+ * `pushNotifications: true`. Otherwise `inapplicable` — the unadvertised host's
7
+ * obligation is the refusal row (`v2-a2a-operation-map`
8
+ * `a2a-push-unadvertised-refused`).
9
+ *
10
+ * Receiver: `lib/scoped-receiver.ts`, one nonce per exercise behind the
11
+ * operator's public front (`OPENWOP_WEBHOOK_RECEIVER_URL`, the RFC 0158
12
+ * receiver). A conforming host REFUSES a loopback, private or plain-http push
13
+ * destination (RFC 0214 §B, webhooks.md §Egress), so without a public https
14
+ * front no leg here can be witnessed and each records `blocked` — never a pass,
15
+ * and never a relaxation of the guard under test.
16
+ *
17
+ * Non-vacuity: every leg drives its task to INPUT_REQUIRED FIRST, registers the
18
+ * config there, and only then resumes — a task that finished before the config
19
+ * existed would owe no push, and a leg reading "nothing arrived" off it would
20
+ * measure nothing. On a public front a missing delivery is a FAILURE, except
21
+ * when other traffic provably reached the listener (`absenceIsUnmeasured`),
22
+ * which records `blocked`.
23
+ *
24
+ * Limit, stated: the delivery-time DNS re-resolve arm of §B cannot be driven
25
+ * from outside the host (the suite does not control the host's resolver); it is
26
+ * witnessed by host unit tests, not here. The `token`-only carriage is a SHOULD
27
+ * (RFC 0214 §C, open question 1) and is not asserted.
28
+ *
29
+ * @see RFCS/0214-a2a-push-credential-is-a-destination-credential.md §A–§D
30
+ * @see spec/v2/core/interop.md §"A2A push delivery"
31
+ * @see spec/v2/core/security-defaults.md §"Onward hops"
32
+ */
33
+ import { describe, it, expect } from 'vitest';
34
+ import { randomBytes } from 'node:crypto';
35
+ import type { ServerResponse } from 'node:http';
36
+ import { driver } from '../lib/driver.js';
37
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
38
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
39
+ import { softSkip } from '../lib/soft-skip.js';
40
+ import { req } from '../lib/requirement-ids.js';
41
+ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedHit, type ScopedReceiver } from '../lib/scoped-receiver.js';
42
+
43
+ export const REQUIRES_HOST_CALLBACK = 'the host POSTs A2A push notifications (StreamResponse statusUpdate) to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
44
+
45
+ const DOC = 'spec/v2/core/interop.md §"A2A push delivery" (RFC 0214)';
46
+ const R = (slug: string): string => `openwop.requirement.0214.${slug}`;
47
+ const PROFILE = 'a2a-1.0';
48
+ const SKILL = 'conformance-approval';
49
+ const ACCEPT = { action: 'accept' };
50
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
51
+
52
+ interface RpcError { code: number; message?: string; data?: unknown }
53
+ interface Rpc { status: number; result?: Record<string, unknown> | undefined; error?: RpcError | undefined }
54
+ interface Task { id?: string; contextId?: string; status?: { state?: string } }
55
+
56
+ async function rpc(url: string, method: string, params: unknown): Promise<Rpc> {
57
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': '1.0' };
58
+ const key = process.env.OPENWOP_API_KEY;
59
+ if (key) headers['authorization'] = `Bearer ${key}`;
60
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params }) });
61
+ const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: RpcError };
62
+ return { status: res.status, ...body };
63
+ }
64
+ function taskOf(r: Rpc): Task | undefined {
65
+ const res = r.result ?? {};
66
+ return (res['task'] ?? (res['id'] !== undefined ? res : undefined)) as Task | undefined;
67
+ }
68
+ const message = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({ messageId: `conf-0214-${randomBytes(8).toString('hex')}`, role: 'ROLE_USER', parts: [{ text: 'openwop conformance RFC 0214' }], ...extra });
69
+ const acceptMessage = (taskId: string): Record<string, unknown> => message({ taskId, parts: [{ data: ACCEPT }], metadata: { openwop: { interrupt: ACCEPT } } });
70
+
71
+ type Gate = { ok: true; url: string } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
72
+ async function gate(): Promise<Gate> {
73
+ if (!(await v2Discovery())) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
74
+ const a2a = await familyAdvertised('a2a');
75
+ if (!a2a) return { ok: false, kind: 'inapplicable', reason: 'a2a not advertised at major 2' };
76
+ const profiles = Array.isArray(a2a['profiles']) ? (a2a['profiles'] as unknown[]) : [];
77
+ if (!profiles.includes(PROFILE)) return { ok: false, kind: 'inapplicable', reason: 'a2a advertised without profile a2a-1.0 — no A2A interface to register a push config on' };
78
+ if (a2a['pushNotifications'] !== true) return { ok: false, kind: 'inapplicable', reason: 'a2a.pushNotifications not advertised — the refusal row applies (v2-a2a-operation-map a2a-push-unadvertised-refused)' };
79
+ const cardUrl = a2a['agentCardUrl'];
80
+ if (typeof cardUrl !== 'string') return { ok: false, kind: 'blocked', reason: 'a2a.agentCardUrl is not advertised' };
81
+ const res = await fetch(cardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } });
82
+ const card = (await res.json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }>; skills?: Array<{ id?: string }> };
83
+ const iface = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0');
84
+ if (typeof iface?.url !== 'string') return { ok: false, kind: 'blocked', reason: 'the card lists no JSONRPC 1.0 interface' };
85
+ const skills = (card.skills ?? []).map((s) => s.id);
86
+ if (skills.length !== 1 || skills[0] !== SKILL || !isFixtureAdvertised(SKILL)) return { ok: false, kind: 'blocked', reason: `the card routes skills [${skills.join(', ')}]; the legs need exactly ${SKILL} (conformance/fixtures.md)` };
87
+ return { ok: true, url: iface.url };
88
+ }
89
+
90
+ /** A receiver that records its own hits and answers each with `answer` (default 204). */
91
+ async function receiver(answer?: (hit: ScopedHit, res: ServerResponse) => void): Promise<{ rx: ScopedReceiver; hits: ScopedHit[] }> {
92
+ const hits: ScopedHit[] = [];
93
+ const rx = await startScopedReceiver((hit, res) => {
94
+ hits.push(hit);
95
+ if (answer) return answer(hit, res);
96
+ res.writeHead(204); res.end();
97
+ });
98
+ return { rx, hits };
99
+ }
100
+ async function until(pred: () => boolean, ms: number): Promise<boolean> {
101
+ const end = Date.now() + ms;
102
+ while (!pred() && Date.now() < end) await new Promise((ok) => setTimeout(ok, 200));
103
+ return pred();
104
+ }
105
+ const wait = (ms: number): Promise<void> => new Promise((ok) => setTimeout(ok, ms));
106
+ const header = (h: ScopedHit, name: string): string | undefined => { const v = h.headers[name.toLowerCase()]; return Array.isArray(v) ? v.join(',') : v; };
107
+
108
+ async function runStatus(taskId: string): Promise<string | undefined> {
109
+ const r = await driver.get(`/runs/${encodeURIComponent(taskId)}`);
110
+ return r.status === 200 ? ((r.json as { status?: string }).status) : undefined;
111
+ }
112
+ async function settle(taskId: string, want: (s: string | undefined) => boolean, ms = 10000): Promise<string | undefined> {
113
+ const end = Date.now() + ms;
114
+ let s = await runStatus(taskId);
115
+ while (!want(s) && Date.now() < end) { await wait(150); s = await runStatus(taskId); }
116
+ return s;
117
+ }
118
+
119
+ /** Start an approval task and hold it at INPUT_REQUIRED — the precondition every leg needs. */
120
+ async function suspendedTask(url: string, id: string): Promise<string> {
121
+ const r = await rpc(url, 'SendMessage', { message: message() });
122
+ const task = taskOf(r);
123
+ expect(typeof task?.id, req(id, DOC, `SendMessage MUST start a task (got ${JSON.stringify(r.error ?? r.result)})`)).toBe('string');
124
+ expect(await settle(task!.id!, (s) => s === 'waiting-approval'), req(id, DOC, 'precondition: the task MUST be suspended (INPUT_REQUIRED) BEFORE the config is registered, or the leg would measure a push nobody owed')).toBe('waiting-approval');
125
+ return task!.id!;
126
+ }
127
+ async function create(url: string, taskId: string, dest: string, extra: Record<string, unknown> = {}): Promise<Rpc> {
128
+ return rpc(url, 'CreateTaskPushNotificationConfig', { taskId, url: dest, ...extra });
129
+ }
130
+
131
+ /** Why a leg cannot be measured on this cut, or null when it can. */
132
+ function unmeasurable(rx: ScopedReceiver, created: Rpc): string | null {
133
+ if (!rx.tunnelled) {
134
+ return `no public https front (OPENWOP_WEBHOOK_RECEIVER_URL) is wired — a conforming host refuses the loopback destination ${rx.url} (RFC 0214 §B), so push delivery cannot be witnessed here${created.error ? ` (Create answered ${JSON.stringify(created.error)})` : ''}`;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ describe('RFC 0214 — v2-a2a-push-delivery (host POSTs A2A push to a suite-owned receiver)', () => {
140
+ it('§A/§C — a transition after registration is POSTed to the registered destination with the registered Authorization and no OpenWOP signature', async () => {
141
+ const id = R('a2a-push-delivery-authenticated');
142
+ const g = await gate();
143
+ if (!g.ok) return softSkip(g.kind, g.reason);
144
+ const { rx, hits } = await receiver();
145
+ try {
146
+ const taskId = await suspendedTask(g.url, id);
147
+ const cred = `owpush${randomBytes(12).toString('hex')}`;
148
+ const created = await create(g.url, taskId, rx.url, { authentication: { scheme: 'Bearer', credentials: cred } });
149
+ const why = unmeasurable(rx, created);
150
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return softSkip('blocked', why); }
151
+ expect(created.error, req(id, DOC, `Create on a suspended task with the public receiver MUST succeed (got ${JSON.stringify(created.error)})`)).toBeUndefined();
152
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
153
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
154
+ const arrived = await until(() => hits.length > 0, 20000);
155
+ if (!arrived && absenceIsUnmeasured(rx)) return softSkip('blocked', noDeliveryCause(rx, 'A2A push'));
156
+ expect(hits.length, req(id, `${DOC} §C ("at least one attempt")`, `a transition after registration MUST be POSTed at least once to the registered destination — ${noDeliveryCause(rx, 'A2A push')}`)).toBeGreaterThan(0);
157
+ const h = hits[0]!;
158
+ expect(h.method, req(id, 'A2A v1.0.1 §4.3.3', 'a push is an HTTP POST')).toBe('POST');
159
+ expect(header(h, 'authorization'), req(id, `${DOC} §A (A2A v1.0.1 §4.3.3 "MUST include authentication credentials")`, 'the push MUST carry the registered credential as Authorization: {scheme} {credentials}')).toBe(`Bearer ${cred}`);
160
+ expect((header(h, 'content-type') ?? '').split(';')[0]!.trim(), req(id, 'A2A v1.0.1 §4.3.3', 'the push body is application/a2a+json')).toBe('application/a2a+json');
161
+ const body = JSON.parse(h.body || '{}') as { statusUpdate?: { taskId?: string; status?: { state?: string } } };
162
+ expect(body.statusUpdate?.taskId, req(id, 'A2A v1.0.1 §4.3.3 (StreamResponse)', `the push body MUST be a StreamResponse statusUpdate naming the task (got ${h.body.slice(0, 200)})`)).toBe(taskId);
163
+ const sigId = R('a2a-push-no-openwop-signature');
164
+ for (const x of hits) {
165
+ expect(header(x, 'openwop-signature'), req(sigId, `${DOC} §C ("no OpenWOP signature")`, 'an A2A push MUST NOT carry an OpenWOP-Signature — A2A defines no secret exchange to verify it')).toBeUndefined();
166
+ }
167
+ } finally { await rx.close(); }
168
+ }, 60_000);
169
+
170
+ it('§B — a 3xx from the destination is a failed delivery: the redirect target receives nothing, credential or not', async () => {
171
+ const id = R('a2a-push-no-redirect');
172
+ const g = await gate();
173
+ if (!g.ok) return softSkip(g.kind, g.reason);
174
+ const target = await receiver();
175
+ const origin = await receiver((_hit, res) => { res.writeHead(307, { location: target.rx.url }); res.end(); });
176
+ try {
177
+ const taskId = await suspendedTask(g.url, id);
178
+ const created = await create(g.url, taskId, origin.rx.url, { authentication: { scheme: 'Bearer', credentials: `owredir${randomBytes(10).toString('hex')}` } });
179
+ const why = unmeasurable(origin.rx, created);
180
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return softSkip('blocked', why); }
181
+ expect(created.error, req(id, DOC, `Create MUST succeed (got ${JSON.stringify(created.error)})`)).toBeUndefined();
182
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
183
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
184
+ const arrived = await until(() => origin.hits.length > 0, 20000);
185
+ if (!arrived && absenceIsUnmeasured(origin.rx)) return softSkip('blocked', noDeliveryCause(origin.rx, 'A2A push'));
186
+ expect(origin.hits.length, req(id, DOC, `positive control: the registered destination MUST receive the push it then redirects — ${noDeliveryCause(origin.rx, 'A2A push')}`)).toBeGreaterThan(0);
187
+ await wait(4000); // any retry or followed redirect lands in this window
188
+ expect(target.hits.length, req(id, `${DOC} §B; webhooks.md §Egress ("refuse to follow redirects")`, 'the redirect target MUST receive nothing: a 3xx is a delivery failure and the credential MUST NOT be sent after a redirect')).toBe(0);
189
+ } finally { await origin.rx.close(); await target.rx.close(); }
190
+ }, 60_000);
191
+
192
+ it('§A — after Delete no further push reaches that destination, while a live config on the same task still receives', async () => {
193
+ const id = R('a2a-push-discard-on-delete');
194
+ const g = await gate();
195
+ if (!g.ok) return softSkip(g.kind, g.reason);
196
+ const kept = await receiver();
197
+ const dropped = await receiver();
198
+ try {
199
+ const taskId = await suspendedTask(g.url, id);
200
+ const a = await create(g.url, taskId, kept.rx.url, { authentication: { scheme: 'Bearer', credentials: `owkeep${randomBytes(10).toString('hex')}` } });
201
+ const why = unmeasurable(kept.rx, a);
202
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return softSkip('blocked', why); }
203
+ const b = await create(g.url, taskId, dropped.rx.url, { authentication: { scheme: 'Bearer', credentials: `owdrop${randomBytes(10).toString('hex')}` } });
204
+ const dropId = (b.result as { id?: string } | undefined)?.id;
205
+ expect(typeof dropId, req(id, DOC, `Create of the second config MUST succeed (got ${JSON.stringify(b.error ?? b.result)})`)).toBe('string');
206
+ const del = await rpc(g.url, 'DeleteTaskPushNotificationConfig', { taskId, id: dropId });
207
+ expect(del.error, req(id, DOC, `Delete of the caller's own config MUST succeed (got ${JSON.stringify(del.error)})`)).toBeUndefined();
208
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
209
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
210
+ const arrived = await until(() => kept.hits.length > 0, 20000);
211
+ if (!arrived && absenceIsUnmeasured(kept.rx)) return softSkip('blocked', noDeliveryCause(kept.rx, 'A2A push'));
212
+ expect(kept.hits.length, req(id, DOC, `positive control: the config that was NOT deleted MUST still receive the push — ${noDeliveryCause(kept.rx, 'A2A push')}`)).toBeGreaterThan(0);
213
+ await wait(3000);
214
+ expect(dropped.hits.length, req(id, `${DOC} §A ("dropped when the config is deleted")`, 'a deleted config MUST receive nothing further — its destination credential is discarded with it')).toBe(0);
215
+ } finally { await kept.rx.close(); await dropped.rx.close(); }
216
+ }, 60_000);
217
+
218
+ it('§D — a replay fork of a pushed run delivers nothing to the source run\'s destination', async () => {
219
+ const id = R('a2a-push-fork-no-push');
220
+ const g = await gate();
221
+ if (!g.ok) return softSkip(g.kind, g.reason);
222
+ const replay = await familyAdvertised('replay');
223
+ const modes = Array.isArray(replay?.['modes']) ? (replay!['modes'] as unknown[]) : [];
224
+ if (!modes.includes('replay')) return softSkip('inapplicable', 'replay (mode replay) is not advertised — no fork to measure');
225
+ const { rx, hits } = await receiver();
226
+ try {
227
+ const taskId = await suspendedTask(g.url, id);
228
+ const created = await create(g.url, taskId, rx.url, { authentication: { scheme: 'Bearer', credentials: `owfork${randomBytes(10).toString('hex')}` } });
229
+ const why = unmeasurable(rx, created);
230
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return softSkip('blocked', why); }
231
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
232
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
233
+ const arrived = await until(() => hits.length > 0, 20000);
234
+ if (!arrived && absenceIsUnmeasured(rx)) return softSkip('blocked', noDeliveryCause(rx, 'A2A push'));
235
+ expect(hits.length, req(id, DOC, `positive control: the source run MUST have pushed before the fork — ${noDeliveryCause(rx, 'A2A push')}`)).toBeGreaterThan(0);
236
+ await wait(3000);
237
+ const before = hits.length;
238
+ const fork = await driver.post(`/runs/${encodeURIComponent(taskId)}:fork`, { mode: 'replay' });
239
+ if (fork.status < 200 || fork.status >= 300) return softSkip('blocked', `replay is advertised but forkRun answered ${fork.status} ${JSON.stringify(fork.json)} — the fork leg is unmeasured`);
240
+ const child = (fork.json as { runId?: string } | undefined)?.runId;
241
+ if (typeof child === 'string') await settle(child, (s) => s !== undefined && TERMINAL.has(s), 15000);
242
+ await wait(4000);
243
+ expect(hits.length, req(id, `${DOC} §D; replay.md §Suppression ("MUST NOT deliver events a replay re-emits")`, `a replay fork MUST NOT push to the source run's destination (deliveries ${before} before the fork, ${hits.length} after)`)).toBe(before);
244
+ } finally { await rx.close(); }
245
+ }, 90_000);
246
+ });
@@ -44,6 +44,7 @@ const CATALOG = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json
44
44
  const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o)) as T;
45
45
  const POSITIVE = JSON.parse(readFileSync(join(FIXTURES_DIR, 'a2ui-v09', 'positive-approve-brief.json'), 'utf8')) as Json;
46
46
  const V1_SURFACE: Json = { catalogVersion: '0.9.1', surface: { title: 'Kickoff', components: [{ component: 'heading', text: 'Kickoff', level: 2 }, { component: 'action.button', id: 'go', label: 'Go', action: { target: 'resume' } }] } };
47
+ const V1_LATER: Json = { catalogVersion: '0.9.1', surface: { title: 'Kickoff, later', components: [{ component: 'heading', text: 'Kickoff, later', level: 2 }] } };
47
48
 
48
49
  let seq = 0;
49
50
  const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -217,8 +218,16 @@ describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-g
217
218
  const sequence = (r.json as { sequence: number }).sequence;
218
219
  const events = await eventsOf(runId);
219
220
  expect(carries(events, payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'the run\'s poll MUST return the recorded surface payload byte-equal — no surface is regenerated')).toBe(true);
220
- const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq: sequence + 1 });
221
- expect(fork.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', `POST :fork at fromSeq ${sequence + 1} MUST answer 201, got ${code(fork)}`)).toBe(201);
221
+ // The surface is the last event of the suspended run, so `sequence + 1`
222
+ // names no event and runs.md §Fork REQUIRES 422 fork_point_invalid for it.
223
+ // Record one more envelope on the same surface and fork AT that event:
224
+ // the surface (sequence < fromSeq) is fixed history, the later one re-executes.
225
+ const later = await emit(runId, envelope(2, v2(sid('rec'), [dataMsg()])));
226
+ expect(later.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.9', `an updateDataModel on the live surface MUST be admitted (it supplies the fork point), got ${code(later)}`)).toBe(201);
227
+ const fromSeq = (later.json as { sequence: number }).sequence;
228
+ expect(fromSeq > sequence, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'api/seams-v2.yaml emitA2uiSurface', `a later recording names a later sequence (surface ${sequence}, update ${fromSeq})`)).toBe(true);
229
+ const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq });
230
+ expect(fork.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', `POST :fork at fromSeq ${fromSeq} (a recorded event after the surface) MUST answer 201, got ${code(fork)}`)).toBe(201);
222
231
  const forkId = (fork.json as { runId: string }).runId;
223
232
  expect(carries(await eventsOf(forkId), payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'a fork at a fromSeq after the surface MUST carry it unchanged')).toBe(true);
224
233
  await done(forkId);
@@ -234,8 +243,13 @@ describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-g
234
243
  expect(r.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `a version-1 surface at floor 1 MUST be admitted, got ${code(r)}`)).toBe(201);
235
244
  const sequence = (r.json as { sequence: number }).sequence;
236
245
  expect(carries(await eventsOf(runId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'the poll MUST return the version-1 surface byte-equal')).toBe(true);
237
- const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq: sequence + 1 });
238
- expect(fork.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `POST :fork MUST answer 201, got ${code(fork)}`)).toBe(201);
246
+ // As in the version-2 leg: fork at a recorded event after the surface, never at `sequence + 1` (no such event; 422 fork_point_invalid).
247
+ const later = await emit(runId, envelope(1, V1_LATER));
248
+ expect(later.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `a second version-1 surface at floor 1 MUST be admitted (it supplies the fork point), got ${code(later)}`)).toBe(201);
249
+ const fromSeq = (later.json as { sequence: number }).sequence;
250
+ expect(fromSeq > sequence, req('openwop.requirement.0209.legacy-readable', 'api/seams-v2.yaml emitA2uiSurface', `a later recording names a later sequence (surface ${sequence}, next ${fromSeq})`)).toBe(true);
251
+ const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq });
252
+ expect(fork.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `POST :fork at fromSeq ${fromSeq} (a recorded event after the surface) MUST answer 201, got ${code(fork)}`)).toBe(201);
239
253
  const forkId = (fork.json as { runId: string }).runId;
240
254
  expect(carries(await eventsOf(forkId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'a fork at a later fromSeq MUST carry the version-1 surface unchanged')).toBe(true);
241
255
  await done(forkId);
@@ -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);
@@ -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();
@@ -0,0 +1,294 @@
1
+ {
2
+ "$comment": [
3
+ "RFC 0212 — cross-language vectors for canonical JSON (RFC 8785 JCS) with the OpenWOP I-JSON refusal set.",
4
+ "Every signed or hashed preimage in the corpus MUST reproduce `canonical` and `sha256` for each `objects` entry, `canonical` for each `numbers` entry, and MUST refuse each `refusals` entry with a refusal (the `refuse` kind is informative).",
5
+ "Generated by a reference implementation that parses JSON TEXT (so duplicates, integer magnitude and lone surrogates are visible before coercion) and cross-checked against RFC 8785 Appendix B and §3.2.2/§3.2.3."
6
+ ],
7
+ "spec": "RFC 8785; RFC 7493 (I-JSON); RFC 0212",
8
+ "hash": "sha256, lowercase hex, over the UTF-8 bytes of `canonical`",
9
+ "numbers": [
10
+ {
11
+ "ieee754": "0000000000000000",
12
+ "canonical": "0"
13
+ },
14
+ {
15
+ "ieee754": "8000000000000000",
16
+ "canonical": "0"
17
+ },
18
+ {
19
+ "ieee754": "0000000000000001",
20
+ "canonical": "5e-324"
21
+ },
22
+ {
23
+ "ieee754": "8000000000000001",
24
+ "canonical": "-5e-324"
25
+ },
26
+ {
27
+ "ieee754": "7fefffffffffffff",
28
+ "canonical": "1.7976931348623157e+308"
29
+ },
30
+ {
31
+ "ieee754": "ffefffffffffffff",
32
+ "canonical": "-1.7976931348623157e+308"
33
+ },
34
+ {
35
+ "ieee754": "4340000000000000",
36
+ "canonical": "9007199254740992"
37
+ },
38
+ {
39
+ "ieee754": "c340000000000000",
40
+ "canonical": "-9007199254740992"
41
+ },
42
+ {
43
+ "ieee754": "4430000000000000",
44
+ "canonical": "295147905179352830000"
45
+ },
46
+ {
47
+ "ieee754": "44b52d02c7e14af5",
48
+ "canonical": "9.999999999999997e+22"
49
+ },
50
+ {
51
+ "ieee754": "44b52d02c7e14af6",
52
+ "canonical": "1e+23"
53
+ },
54
+ {
55
+ "ieee754": "44b52d02c7e14af7",
56
+ "canonical": "1.0000000000000001e+23"
57
+ },
58
+ {
59
+ "ieee754": "444b1ae4d6e2ef4e",
60
+ "canonical": "999999999999999700000"
61
+ },
62
+ {
63
+ "ieee754": "444b1ae4d6e2ef4f",
64
+ "canonical": "999999999999999900000"
65
+ },
66
+ {
67
+ "ieee754": "444b1ae4d6e2ef50",
68
+ "canonical": "1e+21"
69
+ },
70
+ {
71
+ "ieee754": "3eb0c6f7a0b5ed8c",
72
+ "canonical": "9.999999999999997e-7"
73
+ },
74
+ {
75
+ "ieee754": "3eb0c6f7a0b5ed8d",
76
+ "canonical": "0.000001"
77
+ },
78
+ {
79
+ "ieee754": "41b3de4355555553",
80
+ "canonical": "333333333.3333332"
81
+ },
82
+ {
83
+ "ieee754": "41b3de4355555554",
84
+ "canonical": "333333333.33333325"
85
+ },
86
+ {
87
+ "ieee754": "41b3de4355555555",
88
+ "canonical": "333333333.3333333"
89
+ },
90
+ {
91
+ "ieee754": "41b3de4355555556",
92
+ "canonical": "333333333.3333334"
93
+ },
94
+ {
95
+ "ieee754": "41b3de4355555557",
96
+ "canonical": "333333333.33333343"
97
+ },
98
+ {
99
+ "ieee754": "becbf647612f3696",
100
+ "canonical": "-0.0000033333333333333333"
101
+ },
102
+ {
103
+ "ieee754": "43143ff3c1cb0959",
104
+ "canonical": "1424953923781206.2"
105
+ }
106
+ ],
107
+ "numberRefusals": [
108
+ {
109
+ "ieee754": "7fffffffffffffff",
110
+ "refuse": "non-finite"
111
+ },
112
+ {
113
+ "ieee754": "7ff0000000000000",
114
+ "refuse": "non-finite"
115
+ }
116
+ ],
117
+ "objects": [
118
+ {
119
+ "id": "rfc8785-3.2.2",
120
+ "why": "RFC 8785 §3.2.2 worked example: number shortest-round-trip, string escapes, member sort.",
121
+ "input": "{\"numbers\": [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001], \"string\": \"\\u20ac$\\u000F\\u000aA'\\u0042\\u0022\\u005c\\\\\\\"\\/\", \"literals\": [null, true, false]}",
122
+ "canonical": "{\"literals\":[null,true,false],\"numbers\":[333333333.3333333,1e+30,4.5,0.002,1e-27],\"string\":\"€$\\u000f\\nA'B\\\"\\\\\\\\\\\"/\"}",
123
+ "sha256": "2d5e01a318d0f0879ab568c4be289c8b1f64ef8921a53c6277d5e069978baacb"
124
+ },
125
+ {
126
+ "id": "rfc8785-3.2.3-sort",
127
+ "why": "RFC 8785 §3.2.3: members sorted by UTF-16 code units — CR, \"1\", U+0080, ö, €, 😀 (surrogate pair D83D), דּ (FB33). Locale collation gives a different order.",
128
+ "input": "{\"€\":\"Euro Sign\",\"\\r\":\"Carriage Return\",\"דּ\":\"Hebrew Letter Dalet With Dagesh\",\"1\":\"One\",\"😀\":\"Emoji: Grinning Face\",\"\\u0080\":\"Control\",\"ö\":\"Latin Small Letter O With Diaeresis\"}",
129
+ "canonical": "{\"\\r\":\"Carriage Return\",\"1\":\"One\",\"€\":\"Control\",\"ö\":\"Latin Small Letter O With Diaeresis\",\"€\":\"Euro Sign\",\"😀\":\"Emoji: Grinning Face\",\"דּ\":\"Hebrew Letter Dalet With Dagesh\"}",
130
+ "sha256": "5e321556d22018a9656991a9e94f77ec175fa193e52a2429d312f8419ec8b08c"
131
+ },
132
+ {
133
+ "id": "integer-like-keys",
134
+ "why": "Keys \"10\" and \"9\" sort by code unit (\"1\" < \"9\"). A canonicalizer that rebuilds a JS object and calls JSON.stringify emits {\"9\":…,\"10\":…} — the sabotage this vector exists for.",
135
+ "input": "{\"9\":\"nine\",\"10\":\"ten\",\"a\":0}",
136
+ "canonical": "{\"10\":\"ten\",\"9\":\"nine\",\"a\":0}",
137
+ "sha256": "ac5e0767681af2d226a2a4df567cee9cd3254a7819a7643f8bddbfa690792dad"
138
+ },
139
+ {
140
+ "id": "nested-sort",
141
+ "why": "Sorting applies at every level; arrays keep their order.",
142
+ "input": "{\"b\":[{\"z\":1,\"a\":2},{\"y\":[3,1,2]}],\"a\":{\"d\":null,\"c\":true}}",
143
+ "canonical": "{\"a\":{\"c\":true,\"d\":null},\"b\":[{\"a\":2,\"z\":1},{\"y\":[3,1,2]}]}",
144
+ "sha256": "fc8d1753fb837b89a49b6335715adb82c262e9a6bbfe7f4e7b8993e297951cca"
145
+ },
146
+ {
147
+ "id": "control-escapes",
148
+ "why": "U+0000–U+001F: short forms for \\b \\t \\n \\f \\r, lowercase \\u00xx otherwise; U+007F and U+2028 are emitted raw; \"/\" is not escaped.",
149
+ "input": "{\"s\":\"\\u0000\\u0007\\b\\t\\n\\u000b\\f\\r\\u001f\\u007f
/\"}",
150
+ "canonical": "{\"s\":\"\\u0000\\u0007\\b\\t\\n\\u000b\\f\\r\\u001f
/\"}",
151
+ "sha256": "993e6efcefb9f0bc08a2f896edce693cd6e80aa50ed6337ae0921b06a656c1a3"
152
+ },
153
+ {
154
+ "id": "non-ascii-raw",
155
+ "why": "Non-ASCII is emitted as raw UTF-8, never \\u-escaped (em dash, as in 625 registry pack strings).",
156
+ "input": "{\"title\":\"a — b\",\"emoji\":\"😀\"}",
157
+ "canonical": "{\"emoji\":\"😀\",\"title\":\"a — b\"}",
158
+ "sha256": "d2a26872e24a775a770515e000f1bb85a0949c08b40621292b7e70037006d02d"
159
+ },
160
+ {
161
+ "id": "nfc-composed",
162
+ "why": "U+00E9 (composed). MUST NOT hash equal to the decomposed vector: JCS applies no Unicode normalization.",
163
+ "input": "{\"name\":\"café\"}",
164
+ "canonical": "{\"name\":\"café\"}",
165
+ "sha256": "645fa443126a8954fc6d871912b8fc67bc2ee8feae417efe55546251962ca74d"
166
+ },
167
+ {
168
+ "id": "nfd-decomposed",
169
+ "why": "U+0065 U+0301 (decomposed). Pair with nfc-composed.",
170
+ "input": "{\"name\":\"café\"}",
171
+ "canonical": "{\"name\":\"café\"}",
172
+ "sha256": "4b7186ff40a64bfae3cb4bd75ac13b0de5ee693190f20812aaff208a77560ca7"
173
+ },
174
+ {
175
+ "id": "numbers-in-document",
176
+ "why": "Numbers inside a document: -0 → 0, 1.0 → 1, 1e21 → 1e+21, 1e-7 → 1e-7, 0.1+0.2 shortest form, 2^53−1 accepted.",
177
+ "input": "{\"n\":[-0,1.0,1e21,1e-7,0.30000000000000004,9007199254740991,-9007199254740991]}",
178
+ "canonical": "{\"n\":[0,1,1e+21,1e-7,0.30000000000000004,9007199254740991,-9007199254740991]}",
179
+ "sha256": "430e3fefe48227fd5ed7df3ba8b5a6da008c2077858a44bd265d4c7812c498c1"
180
+ },
181
+ {
182
+ "id": "empty-containers",
183
+ "why": "Empty object and array.",
184
+ "input": "{\"a\":{},\"b\":[]}",
185
+ "canonical": "{\"a\":{},\"b\":[]}",
186
+ "sha256": "aeeba1e56a144077f89e26c930dad7cf933e9383cfb8e33ddff618233b29be76"
187
+ }
188
+ ],
189
+ "refusals": [
190
+ {
191
+ "id": "duplicate-name",
192
+ "why": "I-JSON §2.3: duplicate member names.",
193
+ "input": "{\"a\":1,\"a\":2}",
194
+ "refuse": "duplicate-name"
195
+ },
196
+ {
197
+ "id": "lone-high-surrogate",
198
+ "why": "I-JSON §2.1: a lone surrogate is not a Unicode scalar value.",
199
+ "input": "{\"s\":\"\\ud800\"}",
200
+ "refuse": "lone-surrogate"
201
+ },
202
+ {
203
+ "id": "lone-low-surrogate",
204
+ "why": "I-JSON §2.1.",
205
+ "input": "{\"s\":\"x\\udc00\"}",
206
+ "refuse": "lone-surrogate"
207
+ },
208
+ {
209
+ "id": "integer-above-range",
210
+ "why": "Integer literal 2^53+1 exceeds ±(2^53−1) (I-JSON §2.2); a double cannot hold it exactly and a Python verifier would keep it exact — the two canonical forms differ.",
211
+ "input": "{\"n\":9007199254740993}",
212
+ "refuse": "integer-out-of-range"
213
+ },
214
+ {
215
+ "id": "integer-below-range",
216
+ "why": "Integer literal −2^53.",
217
+ "input": "{\"n\":-9007199254740992}",
218
+ "refuse": "integer-out-of-range"
219
+ },
220
+ {
221
+ "id": "nan-token",
222
+ "why": "NaN is not JSON. A JS canonicalizer given an in-memory NaN MUST refuse, not emit null.",
223
+ "input": "{\"n\":NaN}",
224
+ "refuse": "not-json"
225
+ },
226
+ {
227
+ "id": "infinity-overflow",
228
+ "why": "1e400 parses to Infinity: non-finite.",
229
+ "input": "{\"n\":1e400}",
230
+ "refuse": "non-finite"
231
+ }
232
+ ],
233
+ "ordering": {
234
+ "why": "witnessSha256 rows[] and RFC 0150 tools[] sort by UTF-16 code units of the key. Locale collation disagrees in both listed locales; a verifier using either computes a different digest.",
235
+ "ids": [
236
+ "d",
237
+ "h",
238
+ "ch",
239
+ "i",
240
+ "chain-pin-exact",
241
+ "h2",
242
+ "get_weather",
243
+ "getWeather",
244
+ "A",
245
+ "a",
246
+ "a-b",
247
+ "ab"
248
+ ],
249
+ "codeUnit": [
250
+ "A",
251
+ "a",
252
+ "a-b",
253
+ "ab",
254
+ "ch",
255
+ "chain-pin-exact",
256
+ "d",
257
+ "getWeather",
258
+ "get_weather",
259
+ "h",
260
+ "h2",
261
+ "i"
262
+ ],
263
+ "localeCs": [
264
+ "a",
265
+ "A",
266
+ "a-b",
267
+ "ab",
268
+ "d",
269
+ "get_weather",
270
+ "getWeather",
271
+ "h",
272
+ "h2",
273
+ "ch",
274
+ "chain-pin-exact",
275
+ "i"
276
+ ],
277
+ "localeEn": [
278
+ "a",
279
+ "A",
280
+ "a-b",
281
+ "ab",
282
+ "ch",
283
+ "chain-pin-exact",
284
+ "d",
285
+ "get_weather",
286
+ "getWeather",
287
+ "h",
288
+ "h2",
289
+ "i"
290
+ ],
291
+ "differsFromCs": true,
292
+ "differsFromEn": true
293
+ }
294
+ }