@openwop/openwop-conformance 2.6.0 → 2.7.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,31 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.7.0] — 2026-09-18 — the dead-letter read gets its scenarios
4
+
5
+ ### Added
6
+
7
+ - `v2-webhook-durable-delivery` gains two legs: the dead-letter read is served,
8
+ and its records carry no delivered payload. Its §Durability leg stops
9
+ soft-skipping unconditionally — it now asserts the exhausted delivery is IN
10
+ the sink, the half no bundle has ever witnessed.
11
+ - `v2-bound-id-kinds`'s `deliveryId` leg stops recording a corpus gap and
12
+ asserts the bound grammar on a real record.
13
+
14
+ Both are gated on the new `webhooks.deadLetter` facet; a host without it records
15
+ `inapplicable`, not `blocked`.
16
+
17
+ ## [2.6.1] — 2026-09-18 — the correction that half-landed
18
+
19
+ **Why a patch.** No conformance code changes; two schema descriptions.
20
+
21
+ ### Fixed
22
+
23
+ - The unsatisfiable `deliveryId == causationId` claim survived 2.6.0 on
24
+ `run-event-payloads.schema.json`'s `triggerDeliveryAttempted.runId` — the
25
+ durable event-log payload, and the more load-bearing of the two sites.
26
+ - `trigger-event.schema.json` pointed the causing-delivery seat at
27
+ `webhooks.md`, which is the OUTBOUND document for an INBOUND pointer.
28
+
3
29
  ## [2.6.0] — 2026-09-18 — a relaxed profile cannot certify, and five corpus ids for the umbrella
4
30
 
5
31
  ### Fixed
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.6.0 @openwop/spec-artifacts@2.6.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.7.0 @openwop/spec-artifacts@2.7.0
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.6.0",
4
- "stampSha256": "68531a1a3ebfe85e53f55273ab72f627ab8730218fd8557395633bef0a0c8338"
3
+ "version": "2.7.0",
4
+ "stampSha256": "f3306d234e5e77aaa9252563cb102afcad9e8950f37f26c147acf1c92334e900"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.6.0",
3
+ "version": "2.7.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.6.0"
59
+ "@openwop/spec-artifacts": "2.7.0"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -3,10 +3,10 @@
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
5
  "files": 576,
6
- "tests": 2179,
7
- "withStableId": 2179,
6
+ "tests": 2181,
7
+ "withStableId": 2181,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2132
9
+ "explicitIds": 2135
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26055,12 +26055,17 @@
26055
26055
  ]
26056
26056
  },
26057
26057
  {
26058
- "id": "openwop.it.v2-bound-id-kinds.deliveryid-the-kind-has-no-wire-surface-to-be-bound-on-a-corpus-gap-recorded",
26058
+ "id": "openwop.it.v2-bound-id-kinds.deliveryid-every-record-in-the-dead-letter-read-carries-a-bound-id-in-the-caller",
26059
26059
  "file": "v2-bound-id-kinds.test.ts",
26060
26060
  "line": 131,
26061
- "title": "deliveryId: the kind has no wire surface to be bound on — a corpus gap, recorded",
26062
- "explicitId": null,
26063
- "citations": []
26061
+ "title": "deliveryId: every record in the dead-letter read carries a bound id in the caller's tenant",
26062
+ "explicitId": "openwop.requirement.0187.bound-id-kinds.per-kind",
26063
+ "citations": [
26064
+ {
26065
+ "section": "RFC 0188 §A.1",
26066
+ "requirement": "a host advertising webhooks.deadLetter MUST serve the dead-letter read (200)"
26067
+ }
26068
+ ]
26064
26069
  },
26065
26070
  {
26066
26071
  "id": "openwop.it.v2-bound-id-path-projection.a-tenant-bound-id-is-readable-at-its-escaped-path-segment-links-carry-that-form",
@@ -29388,7 +29393,7 @@
29388
29393
  {
29389
29394
  "id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
29390
29395
  "file": "v2-webhook-durable-delivery.test.ts",
29391
- "line": 233,
29396
+ "line": 234,
29392
29397
  "title": "a failed attempt is retried and the event is delivered at least once",
29393
29398
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery",
29394
29399
  "citations": [
@@ -29419,7 +29424,7 @@
29419
29424
  {
29420
29425
  "id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
29421
29426
  "file": "v2-webhook-durable-delivery.test.ts",
29422
- "line": 321,
29427
+ "line": 322,
29423
29428
  "title": "an exhausted delivery is dead-lettered, never dropped",
29424
29429
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
29425
29430
  "citations": [
@@ -29435,6 +29440,36 @@
29435
29440
  "section": "webhooks.md §Durability",
29436
29441
  "requirement": null,
29437
29442
  "interpolated": true
29443
+ },
29444
+ {
29445
+ "section": "webhooks.md §Durability",
29446
+ "requirement": "an exhausted delivery MUST be routed to the sink, not dropped — the half no bundle could witness before RFC 0188 served a read"
29447
+ }
29448
+ ]
29449
+ },
29450
+ {
29451
+ "id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
29452
+ "file": "v2-webhook-durable-delivery.test.ts",
29453
+ "line": 379,
29454
+ "title": "the dead-letter read is served and its records carry no payload",
29455
+ "explicitId": "openwop.requirement.0188.dead-letter-read",
29456
+ "citations": [
29457
+ {
29458
+ "section": "RFC 0188 §A.1",
29459
+ "requirement": "a host advertising webhooks.deadLetter MUST serve GET /webhooks/{webhookId}/dead-letters"
29460
+ }
29461
+ ]
29462
+ },
29463
+ {
29464
+ "id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
29465
+ "file": "v2-webhook-durable-delivery.test.ts",
29466
+ "line": 400,
29467
+ "title": "a dead-letter record carries no delivered payload",
29468
+ "explicitId": "openwop.requirement.0188.dead-letter-content-free",
29469
+ "citations": [
29470
+ {
29471
+ "section": "RFC 0188 §B.1",
29472
+ "requirement": "a dead-letter record MUST NOT carry the delivered body, headers or subscription secret — the queue is precisely the traffic the subscriber never received"
29438
29473
  }
29439
29474
  ]
29440
29475
  },
@@ -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.6.0",
5
- "corpusTag": "v2.6.0",
4
+ "version": "2.7.0",
5
+ "corpusTag": "v2.7.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": "7f6637d38e3c4e6abd26486045250e49510e47f88c369d182c4c58f9100e641b",
13
- "api/v2/asyncapi.yaml": "8cbc0edc6dbf62726ff48e1a1d5f6ac90e2eecee42b005ab5b3d093aa2d11f81",
14
- "api/v2/openapi.yaml": "ea0138814ca4766e2575ab5964ff03c7dba0a41ba7041897f5f1d1ac5808680b",
12
+ "api/seams-v2.yaml": "0a44c26467543367068e89db69a0e01f3e95c7f5e41708bc18c625d252c6091e",
13
+ "api/v2/asyncapi.yaml": "5a431c280d7a7f3eeaabc5a6ee404762336d9cb4e615154fdc898f2b6a6ab557",
14
+ "api/v2/openapi.yaml": "ad75daf10c3f9bfb909f496c75a9480c967f699fbdd6ca1c722aef577c5c5319",
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": "fa9b80a9107fc322ecadbac808e7fbe5e16ccf0af1a6bf673181faa8379dde33",
118
+ "schemas/v2/capabilities.schema.json": "5c6c6a70c73cb9cd0d61594e205a9bc2fd7905c41c7d0f43ae7e50523fdd85a9",
119
119
  "schemas/v2/certification-bundle.schema.json": "0bcf8f297266505ca39d30eea8100e3c0fb048f2651717fd746da8129fcb7920",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
@@ -169,7 +169,7 @@
169
169
  "schemas/v2/residency.schema.json": "3fd23c9732beb140504e81560fc426eb6764d547e56da5fb92c5a5c7bf04db3a",
170
170
  "schemas/v2/run-ancestry-response.schema.json": "334c17f2062dea62726ca1d07b8be3f169cdc422af843c1def5fb317039814c9",
171
171
  "schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
172
- "schemas/v2/run-event-payloads.schema.json": "29dec16f0d40c9022c169d4d413742ddd775a3d8c895d3a60ccc093e2a872439",
172
+ "schemas/v2/run-event-payloads.schema.json": "ec5c934b9ef80404bd4ddc9e715cd1629fc7e7934d09d3eb81b65dcef2e2ff9a",
173
173
  "schemas/v2/run-event.schema.json": "2f0d7416bd376c56affe97b6b2b49981532e8e9ff45c0a162cf806c78b3e7fb7",
174
174
  "schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
175
175
  "schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
@@ -183,10 +183,11 @@
183
183
  "schemas/v2/subject.schema.json": "094e981fb20052a911393e219ea5f2065d8b8417b65080a70c5ea99fd960a8fe",
184
184
  "schemas/v2/suspend-request.schema.json": "4a0dda88a257cdb21c9b57fc9ad898d91de0d75bc4944a693eb950b709f58c91",
185
185
  "schemas/v2/tool-descriptor.schema.json": "658b18ef173e0f12d732f92d4866a9b3daeb8f88b493db8b025a577e09dd681c",
186
- "schemas/v2/trigger-event.schema.json": "66077c3b3d5b81591270655643ae8957c4c42aaf7cf31c45e8e45cb1b73fde63",
186
+ "schemas/v2/trigger-event.schema.json": "4e61e5a761b7eb71c9d36536eea4341dd5db21262e64d720f9c81b18d57c4dd0",
187
187
  "schemas/v2/trigger-subscription-registration.schema.json": "04bc314a07c7e8b565577cb8df5821d968ce2af915900dfdccd4a24e928d20db",
188
188
  "schemas/v2/trigger-subscription.schema.json": "c887d4b9a12be0133f8c9dfd3a8bafcb518e27ecc2e800447ebc4d1fef111503",
189
189
  "schemas/v2/ui-plugin-message.schema.json": "879d65867c01656b3962d64283e66364dcb8f626ad26c5fcf2d2d55a79ae0ce9",
190
+ "schemas/v2/webhook-dead-letter-page.schema.json": "543f5ccc26d8f777684a037172ab7b9fdf70735e5394eeb42e5fa6569503f528",
190
191
  "schemas/v2/webhook-delivery.schema.json": "4e8ab3b722ad1731adf0548c1217f0de62da40de14e8abf77533aee4ace4c079",
191
192
  "schemas/v2/workflow-chain-pack-manifest.schema.json": "b61cc945b4df1c7093a1058fa71ee59ef10bacb3433ae4d7ae7536136e616cc2",
192
193
  "schemas/v2/workflow-definition.schema.json": "9aafddec32b84eb6aec3185be024850f1b9444c6193568771b0b636595fef136",
@@ -200,7 +201,7 @@
200
201
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
201
202
  "spec/v1/alias-detectors.json": "e90916db12d2297e2e560b2d54d2c4702d6cb3bef7179a575844687a7ec17262",
202
203
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
203
- "spec/v1/core-standard-manifest.json": "de1f6bd9dc3f602b6893d66cbfb79a055457e18d0be5ff70a2611ee69f672727",
204
+ "spec/v1/core-standard-manifest.json": "bbb35b8db5c1670f08ac1948f75112344f1200ae935ae7d19db33f8d9ed94f65",
204
205
  "spec/v1/deprecations.json": "c81c8f71460936c8d489d8270477c7661cf4a5d7b7bad86c98899750b5656027",
205
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
206
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -219,7 +220,7 @@
219
220
  "spec/v2/core/errors.md": "c3ac131dff970ab7574629d72735ace52cc56cd4a7ab5d1206ce91f382bc9a68",
220
221
  "spec/v2/core/events.md": "6ab8c727df3ebc5a60de3837c82a0075b4357d6f7e28435c6f80585687d41a76",
221
222
  "spec/v2/core/form-content-packs.md": "d4c5295dfd28972be205c87c34d0d34ced64f4a2561ebff7f9d24b436d83df25",
222
- "spec/v2/core/headers.md": "6c5eb48d998c141b5f0cb61fea926833a072748fdc4034e1a90a4dfe5448659c",
223
+ "spec/v2/core/headers.md": "14cdf4ae9ee7d80eebfd426d7c3552e577baea0300915cd4bb3a68e8588195a8",
223
224
  "spec/v2/core/idempotency.md": "803cd85947b1b2151cd2fb42cf0e0c52c9bcd19cdc6c23dcf34b1eddc600921a",
224
225
  "spec/v2/core/identity.md": "ae24870df315b527dfe9ecf7a4c26582bcc1113da7954f44acf69bf463ac390d",
225
226
  "spec/v2/core/interop.md": "8d8f4b040cb3625044c6aa586c8dcf5e9d15797de3d3b1cea631f44b8924701c",
@@ -231,9 +232,9 @@
231
232
  "spec/v2/core/runs.md": "b4c0bb2869077fcbf26e15bc6bbac06819f68b8241944b903a275a37db1c71fa",
232
233
  "spec/v2/core/security-defaults.md": "9ab1d1a88ccd0929f354422b151ec6d3164e8cd3784f8e58bb6f11cb7ee1e7d5",
233
234
  "spec/v2/core/versioning.md": "27b88cde88f9060567ef7e46f4897b6be8892ade9f4a46c2ab16e314c515eabd",
234
- "spec/v2/core/webhooks.md": "ecec4d22a35aeafd34276bb0579ae28610e79ab8850ebf42188cb51bc946a093",
235
+ "spec/v2/core/webhooks.md": "e08a736fd0bfdc79f5c2309c66f53caf96fac8d9954e0a184c2229de4ce286e9",
235
236
  "spec/v2/core/workflow-chain-packs.md": "26bb65721f826285b7d90c3cb62fa7e9fb2414c1bc6e0fd25b33209168c3102e",
236
- "spec/v2/declaration.json": "a09bac61c1b825e4a77b5abc0f86f1f8c20c381e0650eb08c2f6d0ef0aaec1dc",
237
+ "spec/v2/declaration.json": "0a5119e69983ea74adea739a4ca2aa206be7d1d4e32373c739b53fd0ae6d98d8",
237
238
  "spec/v2/declaration.schema.json": "c86e3083fb9789c1958ecb4576cc6f71aa90e6f339521139238543ed08211dd4",
238
239
  "spec/v2/errors.json": "4ff032033f02d7e3fb4204581456408ba5e7642b438c1f349bbd1c264e63e419",
239
240
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
@@ -266,13 +267,13 @@
266
267
  "spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
267
268
  "spec/v2/facets/runList.schema.json": "6223e1baaebbb9f4faa71bfe968f8d22b4aa88046d0e43e8d5f56409e7f86e90",
268
269
  "spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
269
- "spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
270
+ "spec/v2/facets/webhooks.schema.json": "4bf3f8eb3161a43910efac4c44ccaf9567060c9da52d4cf09bcf3888d1b92192",
270
271
  "spec/v2/id-field-bindings.json": "e73c06f3055ad30173507ac817a458b96362c8f26209ba105c53653292cf63d3",
271
- "spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
272
+ "spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
272
273
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
273
274
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
274
- "spec/v2/release.json": "2c0e277e6ef93e5f439947c546f3ec10696d1f3a7235dedaf5d6973954d1652d",
275
+ "spec/v2/release.json": "fe784abdffc2089df66c6eda874d61a50b45912f8817b90051a91dc9c624380f",
275
276
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
276
277
  },
277
- "corpusCommit": "dff4852f36271da77a30a88494cde1e027199d7c"
278
+ "corpusCommit": "35659ff7e741cc827a52f41140cac72aa0a4abeb"
278
279
  }
@@ -128,9 +128,23 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
128
128
  for (const e of effects) expectBound(e['effectId'], 'effectId', 'GET /runs/{runId}/effects', PER_KIND);
129
129
  });
130
130
 
131
- it('deliveryId: the kind has no wire surface to be bound on — a corpus gap, recorded', async () => {
131
+ it('deliveryId: every record in the dead-letter read carries a bound id in the caller\'s tenant', async () => {
132
+ // Until RFC 0188 this leg was an unconditional softSkip recording a CORPUS
133
+ // gap: `identity.md` §5 bound the kind and no v2 surface returned one, so no
134
+ // host could be held to it. The dead-letter read is that surface.
132
135
  if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
133
- if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
134
- return softSkip('inapplicable', 'identity.md §5 binds deliveryId, but api/v2/openapi.yaml serves no dead-letter read (a GET /webhooks/{webhookId}/dead-letters projection is needed) — the kind has nowhere to appear on the wire, so no host can be held to it. RFC 0187 §Unresolved; v2-webhook-durable-delivery records the same absence for exhaustion.');
136
+ const fam = await gateFamily('webhooks');
137
+ if (!fam) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
138
+ if (!fam['deadLetter']) return softSkip('inapplicable', 'host does not advertise the webhooks.deadLetter facet — it serves no dead-letter read, and RFC 0188 §A.5 makes that a 404 rather than an obligation');
139
+ const reg = await http(() => driver.post('/webhooks', { url: 'https://subscriber.invalid/hook', events: ['run.completed'] }));
140
+ if (reg === null || reg.status !== 201) return softSkip('blocked', `POST /webhooks answered ${reg?.status ?? 'no response'} — no subscription to read a sink for`);
141
+ const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
142
+ if (typeof webhookId !== 'string') return softSkip('blocked', 'the mint returned no webhookId');
143
+ const res = await http(() => driver.get(`/webhooks/${projectBoundId(webhookId)}/dead-letters`));
144
+ if (res === null) return softSkip('blocked', 'GET /webhooks/{webhookId}/dead-letters unreachable (fetch failed)');
145
+ expect(res.status, req(PER_KIND, 'RFC 0188 §A.1', 'a host advertising webhooks.deadLetter MUST serve the dead-letter read (200)')).toBe(200);
146
+ const rows = ((res.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
147
+ if (rows.length === 0) return softSkip('inapplicable', 'the subscription has no dead-lettered delivery in this run — the read is served and the shape is unwitnessed here; v2-webhook-durable-delivery drives an exhaustion and asserts the record');
148
+ for (const r of rows) expectBound(r['deliveryId'], 'deliveryId', 'GET /webhooks/{webhookId}/dead-letters', PER_KIND);
135
149
  });
136
150
  });
@@ -31,6 +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 { projectBoundId } from '../lib/bound-id.js';
34
35
  import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
35
36
  import { readErrorCode } from '../lib/error-envelope.js';
36
37
  import { softSkip } from '../lib/soft-skip.js';
@@ -357,11 +358,64 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
357
358
  req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', `retries MUST stop at the advertised retryPolicy.maxAttempts (${policy.maxAttempts}) — exhaustion routes to the dead-letter sink, not to an unbounded loop`),
358
359
  ).toBeLessThanOrEqual(policy.maxAttempts);
359
360
  }
361
+ // The sink itself. Until RFC 0188 this was an UNCONDITIONAL soft-skip on
362
+ // every host — `webhooks.md` §Durability said the sink was "inspectable for
363
+ // retentionDays" and the corpus served no read that could inspect it, so
364
+ // every bundle recorded "exhaustion was observed, routing to the sink was
365
+ // not". The read exists now; read it before deleting the subscription.
366
+ const fam = await gateFamily('webhooks');
367
+ if (!fam?.['deadLetter']) {
368
+ await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
369
+ return softSkip('inapplicable', 'host does not advertise the webhooks.deadLetter facet — RFC 0188 §A.5 makes the read a 404 rather than an obligation, so the sink half of §Durability is unwitnessable here (the retry half above passed)');
370
+ }
371
+ const sink = await driver.get(`/webhooks/${projectBoundId(sub.webhookId)}/dead-letters`);
360
372
  await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
361
- // The sink itself: webhooks.md §Durability says "inspectable for retentionDays",
362
- // but api/v2/openapi.yaml carries no dead-letter read for webhook deliveries
363
- // (no GET /webhooks/{webhookId}/dead-letters). Without a normative read the
364
- // routing to the sink is not observable from the suite.
365
- return softSkip('blocked', 'no normative dead-letter read surface for webhook deliveries in api/v2/openapi.yaml (a GET /webhooks/{webhookId}/dead-letters projection is needed) — exhaustion was observed, routing to the sink was not');
373
+ expect(
374
+ sink.status === 200 && ((sink.json as { deliveries?: unknown[] } | null)?.deliveries ?? []).some((r) => (r as Record<string, unknown>)['runId'] === runId),
375
+ req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', 'an exhausted delivery MUST be routed to the sink, not dropped — the half no bundle could witness before RFC 0188 served a read'),
376
+ ).toBe(true);
377
+ }, DEAD_LETTER_TEST_TIMEOUT_MS);
378
+
379
+ it('the dead-letter read is served and its records carry no payload', async () => {
380
+ // Its own `it` on purpose: `generate-requirement-registry.mjs` takes the
381
+ // FIRST req() id in a body as the explicitId, so an id minted second in a
382
+ // shared `it` never reaches requirements.json and no bundle can carry a row
383
+ // for it — the same trap that made `0173.pack-isolation.seam` unwitnessable.
384
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
385
+ const fam = await gateFamily('webhooks');
386
+ if (!fam) return softSkip('inapplicable', 'webhooks family not advertised');
387
+ if (!fam['deadLetter']) return softSkip('inapplicable', 'host does not advertise the webhooks.deadLetter facet — RFC 0188 §A.5 makes the read a 404 rather than an obligation');
388
+ const reg = await driver.post('/webhooks', { url: 'https://subscriber.invalid/hook', events: ['run.completed'] });
389
+ if (reg.status !== 201) return softSkip('blocked', `POST /webhooks answered ${reg.status} — no subscription to read a sink for`);
390
+ const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
391
+ if (typeof webhookId !== 'string') return softSkip('blocked', 'the mint returned no webhookId');
392
+ const sink = await driver.get(`/webhooks/${projectBoundId(webhookId)}/dead-letters`);
393
+ await driver.delete(`/webhooks/${encodeURIComponent(webhookId)}`);
394
+ expect(
395
+ sink.status,
396
+ req('openwop.requirement.0188.dead-letter-read', 'RFC 0188 §A.1', 'a host advertising webhooks.deadLetter MUST serve GET /webhooks/{webhookId}/dead-letters'),
397
+ ).toBe(200);
398
+ }, DEAD_LETTER_TEST_TIMEOUT_MS);
399
+
400
+ it('a dead-letter record carries no delivered payload', async () => {
401
+ // Its own `it`: RFC 0168 §A.1 allows one explicit requirement id per it(),
402
+ // and `check-req-only` enforces it.
403
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
404
+ const fam = await gateFamily('webhooks');
405
+ if (!fam?.['deadLetter']) return softSkip('inapplicable', 'host does not advertise the webhooks.deadLetter facet');
406
+ const reg = await driver.post('/webhooks', { url: 'https://subscriber.invalid/hook', events: ['run.completed'] });
407
+ if (reg.status !== 201) return softSkip('blocked', `POST /webhooks answered ${reg.status}`);
408
+ const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
409
+ if (typeof webhookId !== 'string') return softSkip('blocked', 'the mint returned no webhookId');
410
+ const sink = await driver.get(`/webhooks/${projectBoundId(webhookId)}/dead-letters`);
411
+ await driver.delete(`/webhooks/${encodeURIComponent(webhookId)}`);
412
+ if (sink.status !== 200) return softSkip('blocked', `the dead-letter read answered ${sink.status}`);
413
+ const rows = ((sink.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
414
+ for (const r of rows) {
415
+ expect(
416
+ r['body'] === undefined && r['headers'] === undefined && r['secret'] === undefined,
417
+ req('openwop.requirement.0188.dead-letter-content-free', 'RFC 0188 §B.1', 'a dead-letter record MUST NOT carry the delivered body, headers or subscription secret — the queue is precisely the traffic the subscriber never received'),
418
+ ).toBe(true);
419
+ }
366
420
  }, DEAD_LETTER_TEST_TIMEOUT_MS);
367
421
  });