@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.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -3
- package/dist/cli.js +8 -18
- package/dist/lib/certification-bundle-v3.js +46 -18
- package/dist/lib/jcs.js +274 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +457 -33
- package/scenario-majors.json +10 -3
- package/schemas/CORPUS-STAMP.json +29 -29
- package/src/cli.ts +8 -16
- package/src/lib/certification-bundle-v3.ts +42 -17
- package/src/lib/front-mux.ts +36 -1
- package/src/lib/jcs.ts +229 -0
- package/src/lib/llm-cache-key-recipe.ts +10 -20
- package/src/lib/scoped-receiver.ts +2 -2
- package/src/scenarios/jcs-vectors.test.ts +108 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +130 -1
- package/src/scenarios/v2-a2a-push-delivery.test.ts +246 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
- package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
- package/vectors/jcs-v1.json +294 -0
- package/vectors/semantic-request-digest-v2.json +52 -0
|
@@ -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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|