@openwop/openwop-conformance 2.37.1 → 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.
@@ -10,30 +10,20 @@
10
10
  * RFC 0041 §E SECURITY-invariant probe (intra-host reproducibility +
11
11
  * non-recipe-field invariance + Phase 4 advertisement alignment).
12
12
  *
13
- * `canonicalize` mirrors RFC 8785 JCS-style output (sorted keys, no
14
- * whitespace, preserved array order). Hosts that have a real JCS library
15
- * available SHOULD prefer it; this helper is for the conformance side,
16
- * not the host side. Keep in sync with `spec/v1/replay.md` §B.
13
+ * `canonicalize` is RFC 8785 JCS with the RFC 0212 I-JSON refusal set (the
14
+ * suite's one implementation, `./jcs.ts`), and `tools[]` sorts by UTF-16 code
15
+ * units — never `localeCompare`, which orders `get_weather` / `getWeather`
16
+ * differently per locale and so breaks the TS/Python/Go agreement RFC 0150 §C
17
+ * asks for. Keep in sync with `spec/v1/replay.md` §B.
17
18
  */
18
19
 
19
20
  import { createHash } from 'node:crypto';
20
21
  import { driver } from './driver.js';
22
+ import { canonicalJSON, codeUnitCompare } from './jcs.js';
21
23
 
22
- /** RFC 8785 JCS-style canonicalization (subset suitable for the recipe
23
- * fields). Sorted keys recursively; no whitespace; preserved array order;
24
- * strings JSON-encoded verbatim (no NFC normalization — the recipe
25
- * inputs in our test seam are ASCII). */
24
+ /** RFC 8785 JCS (RFC 0212). Throws `JcsRefusal` on a non-I-JSON value. */
26
25
  export function canonicalize(value: unknown): string {
27
- if (value === null) return 'null';
28
- if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
29
- if (typeof value === 'string') return JSON.stringify(value);
30
- if (Array.isArray(value)) return '[' + value.map((v) => canonicalize(v)).join(',') + ']';
31
- if (typeof value === 'object') {
32
- const obj = value as Record<string, unknown>;
33
- const keys = Object.keys(obj).sort();
34
- return '{' + keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(',') + '}';
35
- }
36
- return JSON.stringify(value);
26
+ return canonicalJSON(value);
37
27
  }
38
28
 
39
29
  /** @deprecated RETIRED v1 projection (pre RFC 0150 §C). Kept ONLY so a
@@ -44,7 +34,7 @@ export function canonicalize(value: unknown): string {
44
34
  export function projectRecipe(raw: Record<string, unknown>): Record<string, unknown> {
45
35
  const out: Record<string, unknown> = { provider: raw.provider, model: raw.model, messages: raw.messages };
46
36
  if (Array.isArray(raw.tools) && raw.tools.length > 0) {
47
- out.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name.localeCompare(b.name));
37
+ out.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => codeUnitCompare(a.name, b.name));
48
38
  }
49
39
  if (typeof raw.temperature === 'number') out.temperature = raw.temperature;
50
40
  if (typeof raw.topP === 'number') out.topP = raw.topP;
@@ -76,7 +66,7 @@ export const SEMANTIC_REQUEST_RECIPE_V2 = 'openwop-semantic-request-v2';
76
66
  export function projectSemanticRequestV2(raw: Record<string, unknown>): Record<string, unknown> {
77
67
  const request: Record<string, unknown> = { messages: raw.messages };
78
68
  if (Array.isArray(raw.tools) && raw.tools.length > 0) {
79
- request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name.localeCompare(b.name));
69
+ request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => codeUnitCompare(a.name, b.name));
80
70
  }
81
71
  for (const k of ['temperature', 'topP', 'topK', 'maxOutputTokens', 'seed'] as const) {
82
72
  if (typeof raw[k] === 'number') request[k] = raw[k];
@@ -49,7 +49,7 @@
49
49
 
50
50
  import { randomBytes } from 'node:crypto';
51
51
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
52
- import { FRONT_MUX_PREFIX, registerBehindFront, routeFronted, unregisterBehindFront } from './front-mux.js';
52
+ import { FRONT_MUX_PREFIX, registerBehindFront, routeFronted, unregisterBehindFront, withoutFrontPath } from './front-mux.js';
53
53
  import { receiverBinding, resolvePublicFront } from './webhook-receiver.js';
54
54
 
55
55
  /** The operator's public front for the suite's webhook receiver. */
@@ -122,7 +122,7 @@ export async function startScopedReceiver(
122
122
  // Counted BEFORE routing: `routeFronted` answers an unknown nonce itself and
123
123
  // cannot report that it did, and a request that is not this exercise's must
124
124
  // still be visible in the failure detail.
125
- if (!(request.url ?? '/').startsWith(`${FRONT_MUX_PREFIX}${nonce}`)) foreign += 1;
125
+ if (!withoutFrontPath(WEBHOOK_FRONT_ENV, request.url ?? '/').startsWith(`${FRONT_MUX_PREFIX}${nonce}`)) foreign += 1;
126
126
  if (routeFronted(WEBHOOK_FRONT_ENV, request, res)) return;
127
127
  // Not an `/fx/` path at all: a stranger, or a host that dropped the path it
128
128
  // was given. Answered, never recorded — a receiver that counts what it was
@@ -0,0 +1,108 @@
1
+ /**
2
+ * RFC 0212 — canonical JSON is RFC 8785 (JCS), and the input MUST be I-JSON.
3
+ *
4
+ * `conformance/vectors/jcs-v1.json` is normative: every signature and digest in
5
+ * the corpus (pack signatures, the certification-bundle attestation,
6
+ * `discovery.sha256`, `witnessSha256`, the RFC 0150 request digest) is over
7
+ * these bytes, and an SDK in another language reproduces the same file. This
8
+ * scenario holds the suite's one implementation (`lib/jcs.ts`) to it.
9
+ *
10
+ * Every leg RECOMPUTES from the vector input; none compares two constants from
11
+ * the file. The sabotages RFC 0212 names, each of which turns a leg red:
12
+ *
13
+ * - a rebuild-object canonicalizer (`JSON.stringify` of an object whose keys
14
+ * were inserted in sorted order) — integer-like keys enumerate first, so
15
+ * `integer-like-keys` fails;
16
+ * - NaN → `null` coercion — the value-boundary refusal leg fails;
17
+ * - a parser that keeps the last duplicate name (`JSON.parse`) — the
18
+ * `duplicate-name` refusal is accepted;
19
+ * - a `localeCompare` comparator — the ordering leg and the
20
+ * `rfc8785-3.2.3-sort` vector fail.
21
+ *
22
+ * Server-free and always-on; the vectors ship with the conformance package.
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { createHash } from 'node:crypto';
27
+ import { readFileSync } from 'node:fs';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { dirname, resolve } from 'node:path';
30
+ import { canonicalJSON, canonicalizeText, codeUnitCompare, JcsRefusal } from '../lib/jcs.js';
31
+ import { witnessDigest, type BundleV3Requirement } from '../lib/certification-bundle-v3.js';
32
+ import { req } from '../lib/requirement-ids.js';
33
+
34
+ interface NumberVector { readonly ieee754: string; readonly canonical: string }
35
+ interface NumberRefusal { readonly ieee754: string; readonly refuse: string }
36
+ interface ObjectVector { readonly id: string; readonly why: string; readonly input: string; readonly canonical: string; readonly sha256: string }
37
+ interface RefusalVector { readonly id: string; readonly why: string; readonly input: string; readonly refuse: string }
38
+ interface Ordering { readonly why: string; readonly ids: readonly string[]; readonly codeUnit: readonly string[] }
39
+
40
+ const VECTORS_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'vectors', 'jcs-v1.json');
41
+ const doc = JSON.parse(readFileSync(VECTORS_PATH, 'utf8')) as {
42
+ numbers: readonly NumberVector[];
43
+ numberRefusals: readonly NumberRefusal[];
44
+ objects: readonly ObjectVector[];
45
+ refusals: readonly RefusalVector[];
46
+ ordering: Ordering;
47
+ };
48
+
49
+ const SPEC = 'RFC 0212 · spec/v2/core/conformance.md §"Canonical JSON"';
50
+ const sha256 = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex');
51
+ const double = (hex: string): number => Buffer.from(hex, 'hex').readDoubleBE(0);
52
+
53
+ function refusalOf(fn: () => unknown): JcsRefusal | undefined {
54
+ try { fn(); } catch (e) { if (e instanceof JcsRefusal) return e; throw e; }
55
+ return undefined;
56
+ }
57
+
58
+ describe('RFC 0212 — canonical JSON is RFC 8785 JCS over I-JSON', () => {
59
+ it('the vector set is present and non-trivial', () => {
60
+ // An empty or truncated file would make every leg below vacuous.
61
+ expect(doc.numbers.length, req('openwop.it.jcs-vectors.the-vector-set-is-present-and-non-trivial', SPEC, 'the vectors MUST cover RFC 8785 Appendix B numbers, documents and every §B refusal kind')).toBeGreaterThanOrEqual(20);
62
+ expect(doc.objects.length).toBeGreaterThanOrEqual(10);
63
+ expect(new Set(doc.refusals.map((r) => r.refuse))).toEqual(new Set(['duplicate-name', 'lone-surrogate', 'integer-out-of-range', 'not-json', 'non-finite']));
64
+ });
65
+
66
+ it.each(doc.numbers.map((n) => [n.ieee754, n] as const))('number %s serializes per RFC 8785 Appendix B', (_hex, n) => {
67
+ expect(canonicalJSON(double(n.ieee754)), req('openwop.it.jcs-vectors.number-s-serializes-per-rfc-8785-appendix-b', SPEC, 'numbers MUST serialize as ECMAScript Number::toString (RFC 8785 §3.2.2.3)')).toBe(n.canonical);
68
+ });
69
+
70
+ it.each(doc.objects.map((o) => [o.id, o] as const))('document %s canonicalizes and hashes', (_id, o) => {
71
+ // The preimage is asserted as well as the hash so a failing implementation
72
+ // can diff WHICH member it got wrong.
73
+ const canonical = canonicalizeText(o.input);
74
+ expect(canonical, req('openwop.it.jcs-vectors.document-s-canonicalizes-and-hashes', SPEC, o.why)).toBe(o.canonical);
75
+ expect(sha256(canonical), req('openwop.it.jcs-vectors.document-s-canonicalizes-and-hashes', SPEC, o.why)).toBe(o.sha256);
76
+ });
77
+
78
+ it.each(doc.refusals.map((r) => [r.id, r] as const))('text %s is refused, not coerced', (_id, r) => {
79
+ const refusal = refusalOf(() => canonicalizeText(r.input));
80
+ expect(refusal?.kind, req('openwop.it.jcs-vectors.text-s-is-refused-not-coerced', SPEC, `RFC 0212 §B: ${r.why}`)).toBe(r.refuse);
81
+ });
82
+
83
+ it('a non-I-JSON native value is refused at the value boundary', () => {
84
+ const cases: readonly [string, unknown][] = [
85
+ ...doc.numberRefusals.map((n) => [`ieee754 ${n.ieee754}`, { n: double(n.ieee754) }] as [string, unknown]),
86
+ ['NaN', { n: Number.NaN }],
87
+ ['undefined member', { a: undefined }],
88
+ ['undefined element', [undefined]],
89
+ ['Date', { at: new Date(0) }],
90
+ ['bigint', { n: 1n }],
91
+ ['lone surrogate', { s: '\ud800' }],
92
+ ['lone surrogate in a name', { '\udc00': 1 }],
93
+ ];
94
+ for (const [label, value] of cases) {
95
+ expect(refusalOf(() => canonicalJSON(value)), req('openwop.it.jcs-vectors.a-non-i-json-native-value-is-refused-at-the-value-boundary', SPEC, `RFC 0212 §B: ${label} MUST be refused, not serialized`)).toBeInstanceOf(JcsRefusal);
96
+ }
97
+ });
98
+
99
+ it('member names and certification rows sort by UTF-16 code units, not by locale', () => {
100
+ const o = doc.ordering;
101
+ expect([...o.ids].sort(codeUnitCompare), req('openwop.it.jcs-vectors.member-names-and-certification-rows-sort-by-utf-16-code-units-not-by-locale', SPEC, o.why)).toEqual([...o.codeUnit]);
102
+ // The certification digest uses the same comparator (RFC 0212 §C): rows
103
+ // given in vector order digest to the JCS of the code-unit-sorted rows.
104
+ const rows: BundleV3Requirement[] = o.ids.map((id) => ({ id, scenario: 's.test.ts', result: 'executed-pass' }) as BundleV3Requirement);
105
+ const expected = sha256(canonicalJSON([...o.codeUnit].map((id) => ({ id, scenario: 's.test.ts', result: 'executed-pass' }))));
106
+ expect(witnessDigest(rows), req('openwop.it.jcs-vectors.member-names-and-certification-rows-sort-by-utf-16-code-units-not-by-locale', SPEC, 'witnessSha256 rows MUST sort by id in UTF-16 code-unit order; a locale-sensitive comparator MUST NOT be used')).toBe(expected);
107
+ });
108
+ });
@@ -110,6 +110,14 @@ describe('RFC 0150 §C — semantic request digest golden vectors', () => {
110
110
  expect(digest('tools-sorted-by-name'), req('openwop.it.semantic-digest-vectors.tool-order-is-not-semantic-sorted-and-reversed-agree', 'RFC 0150 §C', 'tool order is not semantic — sorted and reversed agree')).toBe(digest('tools-reversed-same-digest'));
111
111
  });
112
112
 
113
+ it('tool order uses UTF-16 code units, where a locale comparator disagrees', () => {
114
+ // RFC 0212 §D. `alpha`/`zeta` order the same under every comparator, so the
115
+ // pair above cannot catch a `localeCompare` sort; `getWeather`/`get_weather`
116
+ // can — English collation ignores `_` and puts them the other way round.
117
+ expect(digest('tools-code-unit-order'), req('openwop.it.semantic-digest-vectors.tool-order-uses-utf-16-code-units-where-a-locale-comparator-disagrees', 'RFC 0212 §D · RFC 0150 §C', 'tools[] MUST sort by name in UTF-16 code-unit order')).toBe(digest('tools-code-unit-order-reversed'));
118
+ expect(canonicalize(projectSemanticRequestV2(byId.get('tools-code-unit-order')?.input ?? {})), req('openwop.it.semantic-digest-vectors.tool-order-uses-utf-16-code-units-where-a-locale-comparator-disagrees', 'RFC 0212 §D · RFC 0150 §C', 'getWeather (0x57) MUST precede get_weather (0x5F)')).toContain('"tools":[{"name":"getWeather"');
119
+ });
120
+
113
121
  it('message order IS semantic — reversing changes the digest', () => {
114
122
  expect(
115
123
  digest('message-order-is-semantic'),
@@ -25,6 +25,7 @@
25
25
  * @see spec/v2/interop-map.json a2a.operations / a2a.taskState / a2a.errors
26
26
  * @see RFCS/0208-v2-a2a-mcp-operation-mappings.md §C, §D, §E
27
27
  * @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §D.1 (the credential leg; interop-map.json a2a.taskState override row)
28
+ * @see RFCS/0214-a2a-push-credential-is-a-destination-credential.md (the push-config refusal leg; invariants a2a-push-credential-destination-bound, a2a-push-secrets-not-returned)
28
29
  * @see RFCS/0211-a2a-error-details-are-errorinfo.md §A–§E (the error-details legs; the isolation comparator)
29
30
  */
30
31
  import { describe, it, expect } from 'vitest';
@@ -249,6 +250,33 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
249
250
  await rpc(t.url, 'CancelTask', { id });
250
251
  });
251
252
 
253
+ it('a host not advertising a2a.pushNotifications refuses all four push-config operations with PushNotificationNotSupportedError', async () => {
254
+ const rid = 'openwop.requirement.0214.a2a-push-unadvertised-refused';
255
+ const pushDoc = 'interop-map.json a2a.errors PushNotificationNotSupportedError (serverWhen: push config without a2a.pushNotifications) and the push-config operation rows; A2A v1.0.1 §3.3.4';
256
+ const t = await target(true);
257
+ if (!t.ok) return skip(t, rid);
258
+ const a2a = (await familyAdvertised('a2a'))!;
259
+ if (a2a['pushNotifications'] === true) return softSkip('inapplicable', 'a2a.pushNotifications is advertised — the push-config rows are served here, not refused');
260
+ const first = await startApprovalTask(t.url);
261
+ const id = first.task?.id;
262
+ expect(typeof id, req(rid, DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
263
+ // Positive control: the task is readable, so a -32001 below could not be masking the refusal under test.
264
+ const read = await rpc(t.url, 'GetTask', { id });
265
+ expect(read.error, req(rid, 'a2a.operations GetTask', `positive control: the task the suite just started MUST be readable (got ${JSON.stringify(read.error)})`)).toBeUndefined();
266
+ const configId = `conf-0214-${randomBytes(6).toString('hex')}`;
267
+ const calls: Array<[string, Record<string, unknown>]> = [
268
+ ['CreateTaskPushNotificationConfig', { taskId: id, url: 'https://push.example.com/openwop-conformance' }],
269
+ ['GetTaskPushNotificationConfig', { taskId: id, id: configId }],
270
+ ['ListTaskPushNotificationConfigs', { taskId: id }],
271
+ ['DeleteTaskPushNotificationConfig', { taskId: id, id: configId }],
272
+ ];
273
+ for (const [method, params] of calls) {
274
+ const r = await rpc(t.url, method, params);
275
+ expect(r.error?.code, req(rid, pushDoc, `${method} on a host not advertising a2a.pushNotifications MUST be refused PushNotificationNotSupportedError -32003 — not method-not-found -32601 (got ${JSON.stringify(r.error ?? r.result)})`)).toBe(-32003);
276
+ }
277
+ await rpc(t.url, 'CancelTask', { id });
278
+ });
279
+
252
280
  it('ListTasks returns only what listRuns returns to the same Subject; tenant and contextId never select', async () => {
253
281
  const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
254
282
  if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller leg cannot run');
@@ -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
+ }