@openwop/openwop-conformance 2.44.0 → 2.44.2

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,21 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.44.2] — 2026-09-28 — RFC 0215's no-head-of-line leg does not convict a late fan-out, and says when it sampled each count
4
+
5
+ - **The 2.44.2 cycle opens.** A patch: the requirement is unchanged, and one verdict branch that convicted without the contention it names becomes unjudged. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
6
+ - **`0215.no-head-of-line` no longer fails a host whose `run.started` fan-out is late.** The last branch failed the row as "a bounded dispatcher below the floor" whenever fewer than 8 held attempts had arrived by the time the run was terminal. MyndHyve `00821-qec` hit it twice. In the first cut no held attempt had arrived and the healthy one was delivered. In the second, 2 held attempts had arrived at terminal and 7 later, and the healthy attempt started with 7 held open and none closed. §A forbids an attempt waiting for another subscription's attempt to *finish*. None had finished, so the healthy attempt waited for nothing, and the contention §A.2 names was never established. The row is now `blocked` (unjudged) in that case. A bounded pool still fails: its healthy attempt starts only after a held attempt closes, or never while they stay open. So does a serial dispatcher, through the peak assertion.
7
+ - **The detail names each sampling time.** It read "2 held attempt(s) arrived (at most 7 open at once)", with the 2 sampled at terminal and the 7 at the verdict. It now reports what had arrived and was open at terminal, what had arrived by the verdict and the peak, when the healthy attempt arrived, and whether the host had closed any held attempt. It no longer names a cause the data does not show.
8
+ - **The verdict is a pure function with self-tests** (`lib/delivery-isolation-verdict.ts`). The tests cover pass, the contention-present fail, a 7-slot pool with a 15 s timeout (fail), a serial dispatcher (peak assertion), and both MyndHyve cuts (unjudged). Sabotage-checked: removing the new branch fails the two MyndHyve cases.
9
+
10
+ ## [2.44.1] — 2026-09-28 — the webhook retry legs outlast an advertised maxElapsedMs, and four families get v2 normative homes
11
+
12
+ - **The 2.44.1 cycle opens.** `workspace`, `content`, `triggerBridge` and `uiPlugins` get v2 normative homes (RFC 0189), which changes the packed `@openwop/spec-artifacts` tree (`spec/v2/core/host-services.md`, `i18n.md`, `webhooks.md`, `packs.md`, `spec/v2/declaration.json`) after `v2.44.0` was tagged. No scenario changes: the rules are restated from the v1 text, none added or strengthened.
13
+ - New `spec/v2/core/storage.md` also changes the packed tree (`spec/v2/core/storage.md`, `spec/v2/core/overview.md`, `spec/v2/README.md`, `spec/v2/declaration.json`). It restates the v1 rules for `fs`, `kvStorage`, `tableStorage`, `sql`, `nosql`, `vectorStore`, `searchIndex`, `blobStorage` and `cache` (RFC 0189 §D). No rule and no wire change, and no scenario change.
14
+ - **A webhook retry leg is no longer killed before its own wait elapses** (RFC 0225 follow-up). 2.44.0 made `retryWaitFor` follow an advertised `retryPolicy.maxElapsedMs` plus the 30 s grace (up to 1 h), but the test timeouts were still fixed at registration from the operator cap (90 s by default). A host advertising `maxElapsedMs: 600000` waits 630 s, and its dead-letter leg died at 210 s ("Test timed out in 210000ms"). MyndHyve `00821-qec` recorded `0173.webhook-durable-delivery.dead-letter` and `0188.dead-letter-content-free` `executed-fail` after both passed on 2.43.1. It is the 2.0.2 defect again: a wait longer than the timeout governing it. vitest fixes a timeout before discovery is read, so every test that awaits a retry window now takes its timeout from `retryTestTimeoutMs(waits, slack)`, derived from the largest wait `retryWaitFor` can return (`MAX_RETRY_WAIT_MS`, the 1 h clamp). The timeout is a backstop: each wait is still bounded by its own window, so a host finishes no later than before. Fixed in `v2-webhook-durable-delivery` (retry leg; dead-letter legs), `v2-webhook-unregister-stops-delivery` and `v2-webhook-message-id-stable`. Workaround on 2.44.0: `OPENWOP_WEBHOOK_RETRY_WAIT_MS=600000`.
15
+ - **A self-test holds every call site to it.** `lib/webhook-retry-window.test.ts` scans the scenarios: each `it` that awaits a retry window must register a `retryTestTimeoutMs(N, …)` timeout with N at least its number of waits, and every scenario that computes a window must be seen by the scan. Sabotage-checked: restoring any of the four cap-derived timeouts fails it.
16
+ - **`0215.no-head-of-line` is not this bug.** `v2-webhook-delivery-isolation` waits the fixed 20 s floor under a timeout that covers it, and never calls `retryWaitFor`.
17
+ - **Suite `2.44.1`**. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
18
+
3
19
  ## [2.44.0] — 2026-09-28 — three run-failure codes registered, a webhook retry bound hosts can advertise, and RFC 0219 Accepted
4
20
 
5
21
  - **RFC 0219 is `Accepted`** (corpus only; no scenario change). The flip changes the packed `@openwop/spec-artifacts` tree through `spec/v1/gaps.json`: 0219 gap G6 is closed (SDK 2.5.0 sends the header), G7 is declared (`no-floor-no-refusal` needs a host with no `minClientVersion`), and G8 is transferred to #1763 (CORS).
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.44.0 @openwop/spec-artifacts@2.44.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.44.2 @openwop/spec-artifacts@2.44.2
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.44.0",
4
- "stampSha256": "37326a214e400e23aba6d56c45d06915c32f0187bd4fe637888e0e14221c9767"
3
+ "version": "2.44.2",
4
+ "stampSha256": "66d1059ab9f4e5799bbb84c8b66e64033bc08f4daf6016c22c71164cca948c4c"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.44.0",
3
+ "version": "2.44.2",
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.44.0"
59
+ "@openwop/spec-artifacts": "2.44.2"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -34246,7 +34246,7 @@
34246
34246
  {
34247
34247
  "id": "openwop.requirement.0215.no-head-of-line",
34248
34248
  "file": "v2-webhook-delivery-isolation.test.ts",
34249
- "line": 113,
34249
+ "line": 119,
34250
34250
  "title": null,
34251
34251
  "explicitId": "openwop.requirement.0215.no-head-of-line",
34252
34252
  "citations": [
@@ -34273,11 +34273,6 @@
34273
34273
  "requirement": null,
34274
34274
  "interpolated": true
34275
34275
  },
34276
- {
34277
- "section": "webhooks.md §Durability (RFC 0215 §A.1, §A.2)",
34278
- "requirement": null,
34279
- "interpolated": true
34280
- },
34281
34276
  {
34282
34277
  "section": "webhooks.md §Durability (RFC 0215 §A.1, §A.2)",
34283
34278
  "requirement": null,
@@ -34360,7 +34355,7 @@
34360
34355
  {
34361
34356
  "id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
34362
34357
  "file": "v2-webhook-durable-delivery.test.ts",
34363
- "line": 258,
34358
+ "line": 263,
34364
34359
  "title": "a failed attempt is retried and the event is delivered at least once",
34365
34360
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery",
34366
34361
  "citations": [
@@ -34397,7 +34392,7 @@
34397
34392
  {
34398
34393
  "id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
34399
34394
  "file": "v2-webhook-durable-delivery.test.ts",
34400
- "line": 378,
34395
+ "line": 383,
34401
34396
  "title": "an exhausted delivery is dead-lettered, never dropped",
34402
34397
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
34403
34398
  "citations": [
@@ -34436,7 +34431,7 @@
34436
34431
  {
34437
34432
  "id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
34438
34433
  "file": "v2-webhook-durable-delivery.test.ts",
34439
- "line": 486,
34434
+ "line": 491,
34440
34435
  "title": "the dead-letter read is served and its records carry no payload",
34441
34436
  "explicitId": "openwop.requirement.0188.dead-letter-read",
34442
34437
  "citations": [
@@ -34449,7 +34444,7 @@
34449
34444
  {
34450
34445
  "id": "openwop.it.v2-webhook-durable-delivery.after-unregister-the-dead-letter-read-answers-404-not-found-as-for-a-subscriptio",
34451
34446
  "file": "v2-webhook-durable-delivery.test.ts",
34452
- "line": 507,
34447
+ "line": 512,
34453
34448
  "title": "after unregister, the dead-letter read answers 404 not_found, as for a subscription that never existed",
34454
34449
  "explicitId": "openwop.requirement.0217.dead-letter-read-after-unregister",
34455
34450
  "citations": [
@@ -34476,7 +34471,7 @@
34476
34471
  {
34477
34472
  "id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
34478
34473
  "file": "v2-webhook-durable-delivery.test.ts",
34479
- "line": 540,
34474
+ "line": 545,
34480
34475
  "title": "a dead-letter record carries no delivered payload",
34481
34476
  "explicitId": "openwop.requirement.0188.dead-letter-content-free",
34482
34477
  "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.44.0",
5
- "corpusTag": "v2.44.0",
4
+ "version": "2.44.2",
5
+ "corpusTag": "v2.44.2",
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": "69464bc0e1e71ef3b543a73d4fa4f67202343d2603c80af8033f6c3619ef1b30",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "7efbc96aa7d5fb2d501f90585cc7fbb10da6a0ff41289b52cf6ddadf04dc852a",
13
- "api/v2/asyncapi.yaml": "ac39be9e90e1daf84cb12cc268be3152c6b50f6852129ac42820db43bb26f41d",
14
- "api/v2/openapi.yaml": "a8b2fdc24b017cf146985dd2360da4e794a87deb83b98fc51c54a1363fc5e391",
12
+ "api/seams-v2.yaml": "f24127f3cd544f1c5a942859ffc492b438d37b1b7d7a660c631fb87f70cd24f6",
13
+ "api/v2/asyncapi.yaml": "3283ff1bbe962dc75e6d66d2b755d4eb7d53770ce8511f2d671b923d6ca0e51a",
14
+ "api/v2/openapi.yaml": "4a455ba22c81718e2842adce60e5d3c5970e6f8a55b301d9154ccc8060f6cf91",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,7 +204,7 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "29f21e0fb2c7a232a92a5c19b4cba3a86bc3b5e16f06780452f0421c66c6ec38",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "5ea51250e81a3bf70637811cdb3bacfa7d35609dc49ce1a4889f0cff9fb35c85",
207
+ "spec/v1/core-standard-manifest.json": "3d7fd99cd02153094723c2bf383d757c282e653526aa3b199d9bdb4b9cfd7c80",
208
208
  "spec/v1/deprecations.json": "46df807fb4adf0aae24671c20f69975cf3496e3bc3a521cb7e6ea556bdce563c",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -216,7 +216,7 @@
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
217
217
  "spec/v1/operation-path-manifest.json": "fada0342815fb129c1ae61f1829bd6e34417717408ce86fcc54280f373c19e74",
218
218
  "spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
219
- "spec/v2/README.md": "4fa067d377580495e60baf08a4eafc3214b8ca48d8fb270bf91fc5066fad7186",
219
+ "spec/v2/README.md": "377639de1dad1ed71aba6dae8d34fdb6ee954fb23106e89454fe8d29cdde6349",
220
220
  "spec/v2/core/capabilities.md": "ad728e8a761153b55d036ece55043ff62d0982121e2e629e5e7cdf560fca3c03",
221
221
  "spec/v2/core/conformance.md": "0cfceb926eed87055550a5c23e640210c795fc8cf9dd1b2544db0e2b3842c564",
222
222
  "spec/v2/core/connection-packs.md": "1ee129d792c537bfe82393ccb92555d8e6ddc342b1aa781a13f101b27ec38171",
@@ -225,28 +225,29 @@
225
225
  "spec/v2/core/events.md": "604abb6f9e2259a04103e1cb76d728712c3701c0d84dd915d4dc2949c912ee5e",
226
226
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
227
227
  "spec/v2/core/headers.md": "5cb1624fa63acc81992b6a6d842ec392cf7271aaf2f65d1f83c8c4fa286e6730",
228
- "spec/v2/core/host-services.md": "15923cceb95cba148a32e0944f6a2d1d25e9cc36b0c169b67ee42d483bcac800",
229
- "spec/v2/core/i18n.md": "846c6d2878fc8454c2ae0a1576f6b6e6c8958d47187a6c96cc5826ca3c3e2e91",
228
+ "spec/v2/core/host-services.md": "8b920bf2e9335ab04452068429b0c37d6eb8872b380a4d53368f2600d4afa241",
229
+ "spec/v2/core/i18n.md": "2d5a67bd3b9b956d1144acab2fbc3646cdb50b3621f97d772087eaa953798286",
230
230
  "spec/v2/core/idempotency.md": "2084d756789c1faf5121593fb39e67ca36a4c227178e175004742e403178ea49",
231
231
  "spec/v2/core/identity.md": "2dceb1c2697015e4a4fa8a9b28f4fbd25fa44a76ca5a919c752e2a8163c819db",
232
232
  "spec/v2/core/interop.md": "e72e25dff864c83b2980d7366b34054cffa072af0225821199f260f6228d260e",
233
233
  "spec/v2/core/interrupt.md": "4c825634acac72900ebd3c5ac5941f55648fa33d2df92913ef16371dcb700a34",
234
234
  "spec/v2/core/node-pack-runtimes.md": "e1088cb69b8c3c67256c494063328916c2980ab1aa97a7063de25bab82fdb1e5",
235
235
  "spec/v2/core/oauth.md": "c96483a44b62978c596945fbb7d7c863cf6056cd980fdb4cf4e97555e3e10b72",
236
- "spec/v2/core/overview.md": "4087f171b00a3907551f508fec32fb9b0b3ccec8c0ac1b0c7c6d32cd2c0e3ff8",
237
- "spec/v2/core/packs.md": "b472a583211d466d6ab8e00c414c225b246e678043fe6283a710c899fadde913",
236
+ "spec/v2/core/overview.md": "c8c3bedb8e02dd8ce244b8a7a75d23af1c30dc92a343af912489424a89674d70",
237
+ "spec/v2/core/packs.md": "5929e708bd4e79f6e1beea570af583c80f3b63bb21dec78a5df03c235e73364a",
238
238
  "spec/v2/core/persistence.md": "711874f74cb08b03cd118d683056db10a063517762fcea73ec70feb5d983e6a4",
239
239
  "spec/v2/core/portability.md": "87c4327f674f714fda6217fd79b99d2ba219ad47dc02f9751bb56c200fb2c17a",
240
240
  "spec/v2/core/replay.md": "4d379494057746c6883f6047972e93e621863b998ed291b9a691d3aa57c1ff43",
241
241
  "spec/v2/core/runs.md": "55e21292ae4b41d0d104199a6c2634ecb3f787c4a671f9ebc37084be38ceef03",
242
242
  "spec/v2/core/security-defaults.md": "153884da7a2d54c30c81b5e649cfdef31e151b3a2a19dee1bc5a16c35d60a3aa",
243
+ "spec/v2/core/storage.md": "598ad4f3775c297c698a20daf72a7d15a6836c1231ddbca5961865c704f765b5",
243
244
  "spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
244
245
  "spec/v2/core/versioning.md": "e3b3ab6d76e51035039a32173e9b412628a52c8fe7b16687ff58a2d0d983faa0",
245
- "spec/v2/core/webhooks.md": "2fcb502be558014fd20b342687e0f078a5388fc430b2f99d883c0e73ddf7789f",
246
+ "spec/v2/core/webhooks.md": "d2d33c6be777f217ade3e15dd369849e5c6ce67e1a4417216f0aa6ec5bdecc56",
246
247
  "spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
247
248
  "spec/v2/corrections.json": "59f647f4d22477f6c2b06dcf913b682dde5d51dd5e607a5d0cf648ab60864d6e",
248
249
  "spec/v2/corrections.schema.json": "46bbffb954d13bd9995aebc912ceeb2544f7064331abf25fd6ab85c81a5cec94",
249
- "spec/v2/declaration.json": "aba200967c28190c6889c27dcfb01fd02919d0c023b88693e9ffc57e9488021a",
250
+ "spec/v2/declaration.json": "56c85c6d49f93d08d3bd48ccf1c049191cfbc4d4c22a1cd5202c1b6252759aca",
250
251
  "spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
251
252
  "spec/v2/errors.json": "fa650cbbdf25a140fdaf82ec5269f95b4585ac4bdde1fa6b0b3ffe48a3f7943c",
252
253
  "spec/v2/errors.schema.json": "47404ad53e6147cf9017b813483712466573e419fb33e6c4b6c12d3e7deba1bc",
@@ -298,9 +299,9 @@
298
299
  "spec/v2/path-manifest.json": "17298a245c5da9a70aa7ab0b7f2db4277e06209d6fcdd7187a8b4cea179c08f3",
299
300
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
300
301
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
301
- "spec/v2/release.json": "a93ba35569a8a8bbc68243fbaef446363e5d5d5d0b8c1235ec68d0eed5ab6363",
302
+ "spec/v2/release.json": "28b407019929da55701a718110d440f08899646af0b74d62f6adc5542c9c1c91",
302
303
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
303
- "spec/v2/surface-baseline.json": "0d60d3d34ddc8f1290a2fc3a6be2e5ef5cfa211cde0918d8702ec4dd299d5549"
304
+ "spec/v2/surface-baseline.json": "d0540276e74001f5058dd81909100ea6ee3c1925c6ac02918418f5ec941d0661"
304
305
  },
305
- "corpusCommit": "8bb20a89a81544aaf90b3a375fc6b1f4ffef3a33"
306
+ "corpusCommit": "c486eafcfa16908a7b57c98b33cfd66fdf8e599d"
306
307
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The verdict of RFC 0215's `no-head-of-line` leg, as a pure function of what
3
+ * the leg observed (suite 2.44.2), so the rule can be tested without a host.
4
+ *
5
+ * 2.44.2 fixes two defects that MyndHyve's 2.44.x cuts surfaced:
6
+ *
7
+ * 1. **A conviction without the contention it names.** The last branch failed
8
+ * the row as "a bounded dispatcher below the floor" whenever fewer than
9
+ * `floor` held attempts had arrived by the time the run was terminal. On a
10
+ * host whose `run.started` fan-out arrived late, that convicted a healthy
11
+ * attempt that started while every held attempt that HAD arrived was still
12
+ * open and none had finished (MyndHyve `00821-qec`: 0 held attempts, then 2
13
+ * at terminal and 7 later, the healthy attempt delivered with 7 open). §A
14
+ * forbids an attempt waiting for another subscription's attempt to FINISH; no
15
+ * attempt had finished, so there was nothing it could have waited for. The
16
+ * contention was never established, so the row is unjudged, not failed. A
17
+ * bounded pool still fails: its healthy attempt starts only after a held
18
+ * attempt closes, or never while they stay open.
19
+ *
20
+ * 2. **A detail string that mixed two sampling times.** It read "2 held
21
+ * attempt(s) arrived (at most 7 open at once)": the 2 was sampled at
22
+ * terminal, the 7 at the verdict. The detail now names each time.
23
+ */
24
+
25
+ export interface IsolationObservation {
26
+ /** §A.2's floor. */
27
+ readonly floor: number;
28
+ /** When the leg saw the run terminal (the healthy delivery is due by then). */
29
+ readonly terminalAt: number;
30
+ /** When the healthy attempt arrived, or null if it did not within the window. */
31
+ readonly healthyAt: number | null;
32
+ /** Held attempts open when the healthy attempt arrived (-1 if it did not). */
33
+ readonly openAtHealthy: number;
34
+ /** Held attempts that had arrived by `terminalAt`. */
35
+ readonly arrivedAtTerminal: number;
36
+ /** Held attempts still open at `terminalAt`. */
37
+ readonly openAtTerminal: number;
38
+ /** Held attempts that had arrived by the verdict. */
39
+ readonly arrivedByVerdict: number;
40
+ /** The most held attempts open at once, up to the verdict. */
41
+ readonly peakOpen: number;
42
+ /** When the first held attempt closed (the host abandoning it), or Infinity. */
43
+ readonly earliestClose: number;
44
+ readonly windowMs: number;
45
+ readonly delayMs: number;
46
+ }
47
+
48
+ export type IsolationVerdict =
49
+ /** The healthy attempt started while `floor` held attempts were open. */
50
+ | { readonly kind: 'pass'; readonly detail: string }
51
+ | { readonly kind: 'fail'; readonly message: string; readonly detail: string }
52
+ /** Unjudged, after asserting `peakOpen >= floor` (which fails a pool below the floor). */
53
+ | { readonly kind: 'blocked-after-peak'; readonly message: string; readonly detail: string }
54
+ | { readonly kind: 'blocked'; readonly message: string; readonly detail: string };
55
+
56
+ /** What the leg saw, each count against the time it was sampled. */
57
+ export function isolationDetail(o: IsolationObservation): string {
58
+ const at = (t: number): string => `${t - o.terminalAt}ms after the run was seen terminal`;
59
+ const healthy = o.healthyAt === null
60
+ ? `the healthy attempt did not arrive within ${o.windowMs}ms of that`
61
+ : `the healthy attempt arrived ${at(o.healthyAt)}, with ${o.openAtHealthy} held open`;
62
+ const closed = Number.isFinite(o.earliestClose) ? `the first held attempt was closed by the host ${at(o.earliestClose)}` : 'no held attempt was closed by the host';
63
+ return `at terminal, ${o.arrivedAtTerminal} held attempt(s) had arrived and ${o.openAtTerminal} were open; by the verdict, ${o.arrivedByVerdict} had arrived, at most ${o.peakOpen} open at once; ${healthy}; ${closed}`;
64
+ }
65
+
66
+ export function isolationVerdict(o: IsolationObservation): IsolationVerdict {
67
+ const detail = isolationDetail(o);
68
+ const F = o.floor;
69
+ if (o.healthyAt !== null && o.openAtHealthy >= F) return { kind: 'pass', detail };
70
+ if (o.openAtTerminal >= F) {
71
+ // The healthy attempt waited (or never came), and the contention §A.2
72
+ // names was present when it fell due.
73
+ return { kind: 'fail', message: `with ${F} subscriptions' attempts unanswered, a further subscription's attempt MUST still start; it waited for the host to release one`, detail };
74
+ }
75
+ if (o.arrivedAtTerminal >= F || o.earliestClose < o.terminalAt) {
76
+ // Contention was not sustained to the due time. Either the host's own
77
+ // delivery timeout released the held attempts early (its choice), or it
78
+ // never opened F of them at once. peakOpen separates the two: below F the
79
+ // host never had F attempts outstanding at once, which is the defect.
80
+ const released = Number.isFinite(o.earliestClose) ? `${o.terminalAt - o.earliestClose}ms ` : '';
81
+ return { kind: 'blocked-after-peak', message: `unjudged: ${F} held attempts were open at once, but the host closed held attempts ${released}before the run was terminal (its delivery timeout is shorter than this leg's ${o.delayMs}ms delay), so ${F} attempts were not outstanding when the healthy delivery fell due`, detail };
82
+ }
83
+ if (o.healthyAt !== null && !(o.earliestClose < o.healthyAt)) {
84
+ // Fewer than F held attempts had arrived when the healthy delivery fell
85
+ // due, and it started before any held attempt finished: it cannot have
86
+ // waited for one. The held fan-out was late, so the contention the floor
87
+ // names never existed when it mattered.
88
+ return { kind: 'blocked', message: `unjudged: only ${o.arrivedAtTerminal} of the ${F} held attempts had arrived when the healthy delivery fell due, and the healthy attempt started before any held attempt finished, so it did not wait for one; the held run.started deliveries arrived late, and the contention §A.2 names was not established`, detail };
89
+ }
90
+ return {
91
+ kind: 'fail',
92
+ message: o.healthyAt === null
93
+ ? `the host never had ${F} subscriptions' attempts outstanding at once, and the healthy attempt did not start within ${o.windowMs}ms while the held attempts stayed open`
94
+ : `the host never had ${F} subscriptions' attempts outstanding at once, and the healthy attempt started only after a held attempt finished`,
95
+ detail,
96
+ };
97
+ }
@@ -65,9 +65,29 @@ export function retryWaitCapMs(env: Record<string, string | undefined> = process
65
65
  export function retryWaitFor(policy: AdvertisedRetryPolicy | null, capMs: number): number {
66
66
  if (policy === null) return RETRY_WAIT_FLOOR_MS;
67
67
  const bound = advertisedMaxElapsedMs(policy);
68
- if (bound !== null) return Math.min(MAX_RETRY_WAIT_CAP_MS, Math.max(bound + ADVERTISED_BOUND_GRACE_MS, capMs));
68
+ if (bound !== null) return Math.min(MAX_RETRY_WAIT_MS, Math.max(bound + ADVERTISED_BOUND_GRACE_MS, capMs));
69
69
  const backoff = String(policy.backoff ?? '');
70
- return backoff === 'exponential' || backoff === 'fixed' ? capMs : RETRY_WAIT_FLOOR_MS;
70
+ return backoff === 'exponential' || backoff === 'fixed' ? Math.min(MAX_RETRY_WAIT_MS, capMs) : RETRY_WAIT_FLOOR_MS;
71
+ }
72
+
73
+ /**
74
+ * The longest `retryWaitFor` can return, whatever a host advertises or an
75
+ * operator sets (suite 2.44.1).
76
+ *
77
+ * vitest fixes a test's timeout when the test is REGISTERED, before any
78
+ * discovery document is read, so a timeout cannot follow the host's advertised
79
+ * `maxElapsedMs`. 2.44.0 derived them from the cap alone (90 s by default):
80
+ * a host advertising 600000 had its dead-letter leg killed at 210 s ("Test
81
+ * timed out in 210000ms"), before its own bound plus the grace elapsed. That is
82
+ * the 2.0.2 defect again: a wait longer than the timeout that governs it. The
83
+ * timeout is only a backstop — every wait is bounded by its own window — so it
84
+ * is derived from the largest window instead.
85
+ */
86
+ export const MAX_RETRY_WAIT_MS = MAX_RETRY_WAIT_CAP_MS;
87
+
88
+ /** The timeout for a test that runs `waits` sequential retry waits, plus `slackMs` for the HTTP round trips around them. */
89
+ export function retryTestTimeoutMs(waits: number, slackMs: number): number {
90
+ return waits * MAX_RETRY_WAIT_MS + slackMs;
71
91
  }
72
92
 
73
93
  /** What a row says when its window closed before the host's schedule did. Computed, so the numbers a host reads are the ones the run used. */
@@ -26,6 +26,11 @@
26
26
  * host released one to make room), or never within the window, AND the held
27
27
  * attempts had stayed open until the run was terminal: the contention the
28
28
  * floor names existed when the healthy delivery fell due, and it waited.
29
+ * - blocked (unjudged, suite 2.44.2) — fewer than 8 held attempts had arrived
30
+ * when the healthy delivery fell due, and the healthy attempt started before
31
+ * any held attempt finished: it waited for nothing, and the contention was
32
+ * never established (a late `run.started` fan-out). The rule and its tests
33
+ * are in `lib/delivery-isolation-verdict.ts`.
29
34
  * - blocked (unjudged) — the host had all 8 held attempts open at once, but
30
35
  * its own delivery timeout closed them before the run was terminal.
31
36
  * Contention was not sustained when the healthy delivery fell due, so this
@@ -56,6 +61,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
56
61
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
57
62
  import { req } from '../lib/requirement-ids.js';
58
63
  import { RETRY_WAIT_FLOOR_MS } from '../lib/webhook-retry-window.js';
64
+ import { isolationVerdict } from '../lib/delivery-isolation-verdict.js';
59
65
 
60
66
  export const REQUIRES_HOST_CALLBACK = 'the host POSTs webhook deliveries to nine suite-owned subscriptions on the scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL, eight of which the suite holds open';
61
67
 
@@ -179,44 +185,30 @@ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s
179
185
  const terminalAt = await waitTerminal(runId, DELAY_MS + 30_000);
180
186
  if (terminalAt === null) return blockedDespiteAssertions(`${FIXTURE} run ${runId} did not reach a terminal status — the healthy delivery never fell due`);
181
187
  const openAtTerminal = openCount();
182
- const everOpened = held.size;
188
+ const arrivedAtTerminal = held.size;
183
189
  await waitFor(() => healthyAt !== null, WINDOW_MS);
184
190
  const earliestClose = Math.min(...[...held.values()].map((h) => h.closedAt ?? Infinity));
185
- const detail = `${everOpened} held attempt(s) arrived (at most ${peakOpen} open at once); ${openAtTerminal} were still open when the run was seen terminal; `
186
- + (healthyAt === null ? `the healthy attempt did not arrive within ${WINDOW_MS}ms of that` : `the healthy attempt arrived ${healthyAt - terminalAt}ms after it, with ${openAtHealthy} held open`);
187
191
 
188
- if (healthyAt === null && everOpened === 0) {
192
+ if (healthyAt === null && held.size === 0) {
189
193
  if (absenceIsUnmeasured(rx)) return blockedDespiteAssertions(noDeliveryCause(rx, 'webhook attempt'));
190
194
  return blockedDespiteAssertions(`no attempt for any of the ${FLOOR + 1} subscriptions arrived — ${noDeliveryCause(rx, 'webhook attempt')}`);
191
195
  }
192
- if (healthyAt !== null && openAtHealthy >= FLOOR) {
193
- expect(openAtHealthy, req(ID, DOC, `an attempt to one subscription MUST NOT wait for attempts to other subscriptions — ${detail}`)).toBeGreaterThanOrEqual(FLOOR);
194
- } else if (openAtTerminal >= FLOOR) {
195
- // The healthy attempt waited (or never came), and the contention §A.2
196
- // names was present when it fell due.
197
- expect.fail(req(ID, DOC, `with ${FLOOR} subscriptions' attempts unanswered, a further subscription's attempt MUST still start; it waited for the host to release one — ${detail}`));
198
- } else if (everOpened >= FLOOR || earliestClose < terminalAt) {
199
- // Contention was not sustained to the due time. Either the host's own
200
- // delivery timeout released the held attempts early (its choice), or it
201
- // never opened FLOOR of them at once. The second is the defect, the first
202
- // is not, and they differ in WHEN the first held attempt closed.
203
- //
204
- // Unfailable-leg audit (2026-09-26): this branch asserted everOpened > 0
205
- // and then `softSkip('skipped')`, which records `executed-pass` with a
206
- // `partial-witness:` detail. A serial dispatcher (pool of 1) with a
207
- // sub-2s delivery timeout landed here and passed — every held attempt
208
- // arrived eventually (everOpened reached FLOOR), one at a time, while the
209
- // healthy attempt queued behind them: head-of-line blocking. peakOpen
210
- // separates the two cases the comment above names: below FLOOR the host
211
- // never had FLOOR attempts outstanding at once, which is the defect; at
212
- // FLOOR the host's timeout released them early, which is unjudged.
196
+ const v = isolationVerdict({
197
+ floor: FLOOR, terminalAt, healthyAt, openAtHealthy, arrivedAtTerminal, openAtTerminal,
198
+ arrivedByVerdict: held.size, peakOpen, earliestClose, windowMs: WINDOW_MS, delayMs: DELAY_MS,
199
+ });
200
+ if (v.kind === 'blocked') return blockedDespiteAssertions(`${v.message} — ${v.detail}`);
201
+ if (v.kind === 'blocked-after-peak') {
202
+ // Unfailable-leg audit (2026-09-26): a serial dispatcher (pool of 1)
203
+ // with a sub-2s delivery timeout lands here; below FLOOR at once it
204
+ // fails, at FLOOR the host's timeout released them early (unjudged).
213
205
  expect(
214
206
  peakOpen,
215
- req(ID, DOC, `a host MUST sustain ${FLOOR} subscriptions' attempts outstanding at once — at most ${peakOpen} were ever open together, so the held attempts were dispatched below the floor — ${detail}`),
207
+ req(ID, DOC, `a host MUST sustain ${FLOOR} subscriptions' attempts outstanding at once — at most ${peakOpen} were ever open together, so the held attempts were dispatched below the floor — ${v.detail}`),
216
208
  ).toBeGreaterThanOrEqual(FLOOR);
217
- return blockedDespiteAssertions(`unjudged: ${FLOOR} held attempts were open at once, but the host closed held attempts ${Number.isFinite(earliestClose) ? `${terminalAt - earliestClose}ms ` : ''}before the run was terminal (its delivery timeout is shorter than this leg's ${DELAY_MS}ms delay), so ${FLOOR} attempts were not outstanding when the healthy delivery fell due — ${detail}`);
218
- } else {
219
- expect.fail(req(ID, DOC, `the host never had ${FLOOR} subscriptions' attempts outstanding at once, and the healthy attempt waited — a bounded dispatcher below the floor — ${detail}`));
209
+ return blockedDespiteAssertions(`${v.message} — ${v.detail}`);
220
210
  }
211
+ if (v.kind === 'fail') expect.fail(req(ID, DOC, `${v.message} — ${v.detail}`));
212
+ expect(openAtHealthy, req(ID, DOC, `an attempt to one subscription MUST NOT wait for attempts to other subscriptions — ${v.detail}`)).toBeGreaterThanOrEqual(FLOOR);
221
213
  }, DELAY_MS + 30_000 + RETRY_WAIT_FLOOR_MS + 30_000);
222
214
  });
@@ -35,7 +35,7 @@ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedR
35
35
  import { readErrorCode } from '../lib/error-envelope.js';
36
36
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
37
37
  import { req } from '../lib/requirement-ids.js';
38
- import { pastAdvertisedBound, retryWaitCapMs, retryWaitFor, windowClosedNote } from '../lib/webhook-retry-window.js';
38
+ import { pastAdvertisedBound, retryTestTimeoutMs, retryWaitCapMs, retryWaitFor, windowClosedNote } from '../lib/webhook-retry-window.js';
39
39
 
40
40
  export const REQUIRES_HOST_CALLBACK = 'the host POSTs and retries webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
41
41
 
@@ -198,6 +198,11 @@ function retryWaitMs(doc: Record<string, unknown>): number {
198
198
  * to the wait carries its own timeout, the way the advert is sourced from the
199
199
  * constant the delivery loop reads. `WAIT_SLACK_MS` covers `waitTerminal`,
200
200
  * registration and the HTTP round trips around the waits.
201
+ *
202
+ * 2.44.1: and the cap was the wrong wait to derive from once RFC 0225 let a
203
+ * host advertise `maxElapsedMs` — the wait follows the advert, the timeout is
204
+ * fixed at registration. So the budget is derived from the largest wait
205
+ * `retryWaitFor` can return (`retryTestTimeoutMs`).
201
206
  */
202
207
  /**
203
208
  * How many attempts the receiver refuses before answering 204 (suite 2.0.3).
@@ -230,9 +235,9 @@ function failFirstFor(policy: { maxAttempts?: number } | null): number {
230
235
 
231
236
  const WAIT_SLACK_MS = 30_000;
232
237
  /** One `retryWaitMs` wait (the retry leg). */
233
- const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
238
+ const RETRY_TEST_TIMEOUT_MS = retryTestTimeoutMs(1, WAIT_SLACK_MS);
234
239
  /** Two sequential `retryWaitMs` waits (the dead-letter leg: observe a retry, then exhaust). */
235
- const DEAD_LETTER_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS * 2 + WAIT_SLACK_MS;
240
+ const DEAD_LETTER_TEST_TIMEOUT_MS = retryTestTimeoutMs(2, WAIT_SLACK_MS);
236
241
 
237
242
  /** Register the suite receiver; null (with a note) when the host's SSRF guard refuses a loopback URL. */
238
243
  async function register(rx: ScopedReceiver): Promise<{ webhookId: string } | null> {
@@ -33,7 +33,7 @@ import { req } from '../lib/requirement-ids.js';
33
33
  import { readErrorCode } from '../lib/error-envelope.js';
34
34
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
35
35
  import { hitHeader, mintWhsec, startModalReceiver, STANDARD_WEBHOOKS_ID, type ModalHit } from '../lib/webhook-receiver.js';
36
- import { retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
36
+ import { retryTestTimeoutMs, retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
37
37
  import {
38
38
  STANDARD_WEBHOOKS_ALG,
39
39
  deliveriesFor,
@@ -128,5 +128,5 @@ describe('RFC 0201 §C.10 — webhook-id is stable across retries, distinct acro
128
128
  for (const { id } of perKey) {
129
129
  expect(subs.includes(id), req(ID, 'RFC 0201 §C.11', 'webhook-id MUST NOT be the subscription id')).toBe(false);
130
130
  }
131
- }, CAP_MS + 45_000);
131
+ }, retryTestTimeoutMs(1, 45_000));
132
132
  });
@@ -45,7 +45,7 @@ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedR
45
45
  import { readErrorCode } from '../lib/error-envelope.js';
46
46
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
47
47
  import { req } from '../lib/requirement-ids.js';
48
- import { retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
48
+ import { retryTestTimeoutMs, retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
49
49
 
50
50
  export const REQUIRES_HOST_CALLBACK = 'the host POSTs and retries webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
51
51
 
@@ -180,5 +180,5 @@ describe('RFC 0215 §B — unregistering stops the attempts (gated on webhooks)'
180
180
  if (controlLate.length === 0) {
181
181
  return blockedDespiteAssertions(`the control subscription was not retried after the 204 + ${GRACE_MS}ms within ${windowMs}ms either, so the target's silence shows nothing — the schedule finished inside the grace or runs past the window (raise OPENWOP_WEBHOOK_RETRY_WAIT_MS above the host's backoff sum) — ${detail}`);
182
182
  }
183
- }, CAP_MS * 2 + 90_000);
183
+ }, retryTestTimeoutMs(2, 90_000));
184
184
  });