@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +5 -4
  3. package/coverage.md +2 -1
  4. package/dist/cli.js +7 -4
  5. package/dist/lib/certification-bundle-v3.js +12 -2
  6. package/dist/lib/child-env.js +29 -0
  7. package/dist/lib/v2-profiles.js +66 -0
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirements.json +130 -12
  11. package/scenario-majors.json +5 -2
  12. package/schemas/CORPUS-STAMP.json +17 -17
  13. package/src/cli.ts +7 -4
  14. package/src/lib/a2a-fake-peer.ts +16 -3
  15. package/src/lib/certification-bundle-v3.ts +11 -2
  16. package/src/lib/child-env.ts +29 -0
  17. package/src/lib/mcp-fake-server.ts +13 -0
  18. package/src/lib/v2-profiles.ts +66 -0
  19. package/src/lib/webhook-receiver.ts +24 -5
  20. package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
  21. package/src/scenarios/a2a-peer-authority.test.ts +1 -1
  22. package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
  23. package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
  24. package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
  25. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
  26. package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
  27. package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
  28. package/src/scenarios/v2-durability-recovery.test.ts +245 -55
  29. package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
  30. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  31. package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
  32. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
  33. package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
  34. 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
- /** The canonical major-2 run-event read. Null when it does not answer. */
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
- /** Count the run-lifecycle re-starts on a run: §B.4's resumption observation. */
121
- async function runStartedCount(runId: string): Promise<number> {
122
- const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events`);
123
- if (r.status !== 200) return -1;
124
- const events = (r.json as { events?: Array<{ type?: string }> } | null)?.events ?? [];
125
- return events.filter((e) => e.type === 'run.started').length;
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
- const starts = await runStartedCount(runId);
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
- starts >= 1,
161
- req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume — the run's log MUST show it being executed after the kill, observed ${starts} run.started (service answered again after ${backIn}ms)`),
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
- }, 120_000);
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's first clause, and the one a host is most likely to get
187
- // wrong: work that was executing when the process died MUST NOT be
188
- // observable as completed. A host that marks it complete on restart has
189
- // reported success for work it never finished.
190
- const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`);
191
- const status = (snap.json as { status?: unknown } | null)?.status;
192
- const starts = await runStartedCount(runId);
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
- status !== 'completed' || starts > 1,
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 ${String(status)} with ${starts} run.started (service answered again after ${backIn}ms); §B.4 measures kill → RESUMPTION, never kill → terminal`),
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
- }, 120_000);
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
- const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', workflowId: FIXTURE });
206
- if (fired.status >= 400) {
207
- return softSkip('blocked', `the durability seam answered ${fired.status} for mode=duplicate-delivery — the host exposes the route but could not stage a double delivery`);
208
- }
209
- const runId = (fired.json as { runId?: unknown } | null)?.runId;
210
- if (typeof runId !== 'string') {
211
- return softSkip('blocked', 'the seam staged a duplicate delivery but named no runId');
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
- const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
215
- if (eff.status !== 200) {
216
- return softSkip('blocked', `GET /runs/{runId}/effects answered ${eff.status} — per-identity invocation counts are unobservable, so the assertion would be vacuous`);
217
- }
218
- const effects = (eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? [];
219
- if (effects.length === 0) {
220
- return softSkip('blocked', 'the staged run recorded no effects — there is no identity to count invocations against, and an end-state assertion is exactly what §C rules out');
221
- }
222
- // §C: assert INVOCATION COUNTS PER IDENTITY, not final state. A legal end
223
- // state is precisely what a double-fire produces, so an end-state
224
- // assertion passes on the defect it exists to catch.
225
- const byIdentity = new Map<string, number>();
226
- for (const e of effects) {
227
- const id = String(e['effectId'] ?? e['keying'] ?? '');
228
- if (id === '') continue;
229
- byIdentity.set(id, (byIdentity.get(id) ?? 0) + 1);
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
- const doubled = [...byIdentity.entries()].filter(([, n]) => n > 1);
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.endpoint(), authenticated: true, peerOffersOnly: A2A_LOWEST });
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.endpoint(), requestVersion: MCP_LOWEST });
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.endpoint(),
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.endpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
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.endpoint(), authenticated: false });
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.endpoint());
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.endpoint()));
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.endpoint()).host),
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 => {