@openwop/openwop-conformance 2.0.11 → 2.1.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 CHANGED
@@ -1,5 +1,103 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.1.0] — 2026-09-11 — the run-list scenario (RFC 0182)
4
+
5
+ New scenario `v2-run-list` (target major 2, gated on the `runList` family —
6
+ `inapplicable` on a host that does not advertise it), three legs:
7
+
8
+ - `openwop.requirement.0182.run-list.tenant-scoped` (one requirement, five assertions) — two runs the caller just created appear in its unfiltered list, every page validates against `run-list-response.schema.json` (so a bare `runId` fails the shape leg), no page exceeds the advertised `maxPageSize`, and every `runId` carries the caller's tenant segment — the segment of the ids the host minted for this credential moments earlier.
9
+ - `…cursor-refused` — a cursor the host did not mint is `400 validation_error`.
10
+ - `…filter-exact` — when `runList.filters` names `workflowId`, the filtered list contains only that workflow and still contains the run just created.
11
+
12
+ The 2.0.13 receiver fix (below) ships in this release; 2.0.13 itself was never
13
+ tagged. The pinned `@openwop/spec-artifacts` peer moves to `2.1.0`.
14
+
15
+ ## [2.0.13] — 2026-09-10 — three webhook scenarios could not be witnessed off-process
16
+
17
+ `webhook-signed-delivery`, `replay-fanout-suppression` and
18
+ `v2-webhook-durable-delivery` each stand up their own HTTP receiver and hand the
19
+ host under test a URL to POST to. All three hard-coded `127.0.0.1` in the two
20
+ places that matter, so a host running anywhere but *this process* was told to
21
+ deliver to its own loopback.
22
+
23
+ MEASURED 2026-09-09 on a container lane whose harness doubles (compat provider,
24
+ OIDC issuer) were reachable via `--add-host host.docker.internal:host-gateway`,
25
+ from inside the container:
26
+
27
+ ```
28
+ "msg":"webhook delivery failed","url":"http://127.0.0.1:55469/",
29
+ "detail":"fetch failed (connect ECONNREFUSED 127.0.0.1:55469)"
30
+ ```
31
+
32
+ Two independent blockers, both this suite's, neither the operator's: the
33
+ receiver **advertised** `127.0.0.1`, and it **bound loopback-only**, so no
34
+ external name could have reached it either. The lane was configured correctly.
35
+ The host signs webhooks correctly. The scenarios recorded a plain `fail` while
36
+ being unwitnessable — which is a false accusation, and the mirror of a gate that
37
+ cannot fail: a scenario whose pass condition cannot be satisfied in the posture
38
+ it is run in.
39
+
40
+ **Fix.** New `receiverBinding()` in `lib/webhook-receiver.ts`, used by all three.
41
+ It is a no-op by default: with `OPENWOP_CONFORMANCE_HARNESS_HOST` unset it
42
+ returns `{ bind: '127.0.0.1', advertise: '127.0.0.1' }`, byte-for-byte today's
43
+ behaviour, and **no receiver's bind widens for an in-process run**. Only when an
44
+ operator sets that variable — declaring that the host under test is somewhere
45
+ `127.0.0.1` does not name this process, and naming how it reaches this machine —
46
+ does the receiver bind `0.0.0.0` and advertise that name. The variable already
47
+ carried exactly this meaning for host-side harness doubles; the suite's own
48
+ receivers simply never consulted it.
49
+
50
+ **No SSRF gate is relaxed.** `host.docker.internal` is still a private address
51
+ over plain `http`, so the three gates in `webhook-signed-delivery`'s docblock
52
+ apply unchanged and a host must still opt in (or front the receiver with
53
+ `OPENWOP_WEBHOOK_RECEIVER_URL`, which waives nothing) to witness the row. All
54
+ this changes is that the packet now has somewhere to go.
55
+
56
+ ### Second defect, same program: a fixed grace instead of a wait
57
+
58
+ With the address correct, `webhook-signed-delivery` still failed — now in 549ms,
59
+ with `expected 0 to be greater than 0`. It slept a fixed `setTimeout(500)`
60
+ "for fire-and-forget delivery to land". That assumes delivery is synchronous-ish
61
+ with run completion, which is true of a host that POSTs inline and false of one
62
+ doing what `webhooks.md` §"Delivery" asks for: a durable queue with retries,
63
+ drained by a worker on its own cadence. The reference host polls every 1000ms,
64
+ so the sleep expired before the worker's first tick. **The more durable a host's
65
+ delivery machinery, the more exposed it was here** — and this scenario is
66
+ typically the only automated oracle a host has on delivery header NAMES, so the
67
+ cost of the race was not a checkmark.
68
+
69
+ Replaced with a bounded wait: poll every 250ms until a delivery for this run
70
+ arrives or a 12s deadline passes. It still FAILS and never soft-skips; the
71
+ failure now means "did not deliver within 12s" rather than "did not deliver
72
+ within one arbitrary tick of an unrelated clock". The deadline is sized to sit
73
+ inside the 30s per-test budget alongside the existing 10s terminal poll, so a
74
+ genuinely slow host fails on the requirement message rather than on a bare
75
+ vitest timeout.
76
+
77
+ MEASURED end-to-end on the container lane, same image, three runs: **2 failed →
78
+ (address fixed) 1 failed, `replay-fanout-suppression` green → (wait fixed) 0
79
+ failed, both green.**
80
+
81
+ **Self-tests pin the call sites, not just the helper** — a helper that is
82
+ correct and unused is precisely the defect being fixed, and a unit test on
83
+ `receiverBinding()` alone stays green through a full revert of all three
84
+ scenarios. Sabotage-verified with disjoint red sets: reverting either the bind
85
+ or the advertise of any one scenario reds exactly that scenario's case, and each
86
+ helper branch reds only its own. The bounded wait is pinned the same way and for
87
+ a sharper reason — two consecutive container runs disagreed about the 500ms
88
+ sleep (0 deliveries at 549ms, then 1 at 318ms), so a behavioural test for it
89
+ would itself be a coin flip and could not be trusted to go red on a revert.
90
+
91
+ ## [2.0.12] — 2026-09-10 — corpus peer moves with RFC 0181; no scenario change
92
+
93
+ No scenario, assertion or CLI change. The pinned `@openwop/spec-artifacts` peer
94
+ moves to `2.0.12` with the corpus: `versioning.md` §5 decides the vendor path
95
+ namespace (`/host/<org>/…`, RFC 0181), §1.4 scopes the non-protocol-response
96
+ rule to manifest-named paths — which is exactly what `reachedUnderMajor2` has
97
+ always probed — and the declaration registers two orgs and reserves the
98
+ manifest's `/host/` segments. The suite never measures a vendor path, and this
99
+ release says so in the RFC rather than inventing a probe.
100
+
3
101
  ## [2.0.11] — 2026-09-10 — the emitter scrubbed the key id it was required to publish
4
102
 
5
103
  `--certify` fix. `evidenceSecretsFromEnv` selected every `OPENWOP_*` variable
package/README.md CHANGED
@@ -117,7 +117,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
117
117
 
118
118
  ## What's Covered
119
119
 
120
- The current suite has 516 scenario files under `src/scenarios/`.
120
+ The current suite has 517 scenario files under `src/scenarios/`.
121
121
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
122
122
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
123
123
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -462,7 +462,7 @@ Server-required (added in 1.7.0):
462
462
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
463
463
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
464
464
 
465
- Current source tree: 516 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
465
+ Current source tree: 517 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
466
466
 
467
467
  ## Remaining Gaps
468
468
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.0.11",
4
- "stampSha256": "73ec5b7c6f2dceeefad0afb5d47446c3566a33ed6123db4bafcb26af38767fa6"
3
+ "version": "2.1.0",
4
+ "stampSha256": "f2df875754c421e593aab2718938fe29cad09077a079a5193f815d92c81f70f5"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.11",
3
+ "version": "2.1.0",
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.0.11"
59
+ "@openwop/spec-artifacts": "2.1.0"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 565,
6
- "tests": 2153,
7
- "withStableId": 2153,
5
+ "files": 566,
6
+ "tests": 2156,
7
+ "withStableId": 2156,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2108
9
+ "explicitIds": 2111
10
10
  },
11
11
  "records": [
12
12
  {
@@ -21615,7 +21615,7 @@
21615
21615
  {
21616
21616
  "id": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
21617
21617
  "file": "replay-fanout-suppression.test.ts",
21618
- "line": 158,
21618
+ "line": 159,
21619
21619
  "title": "delivers for a live run, suppresses for a replay fork, and delivers again for a branch fork",
21620
21620
  "explicitId": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
21621
21621
  "citations": [
@@ -28335,6 +28335,77 @@
28335
28335
  }
28336
28336
  ]
28337
28337
  },
28338
+ {
28339
+ "id": "openwop.it.v2-run-list.two-runs-the-caller-just-created-appear-every-id-is-bound-to-the-caller-s-tenant",
28340
+ "file": "v2-run-list.test.ts",
28341
+ "line": 62,
28342
+ "title": "two runs the caller just created appear, every id is bound to the caller's tenant, and no page exceeds maxPageSize",
28343
+ "explicitId": "openwop.requirement.0182.run-list.tenant-scoped",
28344
+ "citations": [
28345
+ {
28346
+ "section": "spec/v2/core/capabilities.md § runList",
28347
+ "requirement": null,
28348
+ "interpolated": true
28349
+ },
28350
+ {
28351
+ "section": "spec/v2/core/runs.md §List",
28352
+ "requirement": null,
28353
+ "interpolated": true
28354
+ },
28355
+ {
28356
+ "section": "spec/v2/core/runs.md §List",
28357
+ "requirement": null,
28358
+ "interpolated": true
28359
+ },
28360
+ {
28361
+ "section": "spec/v2/core/runs.md §List",
28362
+ "requirement": null,
28363
+ "interpolated": true
28364
+ },
28365
+ {
28366
+ "section": "spec/v2/core/identity.md §5",
28367
+ "requirement": null,
28368
+ "interpolated": true
28369
+ }
28370
+ ]
28371
+ },
28372
+ {
28373
+ "id": "openwop.it.v2-run-list.a-cursor-the-host-did-not-mint-is-refused-400-validation-error",
28374
+ "file": "v2-run-list.test.ts",
28375
+ "line": 84,
28376
+ "title": "a cursor the host did not mint is refused 400 validation_error",
28377
+ "explicitId": "openwop.requirement.0182.run-list.cursor-refused",
28378
+ "citations": [
28379
+ {
28380
+ "section": "spec/v2/core/runs.md §List",
28381
+ "requirement": null,
28382
+ "interpolated": true
28383
+ },
28384
+ {
28385
+ "section": "spec/v2/core/errors.md",
28386
+ "requirement": "the refusal MUST carry validation_error"
28387
+ }
28388
+ ]
28389
+ },
28390
+ {
28391
+ "id": "openwop.it.v2-run-list.when-the-filters-facet-names-workflowid-a-filtered-list-contains-only-that-workf",
28392
+ "file": "v2-run-list.test.ts",
28393
+ "line": 93,
28394
+ "title": "when the filters facet names workflowId, a filtered list contains only that workflow",
28395
+ "explicitId": "openwop.requirement.0182.run-list.filter-exact",
28396
+ "citations": [
28397
+ {
28398
+ "section": "spec/v2/core/runs.md §List",
28399
+ "requirement": null,
28400
+ "interpolated": true
28401
+ },
28402
+ {
28403
+ "section": "spec/v2/core/runs.md §List",
28404
+ "requirement": null,
28405
+ "interpolated": true
28406
+ }
28407
+ ]
28408
+ },
28338
28409
  {
28339
28410
  "id": "openwop.it.v2-run-options-limits.more-than-100-tags-a-tag-over-256-characters-and-an-unknown-root-key-are-each-re",
28340
28411
  "file": "v2-run-options-limits.test.ts",
@@ -28910,7 +28981,7 @@
28910
28981
  {
28911
28982
  "id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
28912
28983
  "file": "v2-webhook-durable-delivery.test.ts",
28913
- "line": 232,
28984
+ "line": 233,
28914
28985
  "title": "a failed attempt is retried and the event is delivered at least once",
28915
28986
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery",
28916
28987
  "citations": [
@@ -28941,7 +29012,7 @@
28941
29012
  {
28942
29013
  "id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
28943
29014
  "file": "v2-webhook-durable-delivery.test.ts",
28944
- "line": 313,
29015
+ "line": 314,
28945
29016
  "title": "an exhausted delivery is dead-lettered, never dropped",
28946
29017
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
28947
29018
  "citations": [
@@ -29746,7 +29817,7 @@
29746
29817
  {
29747
29818
  "id": "openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature",
29748
29819
  "file": "webhook-signed-delivery.test.ts",
29749
- "line": 165,
29820
+ "line": 169,
29750
29821
  "title": "host POSTs run events to subscriber with valid X-openwop-Signature",
29751
29822
  "explicitId": "openwop.it.webhook-signed-delivery.host-posts-run-events-to-subscriber-with-valid-x-openwop-signature",
29752
29823
  "citations": [
@@ -29797,7 +29868,7 @@
29797
29868
  {
29798
29869
  "id": "openwop.it.webhook-signed-delivery.control-with-a-tunnel-wired-the-host-still-refuses-the-loopback-receiver",
29799
29870
  "file": "webhook-signed-delivery.test.ts",
29800
- "line": 379,
29871
+ "line": 410,
29801
29872
  "title": "control: with a tunnel wired, the host still refuses the loopback receiver",
29802
29873
  "explicitId": "openwop.it.webhook-signed-delivery.control-with-a-tunnel-wired-the-host-still-refuses-the-loopback-receiver",
29803
29874
  "citations": [
@@ -1,17 +1,17 @@
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.0.11",
5
- "corpusTag": "v2.0.11",
4
+ "version": "2.1.0",
5
+ "corpusTag": "v2.1.0",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "94727fc973ebe2ed2fbe54dfec1300bc56d23b27f6d5f248672b68926921d64e",
13
- "api/v2/asyncapi.yaml": "8d8728a51cd6113cabb5c0ee700ddcae4b8232633f86024fcbbba5818d47ccc8",
14
- "api/v2/openapi.yaml": "aa98fa0c2efc8dfede85f94113e900b4bec5432d3b679b8e574a8d01a061a22e",
12
+ "api/seams-v2.yaml": "bf40af2260e55099ac68020a51f6d931ebca05f3bf385bc76cf57ad1d65d50a8",
13
+ "api/v2/asyncapi.yaml": "26a4a9a3a1f3ecf3a7e6e559941872877461b6677f2b0e0bb187332aec56d069",
14
+ "api/v2/openapi.yaml": "ec983d886bf26e1fc2f52ff456ce44e145b601ac1420e8a490bcee7a23fcdd0f",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -115,7 +115,7 @@
115
115
  "schemas/v2/artifact-type-pack-manifest.schema.json": "85630d1b760d0b41cf5a6fad2acfada39abaf7f0befd95cc4334e1e883e9615d",
116
116
  "schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
117
117
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
118
- "schemas/v2/capabilities.schema.json": "6219faf634cbd137824b66df3a1c71cc5fd964d56f8fc2f6089f226650dde6b5",
118
+ "schemas/v2/capabilities.schema.json": "7b9c3853c4792f8242babfd70d6336a2ec365d4702944701e833ee5d45445ff5",
119
119
  "schemas/v2/certification-bundle.schema.json": "d2cbc5cadddff13630a528dc9673162f582c035c6a6663ca02c1ac53e8fec40c",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "9475d46f358d21c19126e78204987a5633979917a2293653354ef5c5e044d54e",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
@@ -171,6 +171,7 @@
171
171
  "schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
172
172
  "schemas/v2/run-event-payloads.schema.json": "1d3ccffc4e506d3109c17990e01976ba935e61672562684b9635743d56af3f57",
173
173
  "schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
174
+ "schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
174
175
  "schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
175
176
  "schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
176
177
  "schemas/v2/run-snapshot.schema.json": "65a3c82220da2e1a54324665d517dc0e627225f3d37ef75b43bef3fb7e3bac0e",
@@ -199,7 +200,7 @@
199
200
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
200
201
  "spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
201
202
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
202
- "spec/v1/core-standard-manifest.json": "ef8d7fe2710c243c6d2451720167d8da0bbbebb6fb0ee64a420a3efdc296d8fe",
203
+ "spec/v1/core-standard-manifest.json": "2e1962fac9303bf8c6e8c74998312548f09383a0a83043743894de360775809c",
203
204
  "spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
204
205
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
205
206
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -211,28 +212,28 @@
211
212
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
212
213
  "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
213
214
  "spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
214
- "spec/v2/README.md": "202d95478ebbc7eb5efd2c3016168054bc16b263085eadb42d132e50d80c2e40",
215
- "spec/v2/core/capabilities.md": "fee5eb083ec5f6173c57e2673f4d5a264807f71ad00ef8847e51cefc552eea84",
216
- "spec/v2/core/conformance.md": "dc42444698fdac31bdb8cb9de0c4d36b25ee770158b6e4bab59c67f19560fa35",
217
- "spec/v2/core/connection-packs.md": "7f767bb12a39e737503625b163e088c432d2095c98a1040ae78044008101234f",
218
- "spec/v2/core/errors.md": "b46467fe39cf87fb3514ae8d498939a61cd7df306e73eaf5112833a8b6afb740",
219
- "spec/v2/core/events.md": "2f3aedf07e9267ff559cf48b106d433ce76d7ff1bb6fd5f4ddcaaa9edc3c0cb0",
220
- "spec/v2/core/form-content-packs.md": "405486e105b6d3b0bf727317af3dfc91af06f727d5bdf8991c71ddeef61d9bae",
221
- "spec/v2/core/headers.md": "882dfb34febcc669812c7cd1b0c640b6f40fc7a8e806cf49eef8d3ad1b8ec1c3",
222
- "spec/v2/core/idempotency.md": "220faad1e01c49cd2f5ab4849c5cd276850a11213906de38caba96a72c132320",
223
- "spec/v2/core/identity.md": "d2c87c8c59ac92cb1df1550afe910d069a21b6967a966c2d54b9c247a15646e9",
224
- "spec/v2/core/interop.md": "54a0e2c8b4ada51d5fa37f3828777a1f0ccf67120af79fcfbfad4fab9e2fa9ba",
225
- "spec/v2/core/interrupt.md": "c44bd714563224aa2c3806376a07b265017140be2643d7a3ac340198159ab36d",
226
- "spec/v2/core/overview.md": "3fef48a6e5b2a6ba01b881d48a3b671d5345c33475d15555dae6946599c25eb9",
227
- "spec/v2/core/packs.md": "00ea589b627f8027d39e4c99463530129815c973b437c8fbef1ad05d952051bf",
228
- "spec/v2/core/persistence.md": "682f2bad8c87df8af021e276c02f502e3d1131465d332e960f46e5caa66dedf0",
229
- "spec/v2/core/replay.md": "ebc4a530e583c31447e95457ca9a3f64e8a51d71ce838a66705b7ae88ee55ab7",
230
- "spec/v2/core/runs.md": "de5ed935f86edb7f5f3aeb7ae1f714ec1b586786c0096773ef0f58514cbc7edb",
231
- "spec/v2/core/security-defaults.md": "cf51b04fab31ef82376d08f33b5a62af9014be1e6db7240bf2493c37cf62d360",
232
- "spec/v2/core/versioning.md": "e98bb5de4261f6fac71e22be9f3a5b5e88060ea2b6d1d88870e4af88b097801b",
233
- "spec/v2/core/webhooks.md": "a1a32fb8f283f0bbc57cb35cdd5272b79657f1bb97df13a8f8daa232d06e8fc5",
234
- "spec/v2/core/workflow-chain-packs.md": "004935d6776658d1c33d06fa4fe9aba40efae4a9ca1e5031d759316d3144adaf",
235
- "spec/v2/declaration.json": "bd8a1dcee899a4478ac96b52da6ee07e1cb5fce4465939ac32e19e52f4d5c6b2",
215
+ "spec/v2/README.md": "7ae852012806ffaab24a5586e5eac107f0bc7b4932f278a93e4b549fcbba56ee",
216
+ "spec/v2/core/capabilities.md": "c5107a4964e487c20bbb5254cfe1f706e0d9ba7e34da9070bc43b50b385e7948",
217
+ "spec/v2/core/conformance.md": "622084e3c74c4549087f4d861b1bb98cec201a0c5aec23788d27f53a355fde3b",
218
+ "spec/v2/core/connection-packs.md": "1d759681e2962f5103228c52c45f24607ed4bb9ae73e30605f690c1d5a59e9ee",
219
+ "spec/v2/core/errors.md": "74fdb5862f432a831cf0c2eadbe0c8cca6b2660ec578180ae33db54773a1df28",
220
+ "spec/v2/core/events.md": "defe0969d6ec7c980f415cc0d78b9d065782ac7e5638b12a44aa4895a35d6126",
221
+ "spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
222
+ "spec/v2/core/headers.md": "9e307d2c6ce8a2f91802a753adfd245fd3d10cdc9135e021e76f461f92b866fb",
223
+ "spec/v2/core/idempotency.md": "b86c352d964aebad532727f314e26ed2177d49de382076fa64d3162e2f56118d",
224
+ "spec/v2/core/identity.md": "dd5299f9cdf54640870e1fffae11edb820cf6396ea298c4ebd14c649c4bd4a87",
225
+ "spec/v2/core/interop.md": "d4c404e469e1d9d9be88bc2c939d777e7d7c1d0419b1251c18770f039838a401",
226
+ "spec/v2/core/interrupt.md": "d872e0652a938594eeb2a19e86cf64e64e29b234f5d9c4fbe2970ab5fb191b9c",
227
+ "spec/v2/core/overview.md": "69377c90bbb87b6bfe4b8be4f1b65810094b0759c8d4f93ca6df92cc530ea023",
228
+ "spec/v2/core/packs.md": "e72a7586a61630cdfd342eaf84e989f864b3e199006f5511ef59ea7c1a1c56f7",
229
+ "spec/v2/core/persistence.md": "2373c2815b25b7b4fe29dfdf3783d3f2f3e637d3abce5085e69f019546302fb6",
230
+ "spec/v2/core/replay.md": "bd191a2565aa7ad0df9b35a0716f3de53be34b8722f1807c5e5b02859404a9b9",
231
+ "spec/v2/core/runs.md": "f7a95fc82fda5b7c0c613de6e5edd376bbd6d8ba4c665a500c94eacbe42b86f2",
232
+ "spec/v2/core/security-defaults.md": "1d38aa03b9d2507f1d4a985333cc57ae7892cecc7356f6342fbd1672e34f638a",
233
+ "spec/v2/core/versioning.md": "d731782eeabb953919c7c7057340b7f9c2916b6186790e6b04c7d8ea60767189",
234
+ "spec/v2/core/webhooks.md": "d360da78336afa837bddf515e289e445808bca02e1e4b493f81fa51ce4a545c4",
235
+ "spec/v2/core/workflow-chain-packs.md": "ef2b07d2d99334eaec0cc370314e2ad2f84ea58deb699e5d92e6e52d6056613f",
236
+ "spec/v2/declaration.json": "63dd351a87439241bc744ce62fdc498b7621292c11a4ad42c3f41429d72366b9",
236
237
  "spec/v2/declaration.schema.json": "eac5f8080bd572f147bd57d0c4ac8ad73b14536d9236222490ae29a33b21e184",
237
238
  "spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
238
239
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
@@ -262,14 +263,15 @@
262
263
  "spec/v2/facets/interrupt.schema.json": "c76c58c0abd99de619f8c8afad246568f5bc9cce3b7a0d097d16d87eba84be3a",
263
264
  "spec/v2/facets/mcp.schema.json": "f56609fe24ba6160ad29ae31aa16043354ff005e5cb6a0f3871c4d226087cc08",
264
265
  "spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
266
+ "spec/v2/facets/runList.schema.json": "6223e1baaebbb9f4faa71bfe968f8d22b4aa88046d0e43e8d5f56409e7f86e90",
265
267
  "spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
266
268
  "spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
267
269
  "spec/v2/id-field-bindings.json": "d9d1f8e266660b581f11020b9511a65c677e026b1a71ada870682ad77e18555d",
268
- "spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
270
+ "spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
269
271
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
270
272
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
271
- "spec/v2/release.json": "5e98d2087cd69d3d2a7a6ab011c2f8ee6c86a7e3c98630267b6175d067f23649",
273
+ "spec/v2/release.json": "4eea9fc280f05634fe3d5343252ce511267e61a981c30aac3d9491963f06ab18",
272
274
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
273
275
  },
274
- "corpusCommit": "2487cf4f9855684a4e874ec7cd8ad0a80b7b3ca6"
276
+ "corpusCommit": "1486657cb7efe8d010cfeec54498aa547e97a981"
275
277
  }
@@ -280,3 +280,47 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
280
280
 
281
281
  return { url: raw, tunnelled: true };
282
282
  }
283
+
284
+ /**
285
+ * Where a scenario's own webhook receiver should LISTEN, and what address the
286
+ * host under test should be told to POST to.
287
+ *
288
+ * Default (variable unset) is `127.0.0.1` for both, which is what every receiver
289
+ * in this suite hard-coded before this helper existed. That default is
290
+ * deliberate and must stay: in the ordinary in-process posture the host is in
291
+ * this very process, loopback is the correct and narrowest binding, and nothing
292
+ * outside the process has any business reaching a test receiver. This helper
293
+ * never widens that.
294
+ *
295
+ * `OPENWOP_CONFORMANCE_HARNESS_HOST` is the operator declaring the opposite —
296
+ * that the host under test runs somewhere `127.0.0.1` does not name this
297
+ * process (a container, a VM, another box) and that the given name is how it
298
+ * reaches this machine. Only then does the receiver bind `0.0.0.0` and
299
+ * advertise that name.
300
+ *
301
+ * MEASURED 2026-09-09, which is why this exists. A container lane ran the host
302
+ * as a real image with `--add-host host.docker.internal:host-gateway` and its
303
+ * compat/OIDC doubles reachable, and `webhook-signed-delivery` plus
304
+ * `replay-fanout-suppression` still failed. From inside the container:
305
+ *
306
+ * "msg":"webhook delivery failed","url":"http://127.0.0.1:55469/",
307
+ * "detail":"fetch failed (connect ECONNREFUSED 127.0.0.1:55469)"
308
+ *
309
+ * The address named the CONTAINER's loopback. Two independent blockers, both
310
+ * this suite's: the receiver advertised `127.0.0.1`, and it bound loopback-only
311
+ * so no external name could have reached it either. The lane was configured
312
+ * correctly and the host signs webhooks correctly; the scenarios simply could
313
+ * not be witnessed, and recorded a plain `fail` while doing it — a false
314
+ * accusation, and the mirror of a gate that cannot fail.
315
+ *
316
+ * This does NOT relax any SSRF gate. `host.docker.internal` is still a private
317
+ * address over plain `http`, so the three gates in `webhook-signed-delivery`'s
318
+ * docblock apply unchanged and a host must still opt in (or use
319
+ * `OPENWOP_WEBHOOK_RECEIVER_URL`, which waives nothing) to witness the row.
320
+ * All this fixes is that the packet had nowhere to go.
321
+ */
322
+ export function receiverBinding(): { bind: string; advertise: string } {
323
+ const advertise = process.env['OPENWOP_CONFORMANCE_HARNESS_HOST']?.trim();
324
+ if (!advertise) return { bind: '127.0.0.1', advertise: '127.0.0.1' };
325
+ return { bind: '0.0.0.0', advertise };
326
+ }
@@ -76,7 +76,7 @@ import { forkDeclined } from '../lib/fork-availability.js';
76
76
  import { discoveryFamilies, readCapabilityFamily } from '../lib/discovery-capabilities.js';
77
77
  import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
78
78
  import { isFixtureAdvertised } from '../lib/fixtures.js';
79
- import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
79
+ import { discoverOwnedTenant, receiverBinding } from '../lib/webhook-receiver.js';
80
80
  import { recordRequirement } from '../lib/requirement-ledger.js';
81
81
  import { requirementIdForFile } from '../lib/scenario-disposition.js';
82
82
  import { req } from '../lib/requirement-ids.js';
@@ -118,10 +118,11 @@ async function startReceiver(): Promise<{ server: Server; url: string; received:
118
118
  res.end();
119
119
  });
120
120
  });
121
- await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
121
+ const binding = receiverBinding();
122
+ await new Promise<void>((resolve) => server.listen(0, binding.bind, () => resolve()));
122
123
  const addr = server.address();
123
124
  if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
124
- return { server, url: `http://127.0.0.1:${addr.port}/`, received };
125
+ return { server, url: `http://${binding.advertise}:${addr.port}/`, received };
125
126
  }
126
127
 
127
128
  let activeServer: Server | null = null;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * RFC 0182 — `run-list` (suite 2.1.0, target major 2; gated on the `runList`
3
+ * family; `spec/v2/core/runs.md` §List).
4
+ *
5
+ * Witness class: witnessable — gated. A host that advertises `runList` MUST
6
+ * serve `GET /runs` as a tenant-scoped, cursor-paginated list of the caller's
7
+ * runs: two runs the suite just created appear (§A.2), every `runId` in the
8
+ * body carries the caller's tenant segment — the segment of the ids the host
9
+ * minted for this credential moments ago (§A.2), no page exceeds the advertised
10
+ * `maxPageSize` (§A.3), a cursor the host did not mint is refused
11
+ * `400 validation_error` (§A.3), and when the `filters` facet names
12
+ * `workflowId` the filtered list contains only that workflow (§A.4). The body
13
+ * is validated against `run-list-response.schema.json`, which `$ref`s
14
+ * `run-snapshot.schema.json` — so a bare id anywhere fails the shape leg
15
+ * before it fails the tenant leg.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
20
+ import { gateFamily, v2Discovery, v2Validator } from '../lib/v2.js';
21
+ import { readErrorCode } from '../lib/error-envelope.js';
22
+ import { softSkip } from '../lib/soft-skip.js';
23
+ import { req } from '../lib/requirement-ids.js';
24
+
25
+ const DOC = 'spec/v2/core/runs.md §List';
26
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
27
+ const MAX_PAGES = 50;
28
+
29
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
30
+ try { return await fn(); } catch { return null; }
31
+ }
32
+
33
+ async function createRun(): Promise<{ runId: string } | { reason: string }> {
34
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
35
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
36
+ const runId = (res.json as { runId?: unknown } | undefined)?.runId;
37
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
38
+ return { runId };
39
+ }
40
+
41
+ function tenantOf(runId: string): string { return runId.slice(0, runId.indexOf('/')); }
42
+
43
+ type Page = { runs?: unknown; nextCursor?: unknown };
44
+
45
+ /** Walk the list from the first page, collecting run ids, until the last page or MAX_PAGES. */
46
+ async function walk(query: string, maxPageSize: number): Promise<{ ids: string[]; pages: Page[]; reason?: string }> {
47
+ const ids: string[] = []; const pages: Page[] = []; let cursor: string | undefined;
48
+ for (let i = 0; i < MAX_PAGES; i++) {
49
+ const q = `${query}${query ? '&' : '?'}limit=${maxPageSize}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
50
+ const res = await http(() => driver.get(`/runs${q}`));
51
+ if (res === null) return { ids, pages, reason: `GET /runs${q} unreachable (fetch failed)` };
52
+ if (res.status !== 200) return { ids, pages, reason: `GET /runs${q} answered ${res.status} ${readErrorCode(res.json) ?? ''}` };
53
+ const page = res.json as Page; pages.push(page);
54
+ for (const r of Array.isArray(page.runs) ? page.runs : []) { const id = (r as { runId?: unknown }).runId; if (typeof id === 'string') ids.push(id); }
55
+ if (typeof page.nextCursor !== 'string') break;
56
+ cursor = page.nextCursor;
57
+ }
58
+ return { ids, pages };
59
+ }
60
+
61
+ describe('RFC 0182 — run-list (gated on runList)', () => {
62
+ it('two runs the caller just created appear, every id is bound to the caller\'s tenant, and no page exceeds maxPageSize', async () => {
63
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
64
+ const fam = await gateFamily('runList');
65
+ if (!fam) return softSkip('inapplicable', 'runList family not advertised — no obligation (gate recorded under openwop.family.runList)');
66
+ const maxPageSize = typeof fam['maxPageSize'] === 'number' ? fam['maxPageSize'] : NaN;
67
+ expect(Number.isInteger(maxPageSize) && maxPageSize >= 1, req('openwop.requirement.0182.run-list.tenant-scoped', 'spec/v2/core/capabilities.md § runList', `runList.maxPageSize MUST be an integer ≥ 1 (got ${String(fam['maxPageSize'])})`)).toBe(true);
68
+ const a = await createRun(); if ('reason' in a) return softSkip('blocked', a.reason);
69
+ const b = await createRun(); if ('reason' in b) return softSkip('blocked', b.reason);
70
+ const tenant = tenantOf(a.runId);
71
+ const walked = await walk('', maxPageSize);
72
+ if (walked.reason && walked.pages.length === 0) return softSkip('blocked', walked.reason);
73
+ const validate = v2Validator('run-list-response');
74
+ for (const page of walked.pages) {
75
+ const v = validate(page);
76
+ expect(v.ok, req('openwop.requirement.0182.run-list.tenant-scoped', DOC, `every page MUST validate against run-list-response.schema.json — runs[] of RunSnapshot (bound runId) plus optional nextCursor (${v.errors})`)).toBe(true);
77
+ expect((page.runs as unknown[]).length <= maxPageSize, req('openwop.requirement.0182.run-list.tenant-scoped', DOC, `a page MUST NOT exceed the advertised runList.maxPageSize (${maxPageSize}); got ${(page.runs as unknown[]).length}`)).toBe(true);
78
+ }
79
+ expect(walked.ids.includes(a.runId) && walked.ids.includes(b.runId), req('openwop.requirement.0182.run-list.tenant-scoped', DOC, `a run the caller just created MUST appear in its unfiltered list — created ${a.runId} and ${b.runId}; walked ${walked.ids.length} id(s) over ${walked.pages.length} page(s)${walked.reason ? ` (${walked.reason})` : ''}`)).toBe(true);
80
+ const foreign = walked.ids.filter((id) => tenantOf(id) !== tenant);
81
+ expect(foreign, req('openwop.requirement.0182.run-list.tenant-scoped', 'spec/v2/core/identity.md §5', `every runId in the list MUST carry the caller's tenant segment (${tenant}) — the list is tenant-scoped by construction; found ${foreign.length} other(s): ${foreign.slice(0, 3).join(', ')}`)).toEqual([]);
82
+ });
83
+
84
+ it('a cursor the host did not mint is refused 400 validation_error', async () => {
85
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
86
+ if (!(await gateFamily('runList'))) return softSkip('inapplicable', 'runList family not advertised — no obligation (gate recorded under openwop.family.runList)');
87
+ const res = await http(() => driver.get('/runs?cursor=openwop-conformance-not-a-cursor'));
88
+ if (res === null) return softSkip('blocked', 'GET /runs?cursor=… unreachable (fetch failed)');
89
+ expect(res.status, req('openwop.requirement.0182.run-list.cursor-refused', DOC, `a cursor the host did not mint MUST be refused 400 validation_error; got ${res.status} ${readErrorCode(res.json) ?? ''}`)).toBe(400);
90
+ expect(readErrorCode(res.json), req('openwop.requirement.0182.run-list.cursor-refused', 'spec/v2/core/errors.md', 'the refusal MUST carry validation_error')).toBe('validation_error');
91
+ });
92
+
93
+ it('when the filters facet names workflowId, a filtered list contains only that workflow', async () => {
94
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
95
+ const fam = await gateFamily('runList');
96
+ if (!fam) return softSkip('inapplicable', 'runList family not advertised — no obligation (gate recorded under openwop.family.runList)');
97
+ const filters = Array.isArray(fam['filters']) ? (fam['filters'] as unknown[]) : [];
98
+ if (!filters.includes('workflowId')) return softSkip('inapplicable', 'runList.filters does not name workflowId — the filter leg has no obligation');
99
+ const maxPageSize = typeof fam['maxPageSize'] === 'number' ? fam['maxPageSize'] : 1;
100
+ const c = await createRun(); if ('reason' in c) return softSkip('blocked', c.reason);
101
+ const walked = await walk(`?workflowId=${encodeURIComponent(NOOP_WORKFLOW_ID)}`, maxPageSize);
102
+ if (walked.reason && walked.pages.length === 0) return softSkip('blocked', walked.reason);
103
+ const others = walked.pages.flatMap((p) => (Array.isArray(p.runs) ? p.runs : [])).filter((r) => (r as { workflowId?: unknown }).workflowId !== NOOP_WORKFLOW_ID);
104
+ expect(others.length, req('openwop.requirement.0182.run-list.filter-exact', DOC, `a workflowId filter MUST be exact — every listed run's workflowId MUST equal ${NOOP_WORKFLOW_ID}; found ${others.length} other(s)`)).toBe(0);
105
+ expect(walked.ids.includes(c.runId), req('openwop.requirement.0182.run-list.filter-exact', DOC, `the filtered list MUST still contain the ${NOOP_WORKFLOW_ID} run the caller just created (${c.runId})`)).toBe(true);
106
+ });
107
+ });
@@ -31,7 +31,7 @@ import { afterEach, describe, it, expect } from 'vitest';
31
31
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
32
32
  import { driver } from '../lib/driver.js';
33
33
  import { v2Discovery, gateFamily } from '../lib/v2.js';
34
- import { resolveRegistrationUrl } from '../lib/webhook-receiver.js';
34
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
35
35
  import { readErrorCode } from '../lib/error-envelope.js';
36
36
  import { softSkip } from '../lib/soft-skip.js';
37
37
  import { req } from '../lib/requirement-ids.js';
@@ -74,10 +74,11 @@ async function startReceiver(failFirst: number): Promise<{ server: Server; url:
74
74
  });
75
75
  const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
76
76
  const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
77
- await new Promise<void>((resolve) => server.listen(bindPort, '127.0.0.1', () => resolve()));
77
+ const binding = receiverBinding();
78
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
78
79
  const addr = server.address();
79
80
  if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
80
- return { server, url: `http://127.0.0.1:${addr.port}/`, attempts };
81
+ return { server, url: `http://${binding.advertise}:${addr.port}/`, attempts };
81
82
  }
82
83
 
83
84
  let active: Server | null = null;
@@ -18,8 +18,11 @@
18
18
  *
19
19
  * Operator contract — THREE gates, not one (clarified 2026-08-25).
20
20
  *
21
- * The test receiver is `http://127.0.0.1:{port}/`, and `webhooks.md`
22
- * forbids it three separate times. A host honoring
21
+ * The test receiver is `http://127.0.0.1:{port}/` or, where the operator has
22
+ * declared `OPENWOP_CONFORMANCE_HARNESS_HOST` because the host under test is
23
+ * off-process, `http://{that name}:{port}/` (see `receiverBinding`). Either
24
+ * way it is a private address over plain `http`, and `webhooks.md` forbids it
25
+ * three separate times. A host honoring
23
26
  * `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` (or an equivalent opt-in) MUST
24
27
  * relax ALL THREE for this scenario to be witnessable:
25
28
  *
@@ -107,7 +110,7 @@ import { driver } from '../lib/driver.js';
107
110
  import { discoveryFamilies } from '../lib/discovery-capabilities.js';
108
111
  import { pollUntilTerminal } from '../lib/polling.js';
109
112
  import { isFixtureAdvertised } from '../lib/fixtures.js';
110
- import { discoverOwnedTenant, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
113
+ import { discoverOwnedTenant, receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
111
114
  import { req } from '../lib/requirement-ids.js';
112
115
 
113
116
  interface DeliveredRequest {
@@ -141,10 +144,11 @@ async function startReceiver(): Promise<{ server: Server; url: string; received:
141
144
  // pins it so `ngrok http <port>` (or a proxy) has a stable target.
142
145
  const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
143
146
  const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
144
- await new Promise<void>((resolve) => server.listen(bindPort, '127.0.0.1', () => resolve()));
147
+ const binding = receiverBinding();
148
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
145
149
  const addr = server.address();
146
150
  if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
147
- return { server, url: `http://127.0.0.1:${addr.port}/`, received };
151
+ return { server, url: `http://${binding.advertise}:${addr.port}/`, received };
148
152
  }
149
153
 
150
154
  let activeServer: Server | null = null;
@@ -254,9 +258,6 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
254
258
  const runId = (create.json as { runId: string }).runId;
255
259
  await pollUntilTerminal(runId, { timeoutMs: 10_000 });
256
260
 
257
- // Allow a small grace period for fire-and-forget delivery to land.
258
- await new Promise((resolve) => setTimeout(resolve, 500));
259
-
260
261
  // Test-isolation note: when this scenario runs concurrently with
261
262
  // other webhook-bearing scenarios against a stateful host, the
262
263
  // host's webhook registry fans out EVERY run's events to EVERY
@@ -264,14 +265,43 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
264
265
  // deliveries from other tests' concurrent runs. Filter to events
265
266
  // carrying THIS test's runId so the assertion checks the
266
267
  // signature shape on a delivery the host emitted for THIS run.
267
- const ourDeliveries = receiver.received.filter((d) => {
268
- try {
269
- const body = JSON.parse(d.body) as { runId?: unknown };
270
- return body.runId === runId;
271
- } catch {
272
- return false;
273
- }
274
- });
268
+ const ours = (): DeliveredRequest[] =>
269
+ receiver.received.filter((d) => {
270
+ try {
271
+ const body = JSON.parse(d.body) as { runId?: unknown };
272
+ return body.runId === runId;
273
+ } catch {
274
+ return false;
275
+ }
276
+ });
277
+
278
+ // WAIT for a delivery, don't sleep a guessed interval for one.
279
+ //
280
+ // This was `setTimeout(500)` — a fixed grace "for fire-and-forget delivery
281
+ // to land". That assumes delivery is synchronous-ish with run completion,
282
+ // which is true of a host that POSTs inline from its event loop and false
283
+ // of one that does what `webhooks.md` §"Delivery" actually asks for: a
284
+ // durable queue with retries, drained by a worker on its own cadence.
285
+ //
286
+ // MEASURED 2026-09-09 against a reference host whose delivery worker polls
287
+ // every 1000ms: with the receiver address finally correct, this assertion
288
+ // still reported `expected 0 to be greater than 0` after a 549ms test — the
289
+ // sleep had expired before the worker's first tick. It reads as "the host
290
+ // does not deliver webhooks" when the host had not yet been asked to. The
291
+ // more durable a host's delivery machinery, the more exposed it is here.
292
+ //
293
+ // A deadline is the honest instrument: it still FAILS, never skips, and the
294
+ // failure now means "did not deliver within DELIVERY_DEADLINE_MS" rather
295
+ // than "did not deliver within one arbitrary tick of an unrelated clock".
296
+ // Sized to stay inside the 30s per-test budget alongside the 10s terminal
297
+ // poll above, so a slow host fails on THIS assertion's message rather than
298
+ // on an uninformative vitest timeout.
299
+ const DELIVERY_DEADLINE_MS = 12_000;
300
+ const deadline = Date.now() + DELIVERY_DEADLINE_MS;
301
+ while (ours().length === 0 && Date.now() < deadline) {
302
+ await new Promise((resolve) => setTimeout(resolve, 250));
303
+ }
304
+ const ourDeliveries = ours();
275
305
  // With a tunnel wired this is the assertion that keeps a mis-wired front
276
306
  // from reading as a pass. Registration succeeded, so the host believes it
277
307
  // has a subscriber; if nothing arrived HERE, the delivery went somewhere
@@ -284,7 +314,8 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
284
314
  'Registration was accepted, so zero deliveries observed on the local receiver means ' +
285
315
  'either the host did not deliver, or OPENWOP_WEBHOOK_RECEIVER_URL does not actually ' +
286
316
  'front this process. Both are failures; neither is a skip.'
287
- : 'host MUST POST at least one event for THIS run to a registered subscriber after run.completed',
317
+ : 'host MUST POST at least one event for THIS run to a registered subscriber within '
318
+ + `${DELIVERY_DEADLINE_MS}ms of run.completed`,
288
319
  )).toBeGreaterThan(0);
289
320
 
290
321
  // Validate the FIRST delivery's signature contract. Other deliveries