@openwop/openwop-conformance 2.33.0 → 2.33.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.33.1] — 2026-09-21 — two false fails: a race between two reads, and a subscription nobody unregistered
4
+
5
+ - **`v2-durability-recovery`: the "completed without re-execution" latch was decided from two non-atomic reads in the wrong order.** Each iteration read the LOG, then the STATUS, and latched when the status said `completed` and the — older — log showed no resumption. A host that re-dispatched between the two requests read as "completed un-re-executed" while being neither. `conformance-noop` re-executes in milliseconds, so the window is real: per run roughly (gap between the requests) / (500 ms poll), a few percent. **Measured on a tier-1 host:** after a genuine SIGKILL and a correct recovery by lease expiry (727 s inside a declared 750 s bound) the row failed with *"read status completed with 2 run.started"* — printing the re-execution it was denying, because the message used a later read than the latch. The same host and code had passed the two previous runs.
6
+ - **Status first, log second.** A run's log is append-only, so a log read taken *after* a `completed` status can only show more than the status implied: if it still shows no resumption the defect is real, if it shows resumption nothing was wrong, if it is unreadable nothing is concluded. The loop moves to `src/lib/durability-watch.ts` with injected readers, and `durability-watch.test.ts` pins the ordering without a host — the race reproduced, the genuine defect still latching, the latch staying sticky, and an unreadable log concluding nothing.
7
+ - **The defect only ever produced FALSE FAILS, never false passes** — the latch could be set wrongly, never cleared wrongly — so an `executed-pass` on this row under 2.32.0 or 2.33.0 stands. A host that saw this row fail on those versions should re-run on 2.33.1 before triaging it.
8
+ - Found by the openwop-app host session reading the installed scenario after an unexplained red. Introduced in 2.32.0, by the same change that made the row assert both clauses of §E item 11.
9
+ - **`v2-webhook-delivery-shape` never unregistered anything, and behind a public front that failed a correct host.** On loopback each leg's receiver binds its own ephemeral port, so leg 1's leftover MAJOR-2 subscription delivered to a dead address and nobody noticed. Behind a tunnel every leg shares ONE URL on ONE pinned port: the still-live v2 subscription delivered its v2 rendering into leg 2, and the major-1 leg read it — *"a major-1 run.started payload MUST validate against the V1 definition … engineVersion must be string"* — on a host that had rendered both contracts correctly. It was the ONE failing row of the v2 reference host's first relaxation-free cut (241 pass / 1 fail / 0 blocked). **Reproduced with no ingress at all** by pinning `OPENWOP_WEBHOOK_RECEIVER_PORT` on a loopback run: ephemeral → 3/3, pinned → the major-1 leg fails; after the fix both → 3/3 and the host is left with **0** subscriptions. Every leg now unregisters what it registered, and — belt and braces — a delivery whose `OpenWOP-Webhook-Id` / `X-openwop-Webhook-Id` names a different subscription is not read as this leg's. The file also left a live subscription on every host it ever ran against; MyndHyve's operator had been sweeping them by hand after each tunnelled cut. The other major-2 webhook files were audited and already unregister.
10
+ - Suite patch: corpus release stays `2.33.0`.
11
+
3
12
  ## [2.33.0] — 2026-09-21 — a declared relaxation denied nothing, an undeclared one was invisible, and two fakes made both unavoidable
4
13
 
5
14
  Step 2 of closing `docs/KNOWN-LIMITS.md` §"The reference host's certified bundles were cut under an undeclared relaxation". Three suite defects, all the corpus's.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  # --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
12
12
  # default resolver refuses. npm 10.9 fails outright with
13
13
  # "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
14
- npm install --legacy-peer-deps @openwop/openwop-conformance@2.33.0 @openwop/spec-artifacts@2.33.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.33.1 @openwop/spec-artifacts@2.33.1
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.33.0",
4
- "stampSha256": "e4dbaa49468204c51012de2eb0d2f121b9805804a77c6d108a0bafb707a86bf4"
3
+ "version": "2.33.1",
4
+ "stampSha256": "132270b1cd8c97bed21d57a5b08c9abe89d10a56ff1aadf9ff3119edafb4d121"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.33.0",
3
+ "version": "2.33.1",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.33.0"
59
+ "@openwop/spec-artifacts": "2.33.1"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -26650,7 +26650,7 @@
26650
26650
  {
26651
26651
  "id": "openwop.it.v2-durability-recovery.accepted-work-survives-a-kill-before-dispatch-and-dispatches-on-resume",
26652
26652
  "file": "v2-durability-recovery.test.ts",
26653
- "line": 262,
26653
+ "line": 254,
26654
26654
  "title": "accepted work survives a kill before dispatch and dispatches on resume",
26655
26655
  "explicitId": "openwop.requirement.0158.kill-after-accept",
26656
26656
  "citations": [
@@ -26664,7 +26664,7 @@
26664
26664
  {
26665
26665
  "id": "openwop.it.v2-durability-recovery.work-executing-at-a-real-process-death-is-never-reported-complete-and-resumes",
26666
26666
  "file": "v2-durability-recovery.test.ts",
26667
- "line": 307,
26667
+ "line": 299,
26668
26668
  "title": "work executing at a real process death is never reported complete, and resumes",
26669
26669
  "explicitId": "openwop.requirement.0158.kill-during-execution",
26670
26670
  "citations": [
@@ -26683,7 +26683,7 @@
26683
26683
  {
26684
26684
  "id": "openwop.it.v2-durability-recovery.the-same-accepted-work-delivered-twice-fires-each-effect-exactly-once",
26685
26685
  "file": "v2-durability-recovery.test.ts",
26686
- "line": 354,
26686
+ "line": 346,
26687
26687
  "title": "the same accepted work delivered twice fires each effect exactly once",
26688
26688
  "explicitId": "openwop.requirement.0158.duplicate-delivery",
26689
26689
  "citations": [
@@ -26702,7 +26702,7 @@
26702
26702
  {
26703
26703
  "id": "openwop.it.v2-durability-recovery.the-declared-recovery-bound-is-derived-from-the-mechanism-that-enforces-it",
26704
26704
  "file": "v2-durability-recovery.test.ts",
26705
- "line": 428,
26705
+ "line": 420,
26706
26706
  "title": "the declared recovery bound is derived from the mechanism that enforces it",
26707
26707
  "explicitId": "openwop.requirement.0158.bound-is-derived",
26708
26708
  "citations": [
@@ -26716,7 +26716,7 @@
26716
26716
  {
26717
26717
  "id": "openwop.it.v2-durability-recovery.deterministically-failing-work-reaches-a-terminal-state-and-stops-being-retried",
26718
26718
  "file": "v2-durability-recovery.test.ts",
26719
- "line": 458,
26719
+ "line": 450,
26720
26720
  "title": "deterministically failing work reaches a terminal state and stops being retried",
26721
26721
  "explicitId": "openwop.requirement.0158.poison-exhaustion",
26722
26722
  "citations": [
@@ -29440,7 +29440,7 @@
29440
29440
  {
29441
29441
  "id": "openwop.it.v2-webhook-delivery-shape.a-major-2-subscriber-receives-the-v2-rendering-the-delivery-validates-and-run-st",
29442
29442
  "file": "v2-webhook-delivery-shape.test.ts",
29443
- "line": 186,
29443
+ "line": 220,
29444
29444
  "title": "a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal",
29445
29445
  "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29446
29446
  "citations": [
@@ -29471,7 +29471,7 @@
29471
29471
  {
29472
29472
  "id": "openwop.it.v2-webhook-delivery-shape.a-major-1-subscriber-still-receives-the-v1-rendering-the-v1-wire-does-not-move-m",
29473
29473
  "file": "v2-webhook-delivery-shape.test.ts",
29474
- "line": 205,
29474
+ "line": 240,
29475
29475
  "title": "a major-1 subscriber still receives the v1 rendering — the v1 wire does not move mid-overlap",
29476
29476
  "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29477
29477
  "citations": [
@@ -29489,7 +29489,7 @@
29489
29489
  {
29490
29490
  "id": "openwop.it.v2-webhook-delivery-shape.a-seeded-era-2-run-is-delivered-projected-too-the-fan-out-branch-a-fresh-run-can",
29491
29491
  "file": "v2-webhook-delivery-shape.test.ts",
29492
- "line": 228,
29492
+ "line": 264,
29493
29493
  "title": "a seeded era-2 run is delivered projected too — the fan-out branch a fresh run cannot reach (seam-gated)",
29494
29494
  "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29495
29495
  "citations": [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.33.0",
4
+ "version": "2.33.1",
5
5
  "corpusTag": "v2.33.0",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
@@ -201,7 +201,7 @@
201
201
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
202
202
  "spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
203
203
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
204
- "spec/v1/core-standard-manifest.json": "9f5d065a0a84cf4745f53957f395b1e868453f910531e030f2da4039ee2c9140",
204
+ "spec/v1/core-standard-manifest.json": "71c38c738496eaac1d144561a7f6e752600f8afd71f938eb483c0c76b8a5263a",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -282,5 +282,5 @@
282
282
  "spec/v2/release.json": "94a3ea302324d9f7df87ce62a186b0b98936de6ce5ac5ec01a3fb3208c38efc3",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "e9825b0da6ca8e2dbc76353e52aea73a56473393"
285
+ "corpusCommit": "b9afb25c0bdfbbd2f11efad6f984df51e50344e5"
286
286
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * RFC 0158 §E item 11 — watching a run across a real process death.
3
+ *
4
+ * Two facts are wanted from one watch: (a) did resumption appear within the
5
+ * budget, and (b) was the run EVER observable as `completed` without having
6
+ * been re-executed. (b) is a statement about two pieces of host state — the
7
+ * status and the log — and they come from two requests, so the ORDER of the
8
+ * reads decides whether a conclusion is sound.
9
+ *
10
+ * Until 2.33.1 each iteration read the LOG, then the STATUS, and latched (b)
11
+ * when the status said `completed` and the (older) log showed no resumption. A
12
+ * host that re-dispatched between the two requests therefore read as "completed
13
+ * un-re-executed" while being neither: the log read predated the re-execution,
14
+ * the status read followed its completion. The staged work is `conformance-noop`,
15
+ * which re-executes in milliseconds, so the window is real — per run roughly
16
+ * (gap between the two requests) / (poll interval), a few percent. Measured on
17
+ * a tier-1 host: after a genuine SIGKILL and a correct recovery by lease expiry
18
+ * (727 s, inside a declared 750 s bound), the row failed with the message
19
+ * "read status completed with 2 run.started" — it printed the re-execution it
20
+ * was denying, because the message used a LATER read than the latch did. It had
21
+ * passed on the two previous runs of the same host and code.
22
+ *
23
+ * The rule: STATUS FIRST, LOG SECOND. A run's log is append-only, so a log read
24
+ * taken AFTER a `completed` status can only show MORE than the status implied.
25
+ * If that later log still shows no resumption, the run really was observable as
26
+ * completed un-re-executed. If it shows resumption, nothing was wrong. If it is
27
+ * unreadable, nothing is concluded — an unreadable log is never evidence.
28
+ *
29
+ * Readers are injected so the ordering itself is testable without a host.
30
+ */
31
+ export interface Observation { readonly readable: boolean; readonly runStarted: number; readonly nodeStarted: number; readonly restored: number }
32
+ export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number }
33
+ export interface WatchIo {
34
+ readStatus(): Promise<string | null>;
35
+ readLog(): Promise<Observation>;
36
+ now(): number;
37
+ sleep(ms: number): Promise<void>;
38
+ }
39
+
40
+ export async function watchForResumption(io: WatchIo, budgetMs: number, resumed: (o: Observation) => boolean, pollMs = 500): Promise<Watch> {
41
+ const t0 = io.now();
42
+ let completedUnresumed = false;
43
+ for (;;) {
44
+ const status = await io.readStatus(); // FIRST
45
+ const last = await io.readLog(); // SECOND — never older than the status it is judged against
46
+ const waitedMs = io.now() - t0;
47
+ const isResumed = last.readable && resumed(last);
48
+ if (status === 'completed' && last.readable && !isResumed) completedUnresumed = true;
49
+ if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
50
+ if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
51
+ await io.sleep(pollMs);
52
+ }
53
+ }
@@ -73,6 +73,7 @@ import { softSkip } from '../lib/soft-skip.js';
73
73
  import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
74
74
  import { req } from '../lib/requirement-ids.js';
75
75
  import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
76
+ import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
76
77
 
77
78
  const FIXTURE = 'conformance-noop';
78
79
  const FAILURE_FIXTURE = 'conformance-failure';
@@ -156,7 +157,6 @@ async function waitBack(deadlineMs: number): Promise<number | null> {
156
157
  * minted for it is not failed for declining to re-emit `run.started`.
157
158
  */
158
159
  const RESTORED_TYPES = new Set(['workflow.restored', 'run.restored-from-snapshot']);
159
- interface Observation { readable: boolean; runStarted: number; nodeStarted: number; restored: number }
160
160
  async function observe(runId: string): Promise<Observation> {
161
161
  const events = await runEvents(runId);
162
162
  if (events === null) return { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
@@ -214,26 +214,18 @@ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared:
214
214
  : { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
215
215
  }
216
216
 
217
- interface Watch { resumedAfterMs: number | null; last: Observation; completedUnresumed: boolean; waitedMs: number }
218
217
  /**
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.
218
+ * Observe `runId` until `resumed` holds or `budgetMs` elapses. The loop itself —
219
+ * and the reason the STATUS is read before the LOG — lives in
220
+ * `lib/durability-watch.ts`, where the ordering is unit-tested without a host.
223
221
  */
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
- }
222
+ function watch(runId: string, budgetMs: number, resumed: (o: Observation) => boolean): Promise<Watch> {
223
+ return watchForResumption({
224
+ readStatus: async () => { const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`); const st = (snap.json as { status?: unknown } | null)?.status; return typeof st === 'string' ? st : null; },
225
+ readLog: () => observe(runId),
226
+ now: () => Date.now(),
227
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
228
+ }, budgetMs, resumed);
237
229
  }
238
230
 
239
231
  /**
@@ -293,7 +285,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
293
285
  // host's OWN declared bound elapses, never once at the instant of return.
294
286
  const bound = await declaredBoundMs(fired.json);
295
287
  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);
288
+ const w = await watch(runId, budget, (o) => o.runStarted >= 1 || o.nodeStarted >= 1 || o.restored >= 1);
297
289
  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
290
  if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
299
291
  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`);
@@ -336,7 +328,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
336
328
  // further one, or the registry's own recovery event.
337
329
  const bound = await declaredBoundMs(fired.json);
338
330
  const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
339
- const w = await watchForResumption(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
331
+ const w = await watch(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
340
332
  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"');
341
333
  expect(
342
334
  w.completedUnresumed,
@@ -115,9 +115,32 @@ async function register(url: string, major: 1 | 2): Promise<string | null> {
115
115
  }
116
116
  expect(reg.status, req(ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
117
117
  const id = (reg.json as { webhookId?: unknown } | null)?.webhookId;
118
+ if (typeof id === 'string') registered.push({ id, major });
118
119
  return typeof id === 'string' ? id : null;
119
120
  }
120
121
 
122
+ /**
123
+ * Every subscription this file registers, unregistered after the leg that made
124
+ * it. Until 2.33.1 NOTHING here was ever unregistered. On loopback that was
125
+ * invisible: each leg's receiver bound its own ephemeral port, so a leftover
126
+ * subscription delivered to a dead address. Behind a public front every leg
127
+ * shares ONE URL on ONE pinned port - so leg 1's still-live MAJOR-2
128
+ * subscription delivered its v2 rendering into leg 2, and the major-1 leg read
129
+ * it and failed a host that had rendered both contracts correctly ("a major-1
130
+ * run.started payload MUST validate against the V1 definition … engineVersion
131
+ * must be string"). Found on the v2 reference host's first relaxation-free cut;
132
+ * reproduced with no ingress at all by pinning OPENWOP_WEBHOOK_RECEIVER_PORT on
133
+ * a loopback run. It also left a live subscription on every host this file ever
134
+ * ran against.
135
+ */
136
+ const registered: Array<{ id: string; major: 1 | 2 }> = [];
137
+ async function unregisterAll(): Promise<void> {
138
+ for (const r of registered.splice(0)) {
139
+ const path = `${r.major === 2 ? '' : '/v1'}/webhooks/${encodeURIComponent(r.id)}`;
140
+ try { await driver.delete(path, { headers: { 'OpenWOP-Version': r.major === 2 ? '2.0' : '1.0' } }); } catch { /* best effort: the leg's verdict is already recorded */ }
141
+ }
142
+ }
143
+
121
144
  async function driveRun(): Promise<string> {
122
145
  const create = await driver.post('/runs', { workflowId: FIXTURE });
123
146
  expect(create.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
@@ -135,10 +158,21 @@ async function waitFor<T>(fn: () => T | undefined, ms: number): Promise<T | unde
135
158
  * receive the bare opaque id (versioning.md §5), so the match is on the segment
136
159
  * both spellings share.
137
160
  */
138
- function deliveryFor(deliveries: Delivery[], runId: string): { event: Record<string, unknown>; envelope: Record<string, unknown> } | undefined {
161
+ /** The subscription a delivery says it belongs to, from either header family; undefined when it carries neither. */
162
+ function subscriptionOf(d: Delivery): string | undefined {
163
+ const h = d.headers['openwop-webhook-id'] ?? d.headers['x-openwop-webhook-id'];
164
+ return typeof h === 'string' ? h : Array.isArray(h) ? h[0] : undefined;
165
+ }
166
+ const bare = (id: string): string => (id.includes('/') ? id.slice(id.indexOf('/') + 1) : id);
167
+
168
+ function deliveryFor(deliveries: Delivery[], runId: string, webhookId?: string): { event: Record<string, unknown>; envelope: Record<string, unknown> } | undefined {
139
169
  const opaque = runId.includes('/') ? runId.slice(runId.indexOf('/') + 1) : runId;
140
170
  for (const d of deliveries) {
141
171
  if (!d.body.includes(opaque)) continue;
172
+ // Belt and braces beside unregisterAll(): a delivery that NAMES another
173
+ // subscription is not this leg's, whatever URL it arrived on.
174
+ const sub = subscriptionOf(d);
175
+ if (webhookId !== undefined && sub !== undefined && bare(sub) !== bare(webhookId)) continue;
142
176
  let parsed: unknown;
143
177
  try { parsed = JSON.parse(d.body); } catch { continue; }
144
178
  if (parsed === null || typeof parsed !== 'object') continue;
@@ -181,15 +215,16 @@ const V1 = 'https://openwop.dev/spec/v1/';
181
215
 
182
216
  describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
183
217
  let active: Server | null = null;
184
- afterEach(async () => { const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
218
+ afterEach(async () => { await unregisterAll(); const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
185
219
 
186
220
  it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
187
221
  if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
188
222
  if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
189
223
  const receiver = await startReceiver(); active = receiver.server;
190
- if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
224
+ const webhookId = await register(receiver.url, 2);
225
+ if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
191
226
  const runId = await driveRun();
192
- const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
227
+ const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
193
228
  if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
194
229
  const v2 = validators(2);
195
230
  const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
@@ -211,9 +246,10 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
211
246
  const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
212
247
  if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
213
248
  const receiver = await startReceiver(); active = receiver.server;
214
- if ((await register(receiver.url, 1)) === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
249
+ const webhookId = await register(receiver.url, 1);
250
+ if (webhookId === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
215
251
  const runId = await driveRun();
216
- const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
252
+ const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
217
253
  if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
218
254
  // The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
219
255
  // §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
@@ -234,11 +270,12 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
234
270
  const gate = era2Gate(disc);
235
271
  if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
236
272
  const receiver = await startReceiver(); active = receiver.server;
237
- if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
273
+ const webhookId = await register(receiver.url, 2);
274
+ if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
238
275
  const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
239
276
  if (!log.ok) return softSkip(log.kind, log.reason);
240
277
  const runId = log.runId;
241
- const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
278
+ const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
242
279
  // The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
243
280
  // reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
244
281
  // era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.