@openwop/openwop-conformance 2.36.1 → 2.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +4 -3
- package/coverage.md +1 -0
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +510 -73
- package/scenario-majors.json +14 -2
- package/schemas/CORPUS-STAMP.json +27 -27
- package/src/lib/a2a-error-info.ts +44 -0
- package/src/lib/a2a-fake-peer.ts +50 -7
- package/src/lib/effect-receiver.ts +135 -0
- package/src/lib/front-mux.ts +102 -0
- package/src/lib/mcp-fake-server.ts +15 -4
- package/src/lib/oidc-issuer.ts +15 -5
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +19 -7
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-a2a-client-error-details.test.ts +70 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +84 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-durability-recovery.test.ts +83 -35
- package/src/scenarios/v2-idempotency-in-flight.test.ts +132 -0
- package/src/scenarios/v2-interrupt-resolve-terminal.test.ts +107 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +41 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +13 -0
- package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +133 -0
- package/src/scenarios/v2-terminal-event-once.test.ts +15 -21
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
26
27
|
import { describe, it, expect } from 'vitest';
|
|
27
28
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
28
29
|
import { driver } from '../lib/driver.js';
|
|
@@ -109,7 +110,23 @@ describe('auth-subject-link: cross-lane deactivation (RFC 0159 §A.3 — opt-in)
|
|
|
109
110
|
if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
|
|
110
111
|
|
|
111
112
|
// 1. Provision a SCIM user carrying an opaque, IdP-stable externalId.
|
|
112
|
-
|
|
113
|
+
//
|
|
114
|
+
// MINTED PER EXERCISE (2.37.0). This was the literal `'idp-op-8f3a'`, and
|
|
115
|
+
// the SCIM directory it is provisioned into is the OPERATOR's
|
|
116
|
+
// (`OPENWOP_TEST_SCIM_URL`) — durable state outside this process. Step 3
|
|
117
|
+
// DEACTIVATES that user, and deactivation is the whole point of the leg, so
|
|
118
|
+
// the second run of this file against the same directory provisions an
|
|
119
|
+
// externalId that is already present and already deactivated. Step 2 then
|
|
120
|
+
// asserts "a valid linked SAML assertion authenticates before deactivation"
|
|
121
|
+
// against a subject the host is CORRECT to refuse: cold directory passes,
|
|
122
|
+
// warm directory fails, nothing about the host having changed. Same shape as
|
|
123
|
+
// the shared effect identity in `lib/effect-receiver.ts` (openwop#1513) and
|
|
124
|
+
// the fixed trigger-bridge dedup key.
|
|
125
|
+
//
|
|
126
|
+
// Unwitnessed: both opt-in variables are unset in every cut this suite has
|
|
127
|
+
// run, so the leg has never executed and the collision has never fired. It
|
|
128
|
+
// is read off the code, and the fix costs nothing if the reading is wrong.
|
|
129
|
+
const externalId = `idp-op-${randomBytes(6).toString('hex')}`;
|
|
113
130
|
const provision = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
114
131
|
scimUrl,
|
|
115
132
|
op: 'create-user',
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from '../lib/triggerBridge.js';
|
|
39
39
|
import { queryTestEvents, requireEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
40
40
|
import { req } from '../lib/requirement-ids.js';
|
|
41
|
+
import { freshDedupKey } from '../lib/triggerBridge.js';
|
|
41
42
|
|
|
42
43
|
const CONTENT_FREE_FORBIDDEN = ['body', 'headers', 'payload', 'secret', 'credentials', 'token', 'apiKey'];
|
|
43
44
|
|
|
@@ -56,7 +57,21 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
|
|
|
56
57
|
if (!(await isEventLogSeamAvailable())) return seamAbsent('host advertises openwop-trigger-bridge but the event-log seam is absent');
|
|
57
58
|
|
|
58
59
|
// ---- Leg 1: dedup → effectively-once (§C-1) ---------------------------
|
|
59
|
-
|
|
60
|
+
//
|
|
61
|
+
// The dedupKey is MINTED PER EXERCISE, and that is load-bearing (2.37.0).
|
|
62
|
+
// It used to be the literal `'conformance-dedup-key'`, and §C-1's dedup
|
|
63
|
+
// window is a ≥24h FLOOR — so the second run of this file against the same
|
|
64
|
+
// host, any time that day, hands the bridge a key it has already delivered.
|
|
65
|
+
// A CONFORMANT host then collapses the whole exercise into the first run's
|
|
66
|
+
// outcome and emits ZERO `delivered` attempts under this run's id, and the
|
|
67
|
+
// `=== 1` below convicts it. Cold host passes, warm host fails, nothing
|
|
68
|
+
// about the host having changed — the same shape as the RFC 0158 row that
|
|
69
|
+
// shared one effect identity between two files (`lib/effect-receiver.ts`).
|
|
70
|
+
// The REPETITION that §C-1 is about happens INSIDE `driveDelivery`'s
|
|
71
|
+
// `scenario: 'dedup'`, so a fresh key per exercise removes the cross-run
|
|
72
|
+
// collision without weakening the clause.
|
|
73
|
+
const dedupKey = freshDedupKey('queue');
|
|
74
|
+
const dedup = await driveDelivery({ scenario: 'dedup', dedupKey, source: 'queue' });
|
|
60
75
|
if (dedup === null) return seamAbsent('host advertises openwop-trigger-bridge but the delivery seam is unwired');
|
|
61
76
|
|
|
62
77
|
// The profile is derived AND the seam is wired — missing evidence is a
|
|
@@ -68,7 +83,7 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
|
|
|
68
83
|
'trigger.delivery.attempted (dedup)',
|
|
69
84
|
);
|
|
70
85
|
const deliveredForKey = dedupEvents.filter(
|
|
71
|
-
(e) => e.payload.dedupKey ===
|
|
86
|
+
(e) => e.payload.dedupKey === dedupKey && e.payload.outcome === 'delivered',
|
|
72
87
|
);
|
|
73
88
|
expect(
|
|
74
89
|
deliveredForKey.length === 1,
|
|
@@ -47,7 +47,7 @@ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
|
|
|
47
47
|
import { driver } from '../lib/driver.js';
|
|
48
48
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
49
49
|
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
50
|
-
import { driveDelivery } from '../lib/triggerBridge.js';
|
|
50
|
+
import { driveDelivery, freshStreamDedupKey } from '../lib/triggerBridge.js';
|
|
51
51
|
import { req } from '../lib/requirement-ids.js';
|
|
52
52
|
import { softSkip } from '../lib/soft-skip.js';
|
|
53
53
|
|
|
@@ -203,7 +203,22 @@ describe.skipIf(HTTP_SKIP)('trigger-stream-cdc: behavioral ingestion + dedup (ca
|
|
|
203
203
|
const external = tb?.ingestion?.externalSources ?? [];
|
|
204
204
|
if (!behaviorGate('triggerBridge.ingestion', (external.length ?? 0) > 0)) return;
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
// The broker coordinates are MINTED PER EXERCISE (2.37.x), and that is
|
|
207
|
+
// load-bearing. This was the literal `'events:3:99001'`, and §F.5 reuses
|
|
208
|
+
// §C-1's dedup window — a ≥24h FLOOR — verbatim, so the second run of this
|
|
209
|
+
// file against the same host, any time that day, hands the bridge an offset
|
|
210
|
+
// it has already delivered. A CONFORMANT host then collapses the exercise
|
|
211
|
+
// into the first run's outcome and reports neither a `deliveredCount` of 1
|
|
212
|
+
// nor `outcome: 'delivered'` under this run, and the assertion below
|
|
213
|
+
// convicts it: cold host passes, warm host fails, nothing about the host
|
|
214
|
+
// having changed. The repetition §C-1 is about happens INSIDE the seam's
|
|
215
|
+
// `scenario: 'dedup'` (it delivers the key TWICE), so a fresh offset per
|
|
216
|
+
// exercise removes the cross-run collision without weakening the clause.
|
|
217
|
+
// Same defect and same fix as `trigger-bridge-delivery`'s dedup key; the
|
|
218
|
+
// offset rather than an opaque token, because the `(topic,partition,offset)`
|
|
219
|
+
// keying is what the `req()` message below asserts over.
|
|
220
|
+
const dedupKey = freshStreamDedupKey();
|
|
221
|
+
const first = await driveDelivery({ scenario: 'dedup', dedupKey, source: 'stream' });
|
|
207
222
|
if (first === null) return softSkip('blocked', 'precondition not met — `first === null` returned early (delivery seam unwired — soft-skip) (seam, prior step, or fixture unavailable)'); // delivery seam unwired — soft-skip
|
|
208
223
|
if (first.outcome === undefined && !external.includes('stream')) return softSkip('blocked', 'precondition not met — `first.outcome === undefined && !external.includes(\'stream\')` returned early (pre-0127 host — soft-skip) (seam, prior step, or fixture unavailable)'); // pre-0127 host — soft-skip
|
|
209
224
|
expect(
|
|
@@ -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
|
-
|
|
188
|
-
|
|
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);
|
|
@@ -27,12 +27,11 @@
|
|
|
27
27
|
* @see RFCS/0187-host-found-bindings.md §A
|
|
28
28
|
*/
|
|
29
29
|
import { describe, it, expect } from 'vitest';
|
|
30
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
31
30
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
32
31
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
33
32
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
33
|
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
-
import {
|
|
34
|
+
import { noDeliveryCause, startScopedReceiver, unservedDestination, type ScopedReceiver } from '../lib/scoped-receiver.js';
|
|
36
35
|
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
37
36
|
import { req } from '../lib/requirement-ids.js';
|
|
38
37
|
import { projectBoundId, BOUND_ID } from '../lib/bound-id.js';
|
|
@@ -50,20 +49,21 @@ const PER_KIND = 'openwop.requirement.0187.bound-id-kinds.per-kind';
|
|
|
50
49
|
const WEBHOOK_EMITTED = 'openwop.requirement.0187.bound-id-kinds.webhook-emitted';
|
|
51
50
|
|
|
52
51
|
type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
|
|
53
|
-
/**
|
|
54
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The suite's receiver for THIS exercise — pinned port and public front
|
|
54
|
+
* honoured, plus a per-exercise nonce path (2.37.0). Four webhook files used to
|
|
55
|
+
* register the same byte-identical front URL, and a webhook subscription
|
|
56
|
+
* outlives the file that made it, so a sibling's leftovers arrived here
|
|
57
|
+
* indistinguishable from this run's deliveries.
|
|
58
|
+
*/
|
|
59
|
+
async function startReceiver(): Promise<ScopedReceiver & { deliveries: Delivery[] }> {
|
|
55
60
|
const deliveries: Delivery[] = [];
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
const rx = await startScopedReceiver((hit, res) => {
|
|
62
|
+
deliveries.push({ body: hit.body, headers: hit.headers });
|
|
63
|
+
res.writeHead(204);
|
|
64
|
+
res.end();
|
|
60
65
|
});
|
|
61
|
-
|
|
62
|
-
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
63
|
-
const binding = receiverBinding();
|
|
64
|
-
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
65
|
-
const addr = server.address();
|
|
66
|
-
return { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/hook`, deliveries };
|
|
66
|
+
return { ...rx, deliveries };
|
|
67
67
|
}
|
|
68
68
|
const header = (d: Delivery, name: string): string | undefined => { const v = d.headers[name]; return Array.isArray(v) ? v[0] : v; };
|
|
69
69
|
|
|
@@ -89,13 +89,21 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
89
89
|
it('subscriptionId: POST /webhooks mints a bound webhookId, the projected segment is accepted, and a foreign tenant segment is refused 403', async () => {
|
|
90
90
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
91
91
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised — no subscription to mint (gate recorded under openwop.family.webhooks)');
|
|
92
|
-
// Route the mint through
|
|
92
|
+
// Route the mint through the operator's front so an operator who sets
|
|
93
93
|
// OPENWOP_WEBHOOK_RECEIVER_URL actually gets a reachable registration here.
|
|
94
|
-
// Before
|
|
94
|
+
// Before that, the blocked note TOLD them to set the variable and this file
|
|
95
95
|
// never read it — inoperative advice in a suite where one blocked row denies
|
|
96
96
|
// every claimed profile (RFC 0168 §E.1). The fallback stays a reserved
|
|
97
97
|
// `.invalid` host: this leg only needs the mint, never a delivery.
|
|
98
|
-
|
|
98
|
+
//
|
|
99
|
+
// `unservedDestination`, not `resolveRegistrationUrl` (2.37.0): the latter
|
|
100
|
+
// returns the front VERBATIM, so this subscription had the same identity as
|
|
101
|
+
// every other exercise's, and it is live from the 201 until the DELETE
|
|
102
|
+
// below. Anything the host fanned out in that window landed on whichever
|
|
103
|
+
// listener held the pinned port and was read as THAT exercise's traffic. The
|
|
104
|
+
// nonce is served by nobody on purpose — this leg wants no delivery, and a
|
|
105
|
+
// delivery to it is now answered 404 instead of absorbed.
|
|
106
|
+
const registration = unservedDestination('https://subscriber.invalid/hook');
|
|
99
107
|
const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
|
|
100
108
|
if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
|
|
101
109
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') return softSkip('blocked', `host SSRF guard rejected the registration URL ${registration.url}${registration.tunnelled ? ' (from OPENWOP_WEBHOOK_RECEIVER_URL)' : ' — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https receiver, which THIS leg now honours'}`);
|
|
@@ -136,11 +144,14 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
136
144
|
const rx = await startReceiver();
|
|
137
145
|
let webhookId: string | null = null;
|
|
138
146
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
// `rx.url` is already this exercise's destination — the front (when
|
|
148
|
+
// wired) plus its nonce path — so it is not run through
|
|
149
|
+
// `resolveRegistrationUrl`, which returns the front VERBATIM and would
|
|
150
|
+
// drop the path that makes the subscription ours.
|
|
151
|
+
const reg = await http(() => driver.post('/webhooks', { url: rx.url, events: ['run.completed'] }));
|
|
141
152
|
if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
|
|
142
153
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
143
|
-
return softSkip('blocked', `host SSRF guard rejected the suite receiver ${
|
|
154
|
+
return softSkip('blocked', `host SSRF guard rejected the suite receiver ${rx.url} (webhooks.md §Egress requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
|
|
144
155
|
}
|
|
145
156
|
const minted = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
146
157
|
if (reg.status !== 201 || typeof minted !== 'string') return softSkip('blocked', `POST /webhooks answered ${reg.status} without a webhookId — the mint leg above owns that obligation; this leg needs an id to compare`);
|
|
@@ -155,7 +166,7 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
155
166
|
const deadline = Date.now() + scaledTimeoutMs(20_000);
|
|
156
167
|
while (ours().length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
|
|
157
168
|
const delivery = ours()[0];
|
|
158
|
-
if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed`);
|
|
169
|
+
if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed. ${noDeliveryCause(rx, `run.completed delivery for run ${runId}`)}`);
|
|
159
170
|
expect(
|
|
160
171
|
header(delivery, 'openwop-webhook-id'),
|
|
161
172
|
req(WEBHOOK_EMITTED, 'webhooks.md §Headers (RFC 0187 §A.1)', `the delivery's OpenWOP-Webhook-Id MUST equal the tenant-bound webhookId the host minted (${minted}) — a subscriber identifies its deliveries by the header, not by the 201 it received once`),
|
|
@@ -172,7 +183,9 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
172
183
|
}
|
|
173
184
|
} finally {
|
|
174
185
|
if (webhookId !== null) await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId as string)}`));
|
|
175
|
-
|
|
186
|
+
// Closed through the receiver: `close()` also drops this exercise's
|
|
187
|
+
// nonce from the front-mux registry.
|
|
188
|
+
await rx.close();
|
|
176
189
|
}
|
|
177
190
|
});
|
|
178
191
|
|
|
@@ -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 {
|
|
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
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
-
//
|
|
438
|
-
// that must not
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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)
|
|
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
|
|
520
|
+
await rx.close();
|
|
473
521
|
}
|
|
474
522
|
}, 180_000);
|
|
475
523
|
|