@openwop/openwop-conformance 2.31.1 → 2.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +5 -4
- package/coverage.md +2 -1
- package/dist/cli.js +7 -4
- package/dist/lib/certification-bundle-v3.js +12 -2
- package/dist/lib/child-env.js +29 -0
- package/dist/lib/v2-profiles.js +66 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +130 -12
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +7 -4
- package/src/lib/a2a-fake-peer.ts +16 -3
- package/src/lib/certification-bundle-v3.ts +11 -2
- package/src/lib/child-env.ts +29 -0
- package/src/lib/mcp-fake-server.ts +13 -0
- package/src/lib/v2-profiles.ts +66 -0
- package/src/lib/webhook-receiver.ts +24 -5
- package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
- package/src/scenarios/a2a-peer-authority.test.ts +1 -1
- package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
- package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
- package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
- package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
- package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
- package/src/scenarios/v2-durability-recovery.test.ts +245 -55
- package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
- package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
- package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* v2 bundle could carry a durability row of any kind and the rung was
|
|
11
11
|
* unwitnessable at major 2 even with the other four passing. The port is the
|
|
12
12
|
* fifth `it` below, and it needs NO SEAM: at major 2 the canonical
|
|
13
|
-
* `GET /runs/{runId}/events` answers the same question the v1 sample seam was
|
|
13
|
+
* `GET /runs/{runId}/events/poll` answers the same question the v1 sample seam was
|
|
14
14
|
* invented to answer, so the row can never be `blocked` for want of a seam a
|
|
15
15
|
* host did not wire. The v1 scenario stays where it is, unchanged.
|
|
16
16
|
*
|
|
@@ -41,6 +41,18 @@
|
|
|
41
41
|
* derived bound: a §B.5 violation that did not happen. Both measured failures
|
|
42
42
|
* are recorded in the RFC.
|
|
43
43
|
*
|
|
44
|
+
* ── 2.32.0: the observation runs to the DECLARED bound ───────────────────────
|
|
45
|
+
* Until 2.32.0 both kill rows read the log ONCE, the instant discovery answered
|
|
46
|
+
* again — mandating resumption within ~0 ms of the listener returning, i.e. a
|
|
47
|
+
* fast bound, which the RFC rejects. Found by the openwop-app host reading this
|
|
48
|
+
* file against its own sweeper BEFORE building the seam; a first witness on a
|
|
49
|
+
* host that re-enters runs at boot would have passed the single read and hidden
|
|
50
|
+
* it. `kill-during-execution` also asserted only the first clause of item 11
|
|
51
|
+
* and so passed on a host that never resumed. See the RFC's 2026-09-20 note.
|
|
52
|
+
* `duplicate-delivery` counted rows per identity on an identity-keyed ledger,
|
|
53
|
+
* which cannot show a double-fire; the effect is now counted at a receiver the
|
|
54
|
+
* suite owns. All four were found by hosts reading this file, not by a run.
|
|
55
|
+
*
|
|
44
56
|
* ── What this file deliberately does not do ─────────────────────────────────
|
|
45
57
|
* `durability/peer-resume` is the `durable-multi-instance` discriminator and is
|
|
46
58
|
* NOT required for the rung these rows witness. §E makes it bundle-witnessed
|
|
@@ -52,6 +64,7 @@
|
|
|
52
64
|
* @see RFCS/0158-durable-execution-and-disaster-recovery-qualification.md §B.4 §D.9 §E
|
|
53
65
|
*/
|
|
54
66
|
|
|
67
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
55
68
|
import { describe, expect, it } from 'vitest';
|
|
56
69
|
import { driver } from '../lib/driver.js';
|
|
57
70
|
import { v2Discovery } from '../lib/v2.js';
|
|
@@ -59,6 +72,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
59
72
|
import { softSkip } from '../lib/soft-skip.js';
|
|
60
73
|
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
61
74
|
import { req } from '../lib/requirement-ids.js';
|
|
75
|
+
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
62
76
|
|
|
63
77
|
const FIXTURE = 'conformance-noop';
|
|
64
78
|
const FAILURE_FIXTURE = 'conformance-failure';
|
|
@@ -71,9 +85,23 @@ const ATTEMPT_TYPES = new Set(['node.started', 'node.retried']);
|
|
|
71
85
|
* it is a wait for something that must not happen. */
|
|
72
86
|
const QUIET_WINDOW_MS = 4_000;
|
|
73
87
|
|
|
74
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* The canonical major-2 JSON read of a run's log. Null when it does not answer.
|
|
90
|
+
*
|
|
91
|
+
* `GET /runs/{runId}/events/poll` — NOT `/runs/{runId}/events`. At major 2 the
|
|
92
|
+
* latter is `streamRunEvents`, `text/event-stream` ONLY (api/v2/openapi.yaml);
|
|
93
|
+
* the JSON read is `pollRunEvents`. Until 2.32.0 this file parsed the STREAM
|
|
94
|
+
* path as JSON. A host that content-negotiates the stream path answered anyway;
|
|
95
|
+
* a host that serves exactly what the OpenAPI states answered SSE, the parse
|
|
96
|
+
* came back null, and `poison-exhaustion` — having already asserted the
|
|
97
|
+
* terminal status — soft-skipped its load-bearing clause and resolved
|
|
98
|
+
* `executed-pass` with the detail "partial-witness: blocked". Measured on the
|
|
99
|
+
* v2 reference host: a vacuous pass sitting in a bundle, from the suite reading
|
|
100
|
+
* the wrong one of the corpus's own two operations. `timeout=1`: existing
|
|
101
|
+
* events are returned at once; the wait only applies when there are none.
|
|
102
|
+
*/
|
|
75
103
|
async function runEvents(runId: string): Promise<Array<{ type: string }> | null> {
|
|
76
|
-
const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events`);
|
|
104
|
+
const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
77
105
|
if (r.status !== 200) return null;
|
|
78
106
|
const events = (r.json as { events?: Array<{ type?: unknown }> } | null)?.events;
|
|
79
107
|
if (!Array.isArray(events)) return null;
|
|
@@ -117,12 +145,117 @@ async function waitBack(deadlineMs: number): Promise<number | null> {
|
|
|
117
145
|
return null;
|
|
118
146
|
}
|
|
119
147
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
/**
|
|
149
|
+
* §B.4's resumption observation, read off the canonical event log.
|
|
150
|
+
*
|
|
151
|
+
* RFC 0158 §E item 11 names "a second `run.started`, or any equivalent
|
|
152
|
+
* progress-past-the-pre-kill-point signal". The registry already has two events
|
|
153
|
+
* whose whole meaning is that signal — `workflow.restored` ("an in-flight run is
|
|
154
|
+
* recovered from the event log on a fresh engine boot") and
|
|
155
|
+
* `run.restored-from-snapshot` — so a host that reports recovery with the event
|
|
156
|
+
* minted for it is not failed for declining to re-emit `run.started`.
|
|
157
|
+
*/
|
|
158
|
+
const RESTORED_TYPES = new Set(['workflow.restored', 'run.restored-from-snapshot']);
|
|
159
|
+
interface Observation { readable: boolean; runStarted: number; nodeStarted: number; restored: number }
|
|
160
|
+
async function observe(runId: string): Promise<Observation> {
|
|
161
|
+
const events = await runEvents(runId);
|
|
162
|
+
if (events === null) return { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
|
|
163
|
+
const n = (pred: (t: string) => boolean): number => events.filter((e) => pred(e.type)).length;
|
|
164
|
+
return { readable: true, runStarted: n((t) => t === 'run.started'), nodeStarted: n((t) => t === 'node.started'), restored: n((t) => RESTORED_TYPES.has(t)) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The longest this suite will wait for a resumption. 240 s by DEFAULT, and
|
|
169
|
+
* operator-raisable, because a fixed ceiling is the single read's defect moved
|
|
170
|
+
* from 0 s to 240 s: a host whose leased-class bound is 12.5 min (a 12 min
|
|
171
|
+
* dispatch lease + a 30 s orphan sweep) is conformant under §B.6, would observe
|
|
172
|
+
* nothing in 240 s, record `blocked`, and — `blocked` denying certification
|
|
173
|
+
* (RFC 0168 §E.1) — could never certify the rung without shortening a lease,
|
|
174
|
+
* the outcome §"Alternatives considered" rejects. An operator with a long bound
|
|
175
|
+
* sets `OPENWOP_DURABILITY_OBSERVATION_CEILING_MS` and waits it out; a
|
|
176
|
+
* 13-minute row in a certification cut is affordable, an uncertifiable
|
|
177
|
+
* conformant host is not. The `it` timeouts below scale from it.
|
|
178
|
+
*/
|
|
179
|
+
const DEFAULT_OBSERVATION_CEILING_MS = 240_000;
|
|
180
|
+
const OBSERVATION_CEILING_MS = ((): number => {
|
|
181
|
+
const raw = Number(process.env['OPENWOP_DURABILITY_OBSERVATION_CEILING_MS']);
|
|
182
|
+
return Number.isFinite(raw) && raw >= DEFAULT_OBSERVATION_CEILING_MS ? raw : DEFAULT_OBSERVATION_CEILING_MS;
|
|
183
|
+
})();
|
|
184
|
+
/** RESUME_WINDOW + the observation + slack for the reads themselves. */
|
|
185
|
+
const KILL_ROW_TIMEOUT_MS = OBSERVATION_CEILING_MS + 120_000;
|
|
186
|
+
/** Used only when the host serves no bound to read; named in the row's detail. */
|
|
187
|
+
const UNDECLARED_BOUND_FALLBACK_MS = 60_000;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How long to keep observing: the host's OWN declared recovery bound.
|
|
191
|
+
*
|
|
192
|
+
* §E item 11: resumption "MUST be observed on a subsequent observation within
|
|
193
|
+
* the declared recovery bound", and §B.6 makes a long bound conformant. Until
|
|
194
|
+
* 2.32.0 both kill rows read the log ONCE, the instant discovery answered
|
|
195
|
+
* again — which demanded resumption within ~0 ms of the listener coming up, a
|
|
196
|
+
* fast bound the RFC's §"Alternatives considered" explicitly rejects. A host
|
|
197
|
+
* whose sweeper first ticks 5 s after boot against a 65 s derived bound read
|
|
198
|
+
* `0 run.started` and failed, then resumed correctly ten seconds later. The
|
|
199
|
+
* bound is used as a CEILING FOR WAITING only; it is never asserted as a
|
|
200
|
+
* scalar here (`bound-is-derived` owns the arithmetic).
|
|
201
|
+
*/
|
|
202
|
+
async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared: boolean }> {
|
|
203
|
+
// A host whose bound is PER CLASS (Unresolved Question 1: unleased work waits
|
|
204
|
+
// out an outbox lease, leased work a dispatch lease — 65 s against 750 s on
|
|
205
|
+
// one measured host) names the figure that governs THIS work on the seam's
|
|
206
|
+
// own response. The bare read below returns one class and would report the
|
|
207
|
+
// interval against a bound that does not govern the staged run.
|
|
208
|
+
const governing = (fired as { recoveryBoundMs?: unknown } | null)?.recoveryBoundMs;
|
|
209
|
+
if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true };
|
|
210
|
+
const r = await driver.get('/host/durability/bound');
|
|
211
|
+
const bound = (r.json as { bound?: unknown } | null)?.bound;
|
|
212
|
+
return r.status === 200 && typeof bound === 'number' && Number.isFinite(bound) && bound > 0
|
|
213
|
+
? { ms: bound, declared: true }
|
|
214
|
+
: { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface Watch { resumedAfterMs: number | null; last: Observation; completedUnresumed: boolean; waitedMs: number }
|
|
218
|
+
/**
|
|
219
|
+
* Observe `runId` from the moment the service answers again until `resumed`
|
|
220
|
+
* holds or `budgetMs` elapses. `completedUnresumed` latches if ANY observation
|
|
221
|
+
* shows the run `completed` while `resumed` is still false — the item-11 defect
|
|
222
|
+
* is a state a later observation can paper over, so it is checked at every read.
|
|
223
|
+
*/
|
|
224
|
+
async function watchForResumption(runId: string, budgetMs: number, resumed: (o: Observation) => boolean): Promise<Watch> {
|
|
225
|
+
const t0 = Date.now();
|
|
226
|
+
let last: Observation = { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
|
|
227
|
+
let completedUnresumed = false;
|
|
228
|
+
for (;;) {
|
|
229
|
+
last = await observe(runId);
|
|
230
|
+
const waitedMs = Date.now() - t0;
|
|
231
|
+
if (last.readable && resumed(last)) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
|
|
232
|
+
const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
233
|
+
if ((snap.json as { status?: unknown } | null)?.status === 'completed' && !(last.readable && resumed(last))) completedUnresumed = true;
|
|
234
|
+
if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
|
|
235
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The suite's own destination for the one staged effect. Every request that
|
|
241
|
+
* reaches it is an INVOCATION — the thing §C says to count. Honours
|
|
242
|
+
* OPENWOP_WEBHOOK_RECEIVER_PORT so a tunnelled cut forwards here (the
|
|
243
|
+
* certification setting is already `--max-workers 1`, so the pinned port is
|
|
244
|
+
* not contended by the webhook files).
|
|
245
|
+
*/
|
|
246
|
+
async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: Array<{ method: string; at: number }> }> {
|
|
247
|
+
const arrivals: Array<{ method: string; at: number }> = [];
|
|
248
|
+
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
249
|
+
request.on('data', () => { /* drain */ });
|
|
250
|
+
request.on('end', () => { arrivals.push({ method: request.method ?? '', at: Date.now() }); res.writeHead(204); res.end(); });
|
|
251
|
+
});
|
|
252
|
+
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
253
|
+
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
254
|
+
const binding = receiverBinding();
|
|
255
|
+
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
256
|
+
const addr = server.address();
|
|
257
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
258
|
+
return { server, url: `http://${binding.advertise}:${port}/effect`, arrivals };
|
|
126
259
|
}
|
|
127
260
|
|
|
128
261
|
describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-single-instance rows)', () => {
|
|
@@ -155,12 +288,21 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
155
288
|
return softSkip('blocked', 'the service never answered again within the window — the operator precondition for this row is a restart supervisor (something must restart the killed instance; the suite cannot)');
|
|
156
289
|
}
|
|
157
290
|
|
|
158
|
-
|
|
291
|
+
// Accepted-but-undispatched work has, by definition, not executed: any
|
|
292
|
+
// dispatch evidence after the death is the resumption. Observed until the
|
|
293
|
+
// host's OWN declared bound elapses, never once at the instant of return.
|
|
294
|
+
const bound = await declaredBoundMs(fired.json);
|
|
295
|
+
const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
|
|
296
|
+
const w = await watchForResumption(runId, budget, (o) => o.runStarted >= 1 || o.nodeStarted >= 1 || o.restored >= 1);
|
|
297
|
+
if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
|
|
298
|
+
if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
|
|
299
|
+
return softSkip('blocked', `no dispatch observed in ${w.waitedMs}ms, but the host declares a ${bound.ms}ms recovery bound and this run observes for at most ${OBSERVATION_CEILING_MS}ms — a bound longer than the observation ceiling is conformant (§B.6) and is neither witnessed nor refuted here; the operator precondition for this row is OPENWOP_DURABILITY_OBSERVATION_CEILING_MS >= the declared bound`);
|
|
300
|
+
}
|
|
159
301
|
expect(
|
|
160
|
-
|
|
161
|
-
req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume
|
|
302
|
+
w.resumedAfterMs !== null,
|
|
303
|
+
req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume within the declared recovery bound — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.nodeStarted} node.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; dispatch first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
|
|
162
304
|
).toBe(true);
|
|
163
|
-
},
|
|
305
|
+
}, KILL_ROW_TIMEOUT_MS);
|
|
164
306
|
|
|
165
307
|
it('work executing at a real process death is never reported complete, and resumes', async () => {
|
|
166
308
|
const doc = await v2Discovery();
|
|
@@ -183,18 +325,31 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
183
325
|
return softSkip('blocked', 'the service never answered again within the window — the operator precondition for this row is a restart supervisor (something must restart the killed instance; the suite cannot)');
|
|
184
326
|
}
|
|
185
327
|
|
|
186
|
-
// §E item 11
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
328
|
+
// §E item 11 is TWO clauses and until 2.32.0 only the first was asserted:
|
|
329
|
+
// `status !== 'completed' || starts > 1` holds forever for a run that is
|
|
330
|
+
// simply never resumed, so the row passed on a host that lost the work —
|
|
331
|
+
// weaker than its own RFC table row ("resumed within the declared bound").
|
|
332
|
+
// (1) work executing at the death MUST NOT be observable as completed
|
|
333
|
+
// without having been re-executed — latched across EVERY observation;
|
|
334
|
+
// (2) resumption MUST be observed within the declared recovery bound.
|
|
335
|
+
// Work that was executing already has one `run.started`; resumption is a
|
|
336
|
+
// further one, or the registry's own recovery event.
|
|
337
|
+
const bound = await declaredBoundMs(fired.json);
|
|
338
|
+
const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
|
|
339
|
+
const w = await watchForResumption(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
|
|
340
|
+
if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
|
|
193
341
|
expect(
|
|
194
|
-
|
|
195
|
-
req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST NOT be observable as completed without having been re-executed — read status
|
|
342
|
+
w.completedUnresumed,
|
|
343
|
+
req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST NOT be observable as completed without having been re-executed — an observation after the kill read status completed with ${w.last.runStarted} run.started and ${w.last.restored} restored (service answered again after ${backIn}ms)`),
|
|
344
|
+
).toBe(false);
|
|
345
|
+
if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
|
|
346
|
+
return softSkip('blocked', `no resumption observed in ${w.waitedMs}ms, but the host declares a ${bound.ms}ms recovery bound and this run observes for at most ${OBSERVATION_CEILING_MS}ms — a bound longer than the observation ceiling is conformant (§B.6) and is neither witnessed nor refuted here; the operator precondition for this row is OPENWOP_DURABILITY_OBSERVATION_CEILING_MS >= the declared bound`);
|
|
347
|
+
}
|
|
348
|
+
expect(
|
|
349
|
+
w.resumedAfterMs !== null,
|
|
350
|
+
req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST resume within the declared recovery bound; §B.4 measures kill → RESUMPTION, never kill → terminal — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; resumption first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
|
|
196
351
|
).toBe(true);
|
|
197
|
-
},
|
|
352
|
+
}, KILL_ROW_TIMEOUT_MS);
|
|
198
353
|
|
|
199
354
|
it('the same accepted work delivered twice fires each effect exactly once', async () => {
|
|
200
355
|
const doc = await v2Discovery();
|
|
@@ -202,38 +357,73 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
202
357
|
const seam = await probeSeam();
|
|
203
358
|
if (seam.kind === 'absent') return softSkip('inapplicable', seam.why);
|
|
204
359
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
360
|
+
// THE EFFECT IS COUNTED WHERE IT LANDS, not where the host says it landed.
|
|
361
|
+
// Until 2.32.0 this row counted rows per identity on GET /runs/{id}/effects.
|
|
362
|
+
// A ledger keyed on effect identity admits AT MOST ONE ROW per identity by
|
|
363
|
+
// construction — a second fire at the same identity writes the same key —
|
|
364
|
+
// so `no identity appears twice` held whatever the effect did. Measured by
|
|
365
|
+
// the openwop-app host before it built to this row: with its dedup claim
|
|
366
|
+
// forced to always win, and then with the effect emitted twice per fire (an
|
|
367
|
+
// unambiguous double-fire), the per-identity count stayed 1 and the row
|
|
368
|
+
// stayed green. §C asks for INVOCATION counts; the projection exposes
|
|
369
|
+
// IDENTITIES. The only black-box oracle for "fired once" is a receiver the
|
|
370
|
+
// suite owns, so the seam aims the staged work's effect at `effectUrl`.
|
|
371
|
+
//
|
|
372
|
+
// NO `workflowId` is sent: no canonical fixture is guaranteed effectful
|
|
373
|
+
// (`conformance-noop` records none). The seam is host test infrastructure
|
|
374
|
+
// (§E) and chooses the work; it MUST stage work that performs EXACTLY ONE
|
|
375
|
+
// outbound effect, addressed to `effectUrl`, and deliver it twice.
|
|
376
|
+
const rx = await startEffectReceiver();
|
|
377
|
+
try {
|
|
378
|
+
const target = resolveRegistrationUrl(rx.url);
|
|
379
|
+
const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url });
|
|
380
|
+
if (fired.status >= 400) {
|
|
381
|
+
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`);
|
|
382
|
+
}
|
|
383
|
+
const runId = (fired.json as { runId?: unknown } | null)?.runId;
|
|
384
|
+
if (typeof runId !== 'string') {
|
|
385
|
+
return softSkip('blocked', 'the seam staged a duplicate delivery but named no runId');
|
|
386
|
+
}
|
|
213
387
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
388
|
+
await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(60_000) });
|
|
389
|
+
// A LONGER wait is a STRONGER claim: this is a wait for a second arrival
|
|
390
|
+
// that must not happen, and the second delivery may trail the first.
|
|
391
|
+
await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
|
|
392
|
+
|
|
393
|
+
const arrivals = rx.arrivals.length;
|
|
394
|
+
if (arrivals === 0 && !target.tunnelled) {
|
|
395
|
+
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`);
|
|
396
|
+
}
|
|
397
|
+
// With a tunnel declared, zero arrivals is a hard failure, never a skip
|
|
398
|
+
// (webhook-receiver.ts): a mis-wired tunnel must not read as a pass.
|
|
399
|
+
expect(
|
|
400
|
+
arrivals,
|
|
401
|
+
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}`),
|
|
402
|
+
).toBe(1);
|
|
403
|
+
|
|
404
|
+
// Secondary, and labelled for what it is: the host's own account agrees
|
|
405
|
+
// with what landed. On an identity-keyed ledger this can never exceed one
|
|
406
|
+
// row per identity, so it witnesses that the PROJECTION IS CONSISTENT, not
|
|
407
|
+
// that no double-fire happened — the arrival count above owns that.
|
|
408
|
+
const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
|
|
409
|
+
if (eff.status === 200) {
|
|
410
|
+
const effects = (eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? [];
|
|
411
|
+
const byIdentity = new Map<string, number>();
|
|
412
|
+
for (const e of effects) {
|
|
413
|
+
const id = String(e['effectId'] ?? e['keying'] ?? '');
|
|
414
|
+
if (id === '') continue;
|
|
415
|
+
byIdentity.set(id, (byIdentity.get(id) ?? 0) + 1);
|
|
416
|
+
}
|
|
417
|
+
const doubled = [...byIdentity.entries()].filter(([, n]) => n > 1);
|
|
418
|
+
expect(
|
|
419
|
+
doubled.length === 0,
|
|
420
|
+
req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the effect ledger MUST agree with the destination: no effect identity recorded more than once — ${byIdentity.size} identity/identities, ${doubled.length} recorded more than once${doubled.length ? ` (${doubled.map(([k, n]) => `${k}×${n}`).join(', ')})` : ''}`),
|
|
421
|
+
).toBe(true);
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
await new Promise<void>((r) => rx.server.close(() => r()));
|
|
230
425
|
}
|
|
231
|
-
|
|
232
|
-
expect(
|
|
233
|
-
doubled.length === 0,
|
|
234
|
-
req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the same accepted work delivered twice MUST fire each effect exactly once, asserted per effect identity — ${byIdentity.size} identity/identities recorded, ${doubled.length} fired more than once${doubled.length ? ` (${doubled.map(([k, n]) => `${k}×${n}`).join(', ')})` : ''}`),
|
|
235
|
-
).toBe(true);
|
|
236
|
-
}, 120_000);
|
|
426
|
+
}, 180_000);
|
|
237
427
|
|
|
238
428
|
it('the declared recovery bound is derived from the mechanism that enforces it', async () => {
|
|
239
429
|
const doc = await v2Discovery();
|
|
@@ -290,7 +480,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
290
480
|
).toBe('failed');
|
|
291
481
|
|
|
292
482
|
const before = await runEvents(runId);
|
|
293
|
-
if (before === null) return softSkip('blocked', 'GET /runs/{runId}/events did not answer — attempts are unobservable, so boundedness would be a vacuous claim');
|
|
483
|
+
if (before === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — attempts are unobservable, so boundedness would be a vacuous claim');
|
|
294
484
|
// Non-vacuity: the failure must actually be ON the log. Without this a host
|
|
295
485
|
// returning an empty array sails through every count comparison below,
|
|
296
486
|
// because 0 === 0 after any wait.
|
|
@@ -308,7 +498,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
308
498
|
// reports a terminal status at some point. Count, wait, count again.
|
|
309
499
|
await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
|
|
310
500
|
const after = await runEvents(runId);
|
|
311
|
-
if (after === null) return softSkip('blocked', 'the second GET /runs/{runId}/events did not answer, so the stability comparison has one side');
|
|
501
|
+
if (after === null) return softSkip('blocked', 'the second GET /runs/{runId}/events/poll did not answer, so the stability comparison has one side');
|
|
312
502
|
const attemptsAfter = after.filter((e) => ATTEMPT_TYPES.has(e.type)).length;
|
|
313
503
|
expect(
|
|
314
504
|
attemptsAfter,
|
|
@@ -96,7 +96,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
|
|
|
96
96
|
const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
|
|
97
97
|
await peer.start();
|
|
98
98
|
try {
|
|
99
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.
|
|
99
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: true, peerOffersOnly: A2A_LOWEST });
|
|
100
100
|
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)`);
|
|
101
101
|
await assertRefused('openwop.requirement.0175.minimum-version-refused', 'a2a', floor, res);
|
|
102
102
|
// The wire leg: the host MUST NOT have spoken the below-floor version to the peer.
|
|
@@ -124,7 +124,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
|
|
|
124
124
|
const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
|
|
125
125
|
await server.start();
|
|
126
126
|
try {
|
|
127
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.
|
|
127
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), requestVersion: MCP_LOWEST });
|
|
128
128
|
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
|
|
129
129
|
await assertRefused('openwop.requirement.0175.minimum-version-refused.mcp', 'mcp', floor, res);
|
|
130
130
|
for (const c of server.invocations()) {
|
|
@@ -73,7 +73,7 @@ describe('RFC 0175 §E.1 — mrtr-rounds-ceiling (gated on mcp + mrtr)', () => {
|
|
|
73
73
|
// input_required for `arguments.rounds` retries before completing.
|
|
74
74
|
const rounds = (maxRounds as number) + 1;
|
|
75
75
|
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, {
|
|
76
|
-
serverUrl: server.
|
|
76
|
+
serverUrl: server.hostFacingEndpoint(),
|
|
77
77
|
tool: LOOP_TOOL,
|
|
78
78
|
arguments: { rounds },
|
|
79
79
|
clientCapabilities: { elicitation: {} },
|
|
@@ -99,7 +99,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
99
99
|
const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
|
|
100
100
|
await peer.start();
|
|
101
101
|
try {
|
|
102
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.
|
|
102
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
|
|
103
103
|
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)`);
|
|
104
104
|
const wire = peer.invocations().filter((i) => i.method !== 'GET').map((i) => String(i.headers['a2a-version'] ?? ''));
|
|
105
105
|
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated', preferred, A2A_LOWER, res, wire);
|
|
@@ -119,7 +119,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
119
119
|
const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
|
|
120
120
|
await server.start();
|
|
121
121
|
try {
|
|
122
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.
|
|
122
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), authenticated: false });
|
|
123
123
|
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
|
|
124
124
|
const wire = server.invocations().map((i) => String(i.headers['mcp-protocol-version'] ?? ''));
|
|
125
125
|
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated.mcp', preferred, MCP_LOWER, res, wire);
|
|
@@ -95,7 +95,7 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
|
95
95
|
const validate = payloadValidator();
|
|
96
96
|
if (!validate) return softSkip('blocked', 'run-event-payloads.schema.json#/$defs/negotiationDecided not readable from SCHEMAS_DIR');
|
|
97
97
|
|
|
98
|
-
const driven = await drive(protocol, peer.
|
|
98
|
+
const driven = await drive(protocol, peer.hostFacingEndpoint());
|
|
99
99
|
if (!driven) return softSkip('blocked', 'invoke seam unavailable (reason recorded above)');
|
|
100
100
|
const runId = driven.body['runId'];
|
|
101
101
|
if (typeof runId !== 'string') {
|
|
@@ -117,11 +117,11 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
|
117
117
|
expect(
|
|
118
118
|
p['peerDigest'],
|
|
119
119
|
req(id, 'interop.md §The audit event', 'peerDigest MUST be the SHA-256 of the peer origin — never the origin in clear (RFC 0175 §D.3)'),
|
|
120
|
-
).toBe(originDigest(peer.
|
|
120
|
+
).toBe(originDigest(peer.hostFacingEndpoint()));
|
|
121
121
|
}
|
|
122
122
|
for (const v of Object.values(p)) {
|
|
123
123
|
expect(
|
|
124
|
-
typeof v === 'string' && v.includes(new URL(peer.
|
|
124
|
+
typeof v === 'string' && v.includes(new URL(peer.hostFacingEndpoint()).host),
|
|
125
125
|
req(id, 'interop.md §The audit event', 'the event is content-free: the peer origin MUST NOT appear in clear in any field'),
|
|
126
126
|
).toBe(false);
|
|
127
127
|
}
|
|
@@ -28,6 +28,8 @@ import { generateKeyPairSync } from 'node:crypto';
|
|
|
28
28
|
import { signBundleV3, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Relaxation } from '../lib/certification-bundle-v3.js';
|
|
29
29
|
import { v2Validator } from '../lib/v2.js';
|
|
30
30
|
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
32
|
+
import { v2RegistryAvailable } from '../lib/v2-profiles.js';
|
|
31
33
|
|
|
32
34
|
const host = generateKeyPairSync('ed25519');
|
|
33
35
|
const hostPem = host.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
|
@@ -38,7 +40,7 @@ const ROWS: BundleV3['results']['requirements'] = [
|
|
|
38
40
|
];
|
|
39
41
|
|
|
40
42
|
/** A schema-valid, verifier-clean bundle v3; `relaxations` and the claimed profile vary per leg. */
|
|
41
|
-
function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean }): BundleV3 {
|
|
43
|
+
function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean; profileId?: string }): BundleV3 {
|
|
42
44
|
const unsigned: Omit<BundleV3, 'signature'> = {
|
|
43
45
|
bundleVersion: '3',
|
|
44
46
|
generatedAt: '2026-09-03T00:00:00Z',
|
|
@@ -52,7 +54,7 @@ function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean }
|
|
|
52
54
|
},
|
|
53
55
|
discovery: { url: 'https://fixture.invalid/.well-known/openwop', sha256: 'a'.repeat(64), protocolVersions: ['2.0'], preferredVersion: '2.0' },
|
|
54
56
|
claimedProfiles: [
|
|
55
|
-
{ id: 'openwop-webhooks', evidenceTier: 'self', witnessCount: 1, certified: opts.certified },
|
|
57
|
+
{ id: opts.profileId ?? 'openwop-webhooks', evidenceTier: 'self', witnessCount: 1, certified: opts.certified },
|
|
56
58
|
{ id: 'openwop-discovery-core', evidenceTier: 'self', witnessCount: 1, certified: true },
|
|
57
59
|
],
|
|
58
60
|
results: { totals: { executedPass: ROWS.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: ROWS },
|
|
@@ -105,6 +107,39 @@ describe('RFC 0173 §A.2 — relaxation-recorded (unaided, fixture bundle)', ()
|
|
|
105
107
|
).not.toContain('relaxed-profile-certified');
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
// Until 2.33.0 every leg above used the FIXTURE profile `openwop-webhooks`, and
|
|
111
|
+
// the verifier matched a relaxation to a profile by testing whether the profile
|
|
112
|
+
// ID contained the family name. No real v2 profile id contains one —
|
|
113
|
+
// `openwop-discovery-core`, `openwop-core-standard`,
|
|
114
|
+
// `openwop-conformance-seams-v2` — so on every real host a DECLARED relaxation
|
|
115
|
+
// denied nothing, and this file stayed green because its fixture was the one
|
|
116
|
+
// id the rule could match. Ownership is what `spec/v2/profiles.json` states:
|
|
117
|
+
// `openwop-core-standard`'s predicate is built on `webhooks`.
|
|
118
|
+
it('a relaxation denies the REAL profile that owns its family, not only one named after it', () => {
|
|
119
|
+
if (!v2RegistryAvailable()) return softSkip('inapplicable', 'spec/v2/profiles.json is absent from this layout — profile ownership cannot be read, so only the id-named arm of the rule can run');
|
|
120
|
+
const EGRESS: BundleV3Relaxation = { obligation: 'webhooks.egress-guard', durability: 'session', reason: 'conformance cut against loopback fixtures' };
|
|
121
|
+
const control = verifyBundleV3(bundle({ certified: true, profileId: 'openwop-core-standard' }));
|
|
122
|
+
expect(
|
|
123
|
+
control.certifiedProfiles,
|
|
124
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'control: with no relaxation recorded, the real profile certifies — so the rejection below is the relaxation\'s'),
|
|
125
|
+
).toContain('openwop-core-standard');
|
|
126
|
+
const relaxed = verifyBundleV3(bundle({ relaxations: [EGRESS], certified: true, profileId: 'openwop-core-standard' }));
|
|
127
|
+
expect(
|
|
128
|
+
relaxed.rejections.find((r) => r.kind === 'relaxed-profile-certified')?.profile,
|
|
129
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'a relaxation on `webhooks.*` MUST deny `openwop-core-standard`, whose registry predicate is built on the `webhooks` family — ownership is read from spec/v2/profiles.json, never from the spelling of the profile id'),
|
|
130
|
+
).toBe('openwop-core-standard');
|
|
131
|
+
expect(
|
|
132
|
+
relaxed.certifiedProfiles,
|
|
133
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'the owning profile is not certified, and a profile that does not own the family still is'),
|
|
134
|
+
).toEqual(['openwop-discovery-core']);
|
|
135
|
+
// A family no claimed profile owns denies nothing — the relaxation is still recorded, which is the obligation.
|
|
136
|
+
const unowned = verifyBundleV3(bundle({ relaxations: [{ ...EGRESS, obligation: 'mcp.egress-guard' }], certified: true, profileId: 'openwop-core-standard' }));
|
|
137
|
+
expect(
|
|
138
|
+
unowned.certifiedProfiles,
|
|
139
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'a relaxation on a family outside every claimed profile\'s predicate is scoped to nothing claimed — it does not poison an unrelated profile'),
|
|
140
|
+
).toContain('openwop-core-standard');
|
|
141
|
+
});
|
|
142
|
+
|
|
108
143
|
it('durability is a closed set: session | deployment | persisted', () => {
|
|
109
144
|
const validate = v2Validator('certification-bundle');
|
|
110
145
|
const withDurability = (durability: string): unknown => {
|