@openwop/openwop-conformance 2.36.1 → 2.37.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.
@@ -19,6 +19,7 @@
19
19
  import {
20
20
  createSign,
21
21
  generateKeyPairSync,
22
+ randomBytes,
22
23
  type KeyObject,
23
24
  } from 'node:crypto';
24
25
 
@@ -166,10 +167,22 @@ export function createSyntheticOIDCIssuer(
166
167
  throw new Error('[oidc-issuer] issuer and audience are required');
167
168
  }
168
169
 
170
+ // The kid is unique PER INSTANCE, not just per rotation (2.37.0). Every
171
+ // instance generates a fresh key, and two scenarios can publish at the same
172
+ // OPENWOP_TEST_OIDC_ISSUER_URL (v2-oidc-id-token-audience and
173
+ // v2-lane-exp-only-bound both do). With a per-instance counter alone every
174
+ // instance minted `openwop-conformance-key-1`, so a host that caches JWKS by
175
+ // kid — RFC 7517 §4.5 says a kid identifies ONE key — verified the second
176
+ // scenario's tokens against the first scenario's key and rejected its VALID
177
+ // control as `invalid_signature`. Measured on openwop-app (suite 2.36.1):
178
+ // 4/4 red in a full run, 4/4 green filtered. A new kid over a new key lets a
179
+ // conformant host see an unknown kid and re-fetch, as it must.
180
+ const instance = randomBytes(6).toString('hex');
181
+ const kidFor = (n: number): string => `openwop-conformance-key-${instance}-${n}`;
169
182
  let rotationCounter = 1;
170
183
  let material = generateKeyMaterial(
171
184
  algorithm,
172
- opts.keyId ?? `openwop-conformance-key-${rotationCounter}`,
185
+ opts.keyId ?? kidFor(rotationCounter),
173
186
  );
174
187
 
175
188
  return {
@@ -232,10 +245,7 @@ export function createSyntheticOIDCIssuer(
232
245
 
233
246
  rotateKey(): void {
234
247
  rotationCounter += 1;
235
- material = generateKeyMaterial(
236
- algorithm,
237
- `openwop-conformance-key-${rotationCounter}`,
238
- );
248
+ material = generateKeyMaterial(algorithm, kidFor(rotationCounter));
239
249
  },
240
250
  };
241
251
  }
@@ -15,7 +15,7 @@
15
15
  * - a legacy-only peer serves the 0.3 card (so the shape follows the revision,
16
16
  * not the calendar);
17
17
  * - `A2A-Version` absent ⇒ 0.3 semantics; unsupported ⇒ `-32009`
18
- * `VERSION_NOT_SUPPORTED` with `supportedVersions[]`; a 1.0-only peer
18
+ * `VERSION_NOT_SUPPORTED` with the supported versions (in `error.data`, an array of `Any` per A2A 1.0.1 §9.5: `google.rpc.ErrorInfo` `metadata.supportedVersions`, comma-joined); a 1.0-only peer
19
19
  * rejects header-less requests;
20
20
  * - `SendMessage` returns `{ task }` (the `SendMessageResponse` oneof) with
21
21
  * `TASK_STATE_*`, `ROLE_*`, `Part` as a `oneof` (no `kind`), `status.timestamp`;
@@ -42,10 +42,20 @@ async function rpc(endpoint: string, method: string, params: unknown, version?:
42
42
  const headers: Record<string, string> = { 'content-type': 'application/json' };
43
43
  if (version !== undefined) headers['A2A-Version'] = version;
44
44
  const res = await fetch(`${endpoint}/a2a/jsonrpc`, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
45
- const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?: Record<string, unknown> } };
45
+ const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?: unknown } };
46
46
  return { status: res.status, ...body };
47
47
  }
48
48
 
49
+ /**
50
+ * A2A 1.0.1 §9.5: `error.data` is an array of ProtoJSON `Any`; the error type is the
51
+ * `google.rpc.ErrorInfo` element's `reason` (§10.6/§11.6). Returns that element, or
52
+ * undefined when `data` is not such an array (so a bare-object `data` fails the leg).
53
+ */
54
+ const errorInfo = (e: { data?: unknown } | undefined): { reason?: string; domain?: string; metadata?: Record<string, string> } | undefined => {
55
+ if (!Array.isArray(e?.data)) return undefined;
56
+ return (e!.data as Array<Record<string, unknown>>).find((d) => d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo') as { reason?: string; domain?: string; metadata?: Record<string, string> } | undefined;
57
+ };
58
+
49
59
  describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', () => {
50
60
  // Explicit 1.0-first so a header-less GET returns the 1.0 card (the default
51
61
  // is 0.3-first for today's 0.3 clients; both eras are spoken either way).
@@ -115,8 +125,10 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
115
125
  const r = await rpc(peer.endpoint(), 'SendMessage', { message: { messageId: 'm2', role: 'ROLE_USER', parts: [{ text: 'hi' }] } }, '99.0');
116
126
  expect(r.status, req('openwop.it.a2a-1-0-agent-card.an-unsupported-version-fails-32009-version-not-supported-with-supportedversions', 'RFC 0152 §A/§B/§C/§D', 'an unsupported version fails -32009 VERSION_NOT_SUPPORTED with supportedVersions[] (HTTP 400)')).toBe(400);
117
127
  expect(r.error?.code).toBe(-32009);
118
- expect(r.error?.data?.['reason']).toBe('VERSION_NOT_SUPPORTED');
119
- expect(r.error?.data?.['supportedVersions']).toEqual(['1.0', '0.3']); // constructor order
128
+ expect(Array.isArray(r.error?.data), req('openwop.it.a2a-1-0-agent-card.an-unsupported-version-fails-32009-version-not-supported-with-supportedversions', 'RFC 0152 §A/§B/§C/§D; A2A 1.0.1 §9.5', 'error.data is an array of Any carrying google.rpc.ErrorInfo')).toBe(true);
129
+ expect(errorInfo(r.error)?.reason).toBe('VERSION_NOT_SUPPORTED');
130
+ expect(errorInfo(r.error)?.domain).toBe('a2a-protocol.org');
131
+ expect(errorInfo(r.error)?.metadata?.['supportedVersions']).toBe('1.0,0.3'); // constructor order; ErrorInfo.metadata is map<string,string>
120
132
  });
121
133
 
122
134
  it('a 1.0-only peer rejects a header-less request (which is 0.3 by rule)', async () => {
@@ -125,7 +137,7 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
125
137
  try {
126
138
  const r = await rpc(only10.endpoint(), 'message/send', { message: { parts: [] } });
127
139
  expect(r.error?.code, req('openwop.it.a2a-1-0-agent-card.a-1-0-only-peer-rejects-a-header-less-request-which-is-0-3-by-rule', 'RFC 0152 §A/§B/§C/§D', 'a 1.0-only peer rejects a header-less request (which is 0.3 by rule)')).toBe(-32009);
128
- expect(r.error?.data?.['requested']).toBe('0.3');
140
+ expect(errorInfo(r.error)?.metadata?.['requested']).toBe('0.3');
129
141
  } finally {
130
142
  await only10.stop();
131
143
  }
@@ -161,10 +173,10 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
161
173
  expect((cancelled.result as { status: { state: string } }).status.state).toBe('TASK_STATE_CANCELED');
162
174
  const again = await rpc(peer.endpoint(), 'CancelTask', { id }, '1.0');
163
175
  expect(again.error?.code).toBe(-32002);
164
- expect(again.error?.data?.['reason']).toBe('TASK_NOT_CANCELABLE');
176
+ expect(errorInfo(again.error)?.reason).toBe('TASK_NOT_CANCELABLE');
165
177
  const missing = await rpc(peer.endpoint(), 'GetTask', { id: 'nope' }, '1.0');
166
178
  expect(missing.error?.code).toBe(-32001);
167
- expect(missing.error?.data?.['reason']).toBe('TASK_NOT_FOUND');
179
+ expect(errorInfo(missing.error)?.reason).toBe('TASK_NOT_FOUND');
168
180
  });
169
181
 
170
182
  it('a 0.3 method name under a 1.0 header is method-not-found — loudly', async () => {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * RFC 0211 §F — the host as an A2A 1.0 client reads a peer's error in either shape.
3
+ * Target major 2; gated on the v2 `a2a` record and the seams profile.
4
+ *
5
+ * A2A v1.0.1 §9.5 puts a JSON-RPC error's details in `error.data` as an ARRAY of
6
+ * `Any` objects; the suite's fake peer and the reference host shipped a bare
7
+ * `{ reason, domain }` object through 2.36.x. A host's A2A client MUST accept the
8
+ * array and SHOULD accept the object for the rest of 2.x
9
+ * (`spec/v2/core/interop.md` §"The operation mappings", A2A error details).
10
+ *
11
+ * The suite drives the host's client at its own 1.0-only fake peer through the
12
+ * §22 invoke seam with `requestVersion: '99.0'`, so the peer answers
13
+ * `VersionNotSupportedError` (-32009) — once with `data` as `Any[]`, once as the
14
+ * legacy object — and the host MUST project both to `interop_version_unsupported`.
15
+ *
16
+ * Weak by construction, and said so: a host that reads only one shape still
17
+ * identifies the error by its JSON-RPC code, so this leg witnesses that the client
18
+ * tolerates both shapes (does not throw, does not mis-project to `internal_error`),
19
+ * not that it reads `reason` out of the array. `interop_version_unsupported` has
20
+ * `details: null` in `spec/v2/errors.json`, so no projected `supportedVersions` is
21
+ * asserted.
22
+ *
23
+ * Callback-shaped (the host calls the suite's peer): unwitnessable when the host is
24
+ * in a separate network namespace — `../lib/host-callback.ts`.
25
+ *
26
+ * @see RFCS/0211-a2a-error-details-are-errorinfo.md §F
27
+ * @see spec/v2/interop-map.json a2a.errors VersionNotSupportedError (clientProjection)
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { driver } from '../lib/driver.js';
32
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
33
+ import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
34
+ import { A2AFakePeer } from '../lib/a2a-fake-peer.js';
35
+ import { readErrorCode } from '../lib/error-envelope.js';
36
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
37
+ import { req } from '../lib/requirement-ids.js';
38
+
39
+ export const REQUIRES_HOST_CALLBACK = "the host's A2A client calls a suite-owned 1.0 peer that refuses the requested version with -32009";
40
+
41
+ const ID = 'openwop.requirement.0211.a2a-client-reads-either-shape';
42
+ const DOC = 'spec/v2/core/interop.md §"The operation mappings", A2A error details (RFC 0211 §F)';
43
+
44
+ describe('RFC 0211 §F — v2-a2a-client-error-details (host as A2A 1.0 client; gated on a2a + seams)', () => {
45
+ it('a peer\'s version error projects to interop_version_unsupported whether error.data is an array or a legacy object', async () => {
46
+ const doc = await v2Discovery().catch(() => null);
47
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
48
+ const facet = await familyAdvertised('a2a');
49
+ if (!facet) return softSkip('inapplicable', 'a2a is not advertised at major 2 — the host has no A2A client to hold');
50
+ if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the client exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
51
+ const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
52
+ await peer.start();
53
+ const seen: string[] = [];
54
+ try {
55
+ for (const legacy of [false, true]) {
56
+ peer.reset();
57
+ peer.setLegacyErrorData(legacy);
58
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: true, requestVersion: '99.0' });
59
+ if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
60
+ const reached = peer.invocations().some((i) => i.method !== 'GET');
61
+ if (!reached) return softSkip('blocked', `the host never called the suite peer (${legacy ? 'legacy' : 'Any[]'} pass) — the host cannot reach it, so no error was projected`);
62
+ const code = readErrorCode(res.json);
63
+ seen.push(`${legacy ? 'legacy object' : 'Any[]'}: HTTP ${res.status} ${String(code)}`);
64
+ expect(code, req(ID, DOC, `a peer's -32009 MUST project to interop_version_unsupported whichever shape error.data takes (${seen.join('; ')})`)).toBe('interop_version_unsupported');
65
+ }
66
+ } finally {
67
+ await peer.stop();
68
+ }
69
+ });
70
+ });
@@ -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/0211-a2a-error-details-are-errorinfo.md §A–§E (the error-details legs; the isolation comparator)
28
29
  */
29
30
  import { describe, it, expect } from 'vitest';
30
31
  import { readFileSync } from 'node:fs';
@@ -37,6 +38,7 @@ import { softSkip } from '../lib/soft-skip.js';
37
38
  import { SCHEMAS_DIR } from '../lib/paths.js';
38
39
  import { req } from '../lib/requirement-ids.js';
39
40
  import { SEAMS_PREFIX } from '../lib/seams.js';
41
+ import { errorInfos, normaliseErrorData, isOpenwopEnvelope, A2A_ERROR_DOMAIN } from '../lib/a2a-error-info.js';
40
42
 
41
43
  export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own card lists; nothing harness-hosted is handed to the host';
42
44
 
@@ -44,6 +46,8 @@ const DOC = 'spec/v2/core/interop.md §"The operation mappings" (RFC 0208)';
44
46
  const PROFILE = 'a2a-1.0';
45
47
  const SKILL = 'conformance-approval';
46
48
  const R = (slug: string): string => `openwop.requirement.0208.${slug}`;
49
+ const R11 = (slug: string): string => `openwop.requirement.0211.${slug}`;
50
+ const DOC11 = 'spec/v2/core/interop.md §"The operation mappings", A2A error details (RFC 0211)';
47
51
  const ACCEPT = { action: 'accept' };
48
52
 
49
53
  interface MapStateRow { runStatus: string; wire: string; interruptKind?: string }
@@ -184,8 +188,12 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
184
188
  const b = await rpc(t.url, 'GetTask', { id: foreign });
185
189
  expect(a.error?.code, req(R('a2a-unreadable-not-found'), 'a2a.errors TaskNotFoundError', `an unknown task MUST be TaskNotFoundError -32001 (got ${JSON.stringify(a.error ?? a.result)})`)).toBe(-32001);
186
190
  expect(b.error?.code, req(R('a2a-unreadable-not-found'), 'interop.md §"The operation mappings" Isolation', `a task the caller cannot read — here a foreign tenant segment REST refuses 403 — MUST be answered exactly as a nonexistent one, -32001 (got ${JSON.stringify(b.error ?? b.result)}, HTTP ${b.status})`)).toBe(-32001);
187
- const shape = (e: RpcError | undefined): string => JSON.stringify({ http: e === undefined ? null : 'rpc', dataKeys: e?.data && typeof e.data === 'object' ? Object.keys(e.data as object).sort() : typeof e?.data });
188
- expect(shape(b.error) + String(b.status), req(R('a2a-unreadable-not-found'), 'interop.md §"The operation mappings" Isolation (A2A 1.0.1 §3.3.2)', 'the foreign-tenant answer MUST be indistinguishable from the unknown-id answer: same HTTP status and the same error.data shape')).toBe(shape(a.error) + String(a.status));
191
+ // RFC 0211 §D: compare the details per element. `Object.keys(data)` of a one-element
192
+ // array is ["0"] whatever the element discloses, so a key comparison is vacuous.
193
+ const na = normaliseErrorData(a.error?.data, fabricated);
194
+ const nb = normaliseErrorData(b.error?.data, foreign);
195
+ expect(errorInfos(a.error?.data).length, req(R11('a2a-unreadable-not-found-details'), DOC11, `positive control: the unknown-id answer MUST carry error.data as an array with a google.rpc.ErrorInfo — without one the comparison below proves nothing (got ${JSON.stringify(a.error?.data)})`)).toBeGreaterThanOrEqual(1);
196
+ expect(JSON.stringify(nb) + String(b.status), req(R11('a2a-unreadable-not-found-details'), 'interop.md §"The operation mappings" Isolation; RFC 0211 §D (A2A 1.0.1 §3.3.2)', 'the foreign-tenant answer MUST be indistinguishable from the unknown-id answer: same HTTP status and the same normalised error details (@type, reason, domain, sorted metadata keys, minus an echo of the requested id)')).toBe(JSON.stringify(na) + String(a.status));
189
197
  await rpc(t.url, 'CancelTask', { id });
190
198
  });
191
199
 
@@ -274,6 +282,80 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
274
282
  await rpc(t.url, 'CancelTask', { id: theirId }, { bearer: other });
275
283
  });
276
284
 
285
+ it('an A2A error\'s data is an array carrying one ErrorInfo with the map row\'s reason and the A2A domain', async () => {
286
+ const t = await target(true);
287
+ if (!t.ok) return skip(t, R11('a2a-error-data-shape'));
288
+ const first = await startApprovalTask(t.url);
289
+ const id = first.task?.id;
290
+ expect(typeof id === 'string' && id.includes('/'), req(R11('a2a-error-data-shape'), DOC, `SendMessage MUST start a task with a tenant-bound id: ${JSON.stringify(first.rpc.error)}`)).toBe(true);
291
+ const tenant = id!.slice(0, id!.indexOf('/'));
292
+ const unknown = await rpc(t.url, 'GetTask', { id: `${tenant}/${randomBytes(16).toString('hex').slice(0, 22)}` });
293
+ await rpc(t.url, 'SendMessage', { message: acceptMessage(id!) });
294
+ await settle(id!, (s) => s !== undefined && TERMINAL.has(s));
295
+ const terminal = await rpc(t.url, 'CancelTask', { id });
296
+ for (const [label, r, reason] of [['GetTask on an unknown id', unknown, 'TASK_NOT_FOUND'], ['CancelTask on a terminal task', terminal, 'TASK_NOT_CANCELABLE']] as const) {
297
+ expect(Array.isArray(r.error?.data), req(R11('a2a-error-data-shape'), `${DOC11} (A2A §9.5)`, `${label}: error.data MUST be an array of objects each carrying @type (got ${JSON.stringify(r.error ?? r.result)})`)).toBe(true);
298
+ const all = (Array.isArray(r.error?.data) ? r.error!.data : []) as unknown[];
299
+ expect(all.every((x) => typeof x === 'object' && x !== null && typeof (x as Record<string, unknown>)['@type'] === 'string'), req(R11('a2a-error-data-shape'), `${DOC11} (A2A §9.5)`, `${label}: every element of error.data MUST carry @type (got ${JSON.stringify(all)})`)).toBe(true);
300
+ const infos = errorInfos(r.error?.data);
301
+ expect(infos.length, req(R11('a2a-error-data-shape'), DOC11, `${label}: error.data MUST include exactly one google.rpc.ErrorInfo (got ${JSON.stringify(all)})`)).toBe(1);
302
+ expect([infos[0]?.reason, infos[0]?.domain], req(R11('a2a-error-data-shape'), `${DOC11}; interop-map.json a2a.errors reason`, `${label}: the ErrorInfo reason MUST be the map row's reason and the domain ${A2A_ERROR_DOMAIN}`)).toEqual([reason, A2A_ERROR_DOMAIN]);
303
+ }
304
+ });
305
+
306
+ it('no response on the card\'s JSON-RPC interface URL is the OpenWOP error envelope', async () => {
307
+ const t = await target(false);
308
+ if (!t.ok) return skip(t, R11('a2a-no-openwop-envelope'));
309
+ const probes: Array<[string, RequestInit]> = [
310
+ ['a body that is not JSON', { method: 'POST', headers: { 'content-type': 'application/json', 'A2A-Version': '1.0', ...(process.env.OPENWOP_API_KEY ? { authorization: `Bearer ${process.env.OPENWOP_API_KEY}` } : {}) }, body: '{"jsonrpc":"2.0","id":1,' }],
311
+ ['a request with no credential', { method: 'POST', headers: { 'content-type': 'application/json', 'A2A-Version': '1.0' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'GetTask', params: { id: 'x/y' } }) }],
312
+ ['an unknown method', { method: 'POST', headers: { 'content-type': 'application/json', 'A2A-Version': '1.0', ...(process.env.OPENWOP_API_KEY ? { authorization: `Bearer ${process.env.OPENWOP_API_KEY}` } : {}) }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'openwop.conformance.NoSuchMethod', params: {} }) }],
313
+ ];
314
+ const seen: string[] = [];
315
+ for (const [label, init] of probes) {
316
+ const res = await fetch(t.url, init);
317
+ const text = await res.text();
318
+ let body: unknown = null;
319
+ try { body = text.length > 0 ? JSON.parse(text) : null; } catch { body = null; }
320
+ seen.push(`${label}: HTTP ${res.status}`);
321
+ expect(isOpenwopEnvelope(body), req(R11('a2a-no-openwop-envelope'), `${DOC11}; RFC 0211 §C`, `${label}: a response on an interface URL the card lists — including a refusal before dispatch — MUST be in the binding's shape, never the OpenWOP { error, message } envelope (HTTP ${res.status}, body ${text.slice(0, 200)})`)).toBe(false);
322
+ }
323
+ expect(seen.length, req(R11('a2a-no-openwop-envelope'), DOC11, 'every probe reached the interface')).toBe(probes.length);
324
+ });
325
+
326
+ it('VersionNotSupportedError carries ErrorInfo VERSION_NOT_SUPPORTED, and supportedVersions is a comma-joined subset of the card', async () => {
327
+ const t = await target(false);
328
+ if (!t.ok) return skip(t, R11('a2a-version-not-supported-shape'));
329
+ const a2a = (await familyAdvertised('a2a'))!;
330
+ const card = (await (await fetch(String(a2a['agentCardUrl']), { headers: { accept: 'application/json', 'A2A-Version': '1.0' } })).json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolVersion?: string }> };
331
+ const offered = new Set((card.supportedInterfaces ?? []).filter((i) => i.url === t.url).map((i) => String(i.protocolVersion)));
332
+ const r = await rpc(t.url, 'GetTask', { id: 'zz-conformance/unknown' }, { version: '99.0' });
333
+ expect(r.error?.code, req(R11('a2a-version-not-supported-shape'), 'a2a.errors VersionNotSupportedError', `an A2A-Version the interface does not serve MUST be -32009, not -32600 (got ${JSON.stringify(r.error ?? r.result)})`)).toBe(-32009);
334
+ const info = errorInfos(r.error?.data)[0];
335
+ expect(info?.reason, req(R11('a2a-version-not-supported-shape'), DOC11, `the -32009 error MUST carry ErrorInfo reason VERSION_NOT_SUPPORTED (got ${JSON.stringify(r.error?.data)})`)).toBe('VERSION_NOT_SUPPORTED');
336
+ const sv = (info?.metadata as Record<string, unknown> | undefined)?.['supportedVersions'];
337
+ // metadata.supportedVersions is a SHOULD (RFC 0211 §E): absent is not a failure — a client
338
+ // falls back to the card. When present it MUST be a comma-joined subset of the card's versions.
339
+ if (sv !== undefined) {
340
+ expect(typeof sv === 'string' && sv.split(',').every((v) => offered.has(v.trim())), req(R11('a2a-version-not-supported-shape'), `${DOC11}; RFC 0211 §E`, `when present, metadata.supportedVersions MUST be a comma-joined string of versions the card lists for this interface (got ${JSON.stringify(sv)}; card ${JSON.stringify([...offered])})`)).toBe(true);
341
+ }
342
+ });
343
+
344
+ it('an HTTP+JSON interface answers an A2A error with google.rpc.Status carrying the ErrorInfo', async () => {
345
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
346
+ const a2a = await familyAdvertised('a2a');
347
+ if (!a2a || !(Array.isArray(a2a['profiles']) && (a2a['profiles'] as unknown[]).includes(PROFILE))) return softSkip('inapplicable', 'a2a-1.0 is not claimed — no A2A interface to hold');
348
+ const card = (await (await fetch(String(a2a['agentCardUrl']), { headers: { accept: 'application/json', 'A2A-Version': '1.0' } })).json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }> };
349
+ const rest = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'HTTP+JSON' && i.protocolVersion === '1.0');
350
+ if (typeof rest?.url !== 'string') return softSkip('inapplicable', 'the card lists no HTTP+JSON interface at 1.0 — RFC 0211 §B binds only a host that lists one');
351
+ const headers: Record<string, string> = { accept: 'application/a2a+json, application/json', 'A2A-Version': '1.0' };
352
+ if (process.env.OPENWOP_API_KEY) headers['authorization'] = `Bearer ${process.env.OPENWOP_API_KEY}`;
353
+ const res = await fetch(`${rest.url.replace(/\/$/, '')}/tasks/${encodeURIComponent(`zz-conformance/${randomBytes(8).toString('hex')}`)}`, { headers });
354
+ const body = (await res.json().catch(() => ({}))) as { error?: { code?: unknown; status?: unknown; message?: unknown; details?: unknown } };
355
+ expect([typeof body.error?.code, typeof body.error?.status, typeof body.error?.message], req(R11('a2a-httpjson-status'), `${DOC11} (A2A §11.6)`, `an HTTP+JSON A2A error MUST be google.rpc.Status { error: { code, status, message, details } } (HTTP ${res.status}, got ${JSON.stringify(body)})`)).toEqual(['number', 'string', 'string']);
356
+ expect(errorInfos(body.error?.details)[0]?.reason, req(R11('a2a-httpjson-status'), `${DOC11} (A2A §11.6)`, 'the Status details MUST carry the ErrorInfo TASK_NOT_FOUND')).toBe('TASK_NOT_FOUND');
357
+ });
358
+
277
359
  it('a run suspended on a credential interrupt projects to TASK_STATE_AUTH_REQUIRED, naming the provider and carrying connectUrl', async () => {
278
360
  const id = 'openwop.requirement.0199.a2a-auth-required';
279
361
  const t = await target(false);
@@ -64,7 +64,6 @@
64
64
  * @see RFCS/0158-durable-execution-and-disaster-recovery-qualification.md §B.4 §D.9 §E
65
65
  */
66
66
 
67
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
68
67
  import { describe, expect, it } from 'vitest';
69
68
  import { driver } from '../lib/driver.js';
70
69
  import { v2Discovery } from '../lib/v2.js';
@@ -72,7 +71,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
72
71
  import { softSkip } from '../lib/soft-skip.js';
73
72
  import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
74
73
  import { req } from '../lib/requirement-ids.js';
75
- import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
74
+ import { startEffectReceiver, waitForFirstArrival } from '../lib/effect-receiver.js';
76
75
  import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
77
76
  import { noteEvidence, parseRecoveryBounds, EVIDENCE_NAME_PATTERN } from '../lib/durability-evidence.js';
78
77
 
@@ -267,26 +266,15 @@ function watch(runId: string, budgetMs: number, resumed: (o: Observation) => boo
267
266
  }
268
267
 
269
268
  /**
270
- * The suite's own destination for the one staged effect. Every request that
271
- * reaches it is an INVOCATION — the thing §C says to count. Honours
272
- * OPENWOP_WEBHOOK_RECEIVER_PORT so a tunnelled cut forwards here (the
273
- * certification setting is already `--max-workers 1`, so the pinned port is
274
- * not contended by the webhook files).
269
+ * How long to wait for the ONE legitimate arrival before concluding none came.
270
+ *
271
+ * Separate from `QUIET_WINDOW_MS`, and for the opposite reason: this is a wait
272
+ * FOR something that must happen, so a generous bound weakens nothing, while
273
+ * the quiet window that follows is a wait for something that must NOT happen.
274
+ * Collapsing the two into one blind sleep made the row measure the host's
275
+ * effect latency under load — see `lib/effect-receiver.ts`.
275
276
  */
276
- async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: Array<{ method: string; at: number }> }> {
277
- const arrivals: Array<{ method: string; at: number }> = [];
278
- const server = createServer((request: IncomingMessage, res: ServerResponse) => {
279
- request.on('data', () => { /* drain */ });
280
- request.on('end', () => { arrivals.push({ method: request.method ?? '', at: Date.now() }); res.writeHead(204); res.end(); });
281
- });
282
- const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
283
- const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
284
- const binding = receiverBinding();
285
- await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
286
- const addr = server.address();
287
- const port = typeof addr === 'object' && addr ? addr.port : 0;
288
- return { server, url: `http://${binding.advertise}:${port}/effect`, arrivals };
289
- }
277
+ const FIRST_ARRIVAL_BUDGET_MS = 20_000;
290
278
 
291
279
  describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-single-instance rows)', () => {
292
280
  it('accepted work survives a kill before dispatch and dispatches on resume', async () => {
@@ -421,12 +409,29 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
421
409
  // (`conformance-noop` records none). The seam is host test infrastructure
422
410
  // (§E) and chooses the work; it MUST stage work that performs EXACTLY ONE
423
411
  // outbound effect, addressed to `effectUrl`, and deliver it twice.
412
+ //
413
+ // 2.37.0 — THE DESTINATION CARRIES A PER-EXERCISE NONCE, and that is what
414
+ // makes this row deterministic. An effect's Layer-2 identity is its
415
+ // BUSINESS identity (idempotency.md §"Layer 2 Keying"): tenant, workflow,
416
+ // node, request digest — and the destination URL is part of that digest,
417
+ // while the runId deliberately is not. Until 2.37.0 this leg and
418
+ // `0194.terminal-once.duplicate-delivery` both handed the seam
419
+ // `resolveRegistrationUrl(...)`, which on a tunnelled cut is
420
+ // OPENWOP_WEBHOOK_RECEIVER_URL verbatim — the same string for both. Same
421
+ // fixture, same node, same URL ⇒ SAME effect identity, so a CONFORMANT
422
+ // host resolved the second exercise to the first's recorded outcome and
423
+ // called out zero times. Whichever leg vitest ran second read zero
424
+ // arrivals: `blocked` on loopback, a hard `executed-fail` on a tunnelled
425
+ // cut. Measured on the v2 reference host — exercise 2's ledger row read
426
+ // `invocationId: "deduplicated-of:<run 1>"`. Nothing but the file order
427
+ // differed between a pass and a fail, and the row was measuring
428
+ // cross-exercise deduplication rather than §C's within-exercise
429
+ // exactly-once. The nonce gives every exercise its own identity.
424
430
  const rx = await startEffectReceiver();
425
431
  try {
426
- const target = resolveRegistrationUrl(rx.url);
427
- const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url });
432
+ const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: rx.url });
428
433
  if (fired.status >= 400) {
429
- return softSkip('blocked', `the durability seam answered ${fired.status} for mode=duplicate-delivery with effectUrl ${target.tunnelled ? '(tunnelled)' : rx.url} — the host exposes the route but could not stage a double delivery; if its egress guard refused the receiver, the operator precondition is the webhook rows' own: a publicly-resolvable https front (OPENWOP_WEBHOOK_RECEIVER_URL) or a host run with its private-egress relaxation recorded`);
434
+ return softSkip('blocked', `the durability seam answered ${fired.status} for mode=duplicate-delivery with effectUrl ${rx.tunnelled ? '(tunnelled front)' : rx.url} — the host exposes the route but could not stage a double delivery; if its egress guard refused the receiver, the operator precondition is the webhook rows' own: a publicly-resolvable https front (OPENWOP_WEBHOOK_RECEIVER_URL) or a host run with its private-egress relaxation recorded`);
430
435
  }
431
436
  const runId = (fired.json as { runId?: unknown } | null)?.runId;
432
437
  if (typeof runId !== 'string') {
@@ -434,28 +439,71 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
434
439
  }
435
440
 
436
441
  await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(60_000) });
437
- // A LONGER wait is a STRONGER claim: this is a wait for a second arrival
438
- // that must not happen, and the second delivery may trail the first.
442
+ // Wait FOR the one legitimate arrival, then wait OUT the quiet window for
443
+ // a second that must not come. A single blind sleep conflated the two and
444
+ // made a slow-but-correct host read as zero.
445
+ await waitForFirstArrival(rx, scaledTimeoutMs(FIRST_ARRIVAL_BUDGET_MS));
439
446
  await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
440
447
 
441
- const arrivals = rx.arrivals.length;
442
- if (arrivals === 0 && !target.tunnelled) {
443
- return softSkip('blocked', `the staged work's effect never reached the suite's receiver at ${rx.url} — for mode=duplicate-delivery the seam MUST aim exactly one outbound effect at the given effectUrl; with nothing landed there is no invocation to count, and the ledger alone cannot witness a double-fire`);
448
+ // Read the host's OWN account first, so a zero can SAY WHY. The two
449
+ // zeroes are different facts and must not share one disposition: nothing
450
+ // was ever sent, or the host resolved this work to an outcome it had
451
+ // already recorded (a `deduplicated-of:` / `replay-of:` invocation) —
452
+ // correct Layer-2 behaviour that leaves §C unmeasured, not refuted.
453
+ const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
454
+ const effects = eff.status === 200 ? ((eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? []) : [];
455
+ const resolvedElsewhere = effects.filter((e) => /^(deduplicated-of|replay-of):/.test(String(e['invocationId'] ?? '')));
456
+ // An effect the host ATTEMPTED and whose transport failed. `released` is
457
+ // the state persistence.md gives a claim that was taken and given back;
458
+ // a host that names it is telling us the invocation was tried and never
459
+ // reached the destination. That is a THIRD kind of zero and it must not
460
+ // be reported as either of the others.
461
+ const attemptedAndFailed = effects.filter((e) => String(e['state'] ?? '') === 'released');
462
+ const arrivals = rx.arrivals();
463
+ if (arrivals === 0) {
464
+ // A BARE FAIL ON ZERO IS UNINFORMATIVE, so the zero is classified from
465
+ // the host's own ledger before any disposition is chosen. Three states
466
+ // wear the same symptom and only one of them is about §C:
467
+ // (a) resolved to an outcome the host had ALREADY recorded — correct
468
+ // Layer-2 keying, and this exercise simply performed no
469
+ // invocation to count;
470
+ // (b) attempted and the transport failed — a missed fire, which is
471
+ // the opposite of a double fire and cannot refute exactly-once;
472
+ // (c) nothing in the ledger at all — the seam never staged the one
473
+ // effect the mode requires, so the exercise did not happen.
474
+ // None of the three is evidence AGAINST §C, so none of them is an
475
+ // `executed-fail`; each is `blocked` with its own cause named. The only
476
+ // thing this row ever fails on is a count that is not 1 with at least
477
+ // one real invocation observed.
478
+ const why = resolvedElsewhere.length > 0
479
+ ? `the host resolved the staged effect to an outcome it had ALREADY RECORDED (${resolvedElsewhere.map((e) => String(e['invocationId'])).join(', ')}) — correct Layer-2 keying (idempotency.md §"Layer 2 Keying"), but it means no invocation happened in THIS exercise, so §C's exactly-once is unmeasured here rather than violated. The destination this suite minted carries a per-exercise nonce precisely so this cannot happen; a host that keys on something coarser than the request MUST stage a fresh business identity per exercise`
480
+ : attemptedAndFailed.length > 0
481
+ ? `the host ATTEMPTED the staged effect and its transport failed — ${attemptedAndFailed.length} ledger row(s) in state \`released\`${attemptedAndFailed.map((e) => ` (${String(e['effectId'] ?? '?')} attempt ${String(e['attempt'] ?? '?')})`).join('')}. A MISSED fire is not an exactly-once violation, so this is not a refutation of §C and is not recorded as one; the operator precondition is a destination the host can actually reach inside its own effect timeout, and a host whose staged work takes no transport retry will show this whenever the round trip exceeds that timeout`
482
+ : `the staged work's effect never reached the suite's receiver at ${rx.localUrl}${rx.tunnelled ? ` (fronted as ${rx.url})` : ''} within ${FIRST_ARRIVAL_BUDGET_MS}ms of terminal, and the host's own effect ledger records no attempt — for mode=duplicate-delivery the seam MUST aim exactly one outbound effect at the given effectUrl; with nothing landed there is no invocation to count, and the ledger alone cannot witness a double-fire${rx.foreign() > 0 ? `. ${rx.foreign()} request(s) DID reach this listener without this exercise's nonce, so the front is wired but the host addressed something else` : ''}`;
483
+ // Zero is never a pass, and — since 2.37.0 — never a bare fail either.
484
+ // Until now a tunnelled cut turned every zero into `executed-fail`
485
+ // ("a mis-wired tunnel must not read as a pass"), which is right about
486
+ // the pass and wrong about the fail: it convicted a host of violating
487
+ // exactly-once on the evidence that it fired too FEW times. The
488
+ // mis-wired tunnel it was defending against is now the `foreign()`
489
+ // count — a front that reaches this listener but carries someone
490
+ // else's nonce — which the reason above names. `blocked` is not a
491
+ // softening: a blocked row DENIES certification (RFC 0168 §E.1)
492
+ // exactly as a failed one does, so the rung is still refused; what
493
+ // changes is that the bundle says which of the three zeroes happened
494
+ // instead of asserting a violation that was never observed.
495
+ return softSkip('blocked', why);
444
496
  }
445
- // With a tunnel declared, zero arrivals is a hard failure, never a skip
446
- // (webhook-receiver.ts): a mis-wired tunnel must not read as a pass.
447
497
  expect(
448
498
  arrivals,
449
- req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the same accepted work delivered twice MUST fire each effect exactly once, counted at the effect's destination — the suite's receiver observed ${arrivals} arrival(s) of the one staged effect for run ${runId}`),
499
+ req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the same accepted work delivered twice MUST fire each effect exactly once, counted at the effect's destination — the suite's receiver observed ${arrivals} arrival(s) bearing this exercise's nonce ${rx.nonce} for run ${runId}${rx.foreign() > 0 ? ` (and ${rx.foreign()} unrelated request(s), not counted)` : ''}`),
450
500
  ).toBe(1);
451
501
 
452
502
  // Secondary, and labelled for what it is: the host's own account agrees
453
503
  // with what landed. On an identity-keyed ledger this can never exceed one
454
504
  // row per identity, so it witnesses that the PROJECTION IS CONSISTENT, not
455
505
  // that no double-fire happened — the arrival count above owns that.
456
- const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
457
506
  if (eff.status === 200) {
458
- const effects = (eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? [];
459
507
  const byIdentity = new Map<string, number>();
460
508
  for (const e of effects) {
461
509
  const id = String(e['effectId'] ?? e['keying'] ?? '');
@@ -469,7 +517,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
469
517
  ).toBe(true);
470
518
  }
471
519
  } finally {
472
- await new Promise<void>((r) => rx.server.close(() => r()));
520
+ await rx.close();
473
521
  }
474
522
  }, 180_000);
475
523
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `spec/v2/core/idempotency.md` Concurrency row — the loser of a same-key race
3
+ * (RFC 0213 §B; suite 2.37.0, target major 2; unaided; one run created).
4
+ *
5
+ * Of concurrent same-key requests a host MUST process exactly one. The others
6
+ * MAY wait and receive the winner's response only if it is a final outcome,
7
+ * marked `OpenWOP-Idempotent-Replay: true`; otherwise the host MUST answer
8
+ * `409 idempotency_in_flight` with no retry timing in `details`, and SHOULD set
9
+ * `Retry-After`. The v2 counterpart of the major-1 `highConcurrency.test.ts`.
10
+ *
11
+ * N = 5 parallel `POST /runs` with one key. The record is in flight only
12
+ * while the winning create request is being handled — not while its run
13
+ * executes — so a host that answers create in milliseconds rarely refuses a
14
+ * loser; the fixture choice does not widen that window:
15
+ * - exactly one distinct runId across the successes (two runs = the claim
16
+ * was not honored);
17
+ * - every success but one carries `OpenWOP-Idempotent-Replay: true`;
18
+ * - every refusal is `409 idempotency_in_flight`, carries no
19
+ * `details.retryAfter*`, and a `Retry-After`, when present, parses.
20
+ *
21
+ * Non-vacuity: overlap is not guaranteed. When no request was refused in flight
22
+ * the file records `partial-witness` — the 409 branch never ran, so a pass is
23
+ * not claimed for it.
24
+ *
25
+ * @see spec/v2/core/idempotency.md
26
+ * @see RFCS/0213-three-unstated-v2-outcomes.md §B
27
+ */
28
+
29
+ import { describe, it, expect } from 'vitest';
30
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
31
+ import { v2Discovery } from '../lib/v2.js';
32
+ import { readErrorCode } from '../lib/error-envelope.js';
33
+ import { softSkip } from '../lib/soft-skip.js';
34
+ import { req } from '../lib/requirement-ids.js';
35
+
36
+ const DOC = 'spec/v2/core/idempotency.md Concurrency';
37
+ const ID_ONE = 'openwop.requirement.0213.in-flight-one-winner';
38
+ const ID_LOSER = 'openwop.requirement.0213.in-flight-loser-outcome';
39
+ const FIXTURE = 'conformance-delay';
40
+ const N = 5;
41
+
42
+ async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
43
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
44
+ function parsesRetryAfter(v: string): boolean { return /^\d+$/.test(v.trim()) || !Number.isNaN(Date.parse(v)); }
45
+
46
+ describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)', () => {
47
+ it('concurrent same-key creates yield one run; each loser is a marked replay of a final outcome or 409 idempotency_in_flight with no retry timing in details', async () => {
48
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
49
+ const key = `openwopconf-inflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
50
+ const body = { workflowId: FIXTURE, inputs: { delayMs: 1500 } };
51
+ const results = await Promise.all(Array.from({ length: N }, () => http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }))));
52
+ if (results.some((r) => r === null)) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
53
+ const rs = results as OpenWOPResponse[];
54
+ const first = rs.find((r) => r.status >= 200 && r.status < 300);
55
+ if (first === undefined) {
56
+ const codes = rs.map((r) => `${r.status} ${readErrorCode(r.json) ?? ''}`.trim()).join(', ');
57
+ if (rs.every((r) => r.status === 404 || r.status === 422)) return softSkip('blocked', `the ${FIXTURE} fixture is not runnable (${codes})`);
58
+ }
59
+ const successes = rs.filter((r) => r.status >= 200 && r.status < 300);
60
+ const refusals = rs.filter((r) => !(r.status >= 200 && r.status < 300));
61
+ expect(successes.length, req(ID_ONE, DOC, `at least one of ${N} same-key creates MUST complete (statuses: ${rs.map((r) => r.status).join(',')})`)).toBeGreaterThan(0);
62
+ const runIds = new Set(successes.map((r) => (r.json as { runId?: unknown } | null)?.runId).filter((x): x is string => typeof x === 'string'));
63
+ expect(runIds.size, req(ID_ONE, DOC, `a host MUST NOT process two same-key requests: distinct runIds ${[...runIds].join(', ')}`)).toBe(1);
64
+ const unmarked = successes.filter((r) => r.headers.get('openwop-idempotent-replay') !== 'true');
65
+ expect(unmarked.length, req(ID_LOSER, DOC, `exactly one success is the winner; every other MUST carry OpenWOP-Idempotent-Replay: true (${unmarked.length} of ${successes.length} unmarked)`)).toBe(1);
66
+ for (const r of refusals) {
67
+ const code = readErrorCode(r.json);
68
+ expect({ status: r.status, code }, req(ID_LOSER, DOC, `a loser that is not a replay MUST be 409 idempotency_in_flight (got ${r.status} ${String(code)})`)).toEqual({ status: 409, code: 'idempotency_in_flight' });
69
+ const details = (r.json as { details?: Record<string, unknown> } | null)?.details ?? {};
70
+ const timing = Object.keys(details).filter((k) => /^retryAfter/i.test(k));
71
+ expect(timing, req(ID_LOSER, 'spec/v2/core/errors.md §Retry timing', `retry timing MUST NOT travel in details (found ${timing.join(', ')})`)).toEqual([]);
72
+ const ra = r.headers.get('retry-after');
73
+ if (ra !== null) expect(parsesRetryAfter(ra), req(ID_LOSER, DOC, `a Retry-After that is present MUST parse (got ${ra})`)).toBe(true);
74
+ }
75
+ if (refusals.length === 0) return softSkip('blocked', `no loser was refused in flight — all ${N} answers were successes, so the 409 branch did not run on this host`);
76
+ }, 60_000);
77
+ });