@openwop/openwop-conformance 2.39.5 → 2.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.40.1] — 2026-09-26 — three declared-unwitnessable rows are reached and say why
4
+
5
+ - **Three Falsifiability rows declared non-executable now have a leg that records the reason** (RFC 0174 §B.1 rule 4, second branch: "the suite must reach the leg and record a reason"). Each RFC's table declares one of these rows unwitnessable, but no suite leg referenced it, so no bundle could account for it. Each of RFCs 0197, 0205 and 0209 was held from `Accepted` by that one row. No leg can record `executed-pass` without observing the requirement.
6
+ - `openwop.requirement.0197.retired-not-emitted` (`v2-capability-maturity-bounded`, new describe) is **conditional, not constant**. It reads `spec/v1/deprecations.json`, `spec/v2/migrations.json` and `spec/v2/release.json` from the shipped corpus. While no `v2-minor` row with `retirement.persistence: "persisted"` is due, it records `inapplicable` with RFC 0197 §A.3's reason; that is today's state, with 0 rows. Once one is due, it locates the member by the schema's `x-openwop-retired-in` annotation (a property name, or a `const` / single-`enum` value). It then drives a `conformance-noop` run and fails on any event that carries the member. A due row whose member it cannot locate records `blocked`. The branches no host can reach yet are self-tested in `src/lib/v2-retired-members.test.ts` (due / not-due / advertised-class / unannotated / caught emission / clean event).
7
+ - `openwop.requirement.0205.export-alias-equivalence` (`v2-artifact-a2a-shape`, new describe) records `inapplicable` on every host, before any request, with RFC 0205 §C.11's verdict: consumer matching logic and a pack-author call, neither on the wire.
8
+ - `openwop.requirement.0209.render-needs-root` (`v2-a2ui-v09-surface`, new describe) records `inapplicable` on every host, before any request or seam gate, with RFC 0209 §C.9's verdict. It names the reference-impl witness: openwop-app `frontend/react/src/chat/a2ui/__tests__/a2ui-v09-render-needs-root.test.tsx` (openwop-app#4121).
9
+ - **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.40.1` because `2.40.0` is tagged. Not tagged, not published. 561 scenario files, unchanged, so this is a patch.
10
+
11
+ ## [2.40.0] — 2026-09-26 — RFC 0215: one subscription's dead receiver cannot hold another's delivery, and unregistering stops the retries
12
+
13
+ - **Two new scenarios, gated on `webhooks` (RFC 0215, `Active` 2026-09-25).** `v2-webhook-delivery-isolation` (`openwop.requirement.0215.no-head-of-line`) holds 8 subscriptions' attempts unanswered, then checks that a ninth subscription's attempt still starts. The 8 filter `run.started` and the ninth `run.completed` of a 2 s `conformance-delay` run, so contention exists before the healthy delivery falls due and the host's dispatch order cannot decide the row. It fails when the held attempts were still open at the due time and the healthy one waited for the host to release one. It records `partial-witness` when the host's own delivery timeout closed them first. `v2-webhook-unregister-stops-delivery` (`openwop.requirement.0215.unregister-stops-delivery`) answers `500` to two subscriptions on one run, unregisters one after its first attempt, and fails if that one is attempted more than 5 s after the `204`. It passes only when the other, the control, was retried after that point: a silence counts only when a retry demonstrably would have come. Sabotage on the v2 reference host: a sequential worker and a 5-slot pool each fail the first scenario, and an attempt that falls back to a cached subscription after the delete fails the second.
14
+ - **Suite `2.40.0`**: 561 scenario files, a minor because two are added. `@openwop/spec-artifacts` moves in lockstep at the same exact pin, and ships the new `webhooks.md` §Durability text and the two invariant rows' threat-model home.
15
+
3
16
  ## [2.39.5] — 2026-09-26 — RFC 0213 §B has a deterministic witness, and a host front whose URLs follow the request origin verifies
4
17
 
5
18
  - **RFC 0213 §B has a deterministic witness: `0213.in-flight-refused-under-hold`.** On a host whose create answers in milliseconds the unaided `v2-idempotency-in-flight` leg records `partial-witness` — same-key creates never overlap. The new leg arms the seams-profile seam `armIdempotencyHold` (`POST /conformance/seams/sample/test/idempotency/hold`, host-sample-test-seams.md §26), which holds the caller's next real create's claim in flight; a concurrent same-key create MUST then be refused `409 idempotency_in_flight` by the host's own path (no retry timing in `details`), and the held create still wins. Seams not advertised ⇒ `inapplicable`; advertised but the path unserved ⇒ `blocked`.
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.39.5 @openwop/spec-artifacts@2.39.5
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.40.1 @openwop/spec-artifacts@2.40.1
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 559 scenario files under `src/scenarios/`.
138
+ The current suite has 561 scenario files under `src/scenarios/`.
139
139
  - 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
140
140
  - 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.
141
141
  - 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.
@@ -481,7 +481,7 @@ Server-required (added in 1.7.0):
481
481
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
482
482
  | **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. |
483
483
 
484
- Current source tree: 559 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
484
+ Current source tree: 561 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
485
 
486
486
  ## Remaining Gaps
487
487
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.39.5",
4
- "stampSha256": "db86d304e8d1c9042e51cc62a10c3d952826f9f3d8eddbcee08c29128c4e4252"
3
+ "version": "2.40.1",
4
+ "stampSha256": "1fb5733bb565913ac21afc3e5179ea3cb4d1b98ab39f543fb3c70f9a936453bd"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.39.5",
3
+ "version": "2.40.1",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.39.5"
59
+ "@openwop/spec-artifacts": "2.40.1"
60
60
  }
61
61
  }
@@ -167,6 +167,8 @@
167
167
  "openwop.it.v2-security-defaults-table.the-obligation-table-has-a-witness-column-and-no-row-is-unwitnessable": "openwop.requirement.0173.security-defaults-table",
168
168
  "openwop.it.v2-spec-artifacts-digest.the-corpus-gate-script-exits-0": "openwop.requirement.0168.spec-artifacts-digest",
169
169
  "openwop.it.v2-threat-model-template.the-corpus-gate-script-exits-0": "openwop.requirement.0173.threat-model-template",
170
- "openwop.it.v2-witness-required.the-corpus-gate-script-exits-0": "openwop.requirement.0168.witness-required"
170
+ "openwop.it.v2-witness-required.the-corpus-gate-script-exits-0": "openwop.requirement.0168.witness-required",
171
+ "openwop.it.bundle-maturity-gate.the-committed-corpus-is-green-and-the-report-names-the-overstated-families-and-t": "openwop.it.bundle-maturity-gate.the-committed-corpus-is-green-and-the-gate-states-when-c-7-binds",
172
+ "openwop.it.bundle-maturity-gate.the-same-committed-bundle-re-labelled-onto-the-rfc-s-suite-fails-naming-every-ov": "openwop.it.bundle-maturity-gate.the-superseded-myndhyve-bundle-re-labelled-onto-the-rfc-s-suite-fails-naming-eve"
171
173
  }
172
174
  }
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": 633,
6
- "tests": 2425,
7
- "withStableId": 2425,
5
+ "files": 635,
6
+ "tests": 2430,
7
+ "withStableId": 2430,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2305
9
+ "explicitIds": 2310
10
10
  },
11
11
  "records": [
12
12
  {
@@ -27218,7 +27218,7 @@
27218
27218
  {
27219
27219
  "id": "openwop.it.v2-a2ui-v09-surface.a-version-2-envelope-carrying-a-version-1-body-is-refused-the-positive-is-admitt",
27220
27220
  "file": "v2-a2ui-v09-surface.test.ts",
27221
- "line": 129,
27221
+ "line": 137,
27222
27222
  "title": "a version-2 envelope carrying a version-1 body is refused; the positive is admitted; above the floor is refused; below it follows envelopeStrictness",
27223
27223
  "explicitId": "openwop.requirement.0209.version-selects-branch",
27224
27224
  "citations": [
@@ -27262,7 +27262,7 @@
27262
27262
  {
27263
27263
  "id": "openwop.it.v2-a2ui-v09-surface.no-first-createsurface-a-message-after-deletesurface-and-a-second-createsurface",
27264
27264
  "file": "v2-a2ui-v09-surface.test.ts",
27265
- "line": 155,
27265
+ "line": 163,
27266
27266
  "title": "no first createSurface, a message after deleteSurface, and a second createSurface are each refused; the valid steps are admitted",
27267
27267
  "explicitId": "openwop.requirement.0209.fold-guarded",
27268
27268
  "citations": [
@@ -27276,7 +27276,7 @@
27276
27276
  {
27277
27277
  "id": "openwop.it.v2-a2ui-v09-surface.a-createsurface-catalogid-differing-from-the-payload-catalogid-is-refused",
27278
27278
  "file": "v2-a2ui-v09-surface.test.ts",
27279
- "line": 180,
27279
+ "line": 188,
27280
27280
  "title": "a createSurface.catalogId differing from the payload catalogId is refused",
27281
27281
  "explicitId": "openwop.requirement.0209.catalog-equality",
27282
27282
  "citations": [
@@ -27294,7 +27294,7 @@
27294
27294
  {
27295
27295
  "id": "openwop.it.v2-a2ui-v09-surface.a-message-whose-surfaceid-differs-from-the-payload-surfaceid-is-refused",
27296
27296
  "file": "v2-a2ui-v09-surface.test.ts",
27297
- "line": 194,
27297
+ "line": 202,
27298
27298
  "title": "a message whose surfaceId differs from the payload surfaceId is refused",
27299
27299
  "explicitId": "openwop.it.v2-a2ui-v09-surface.surface-id-equality",
27300
27300
  "citations": [
@@ -27312,7 +27312,7 @@
27312
27312
  {
27313
27313
  "id": "openwop.it.v2-a2ui-v09-surface.a-recorded-version-2-surface-is-returned-byte-equal-on-poll-and-carried-by-a-for",
27314
27314
  "file": "v2-a2ui-v09-surface.test.ts",
27315
- "line": 210,
27315
+ "line": 218,
27316
27316
  "title": "a recorded version-2 surface is returned byte-equal on poll and carried by a fork at a later fromSeq",
27317
27317
  "explicitId": "openwop.it.v2-a2ui-v09-surface.recorded-as-recorded",
27318
27318
  "citations": [
@@ -27349,7 +27349,7 @@
27349
27349
  {
27350
27350
  "id": "openwop.it.v2-a2ui-v09-surface.a-version-1-surface-recorded-at-floor-1-is-returned-byte-equal-on-poll-and-carri",
27351
27351
  "file": "v2-a2ui-v09-surface.test.ts",
27352
- "line": 237,
27352
+ "line": 245,
27353
27353
  "title": "a version-1 surface recorded at floor 1 is returned byte-equal on poll and carried by a fork",
27354
27354
  "explicitId": "openwop.requirement.0209.legacy-readable",
27355
27355
  "citations": [
@@ -27386,7 +27386,7 @@
27386
27386
  {
27387
27387
  "id": "openwop.it.v2-a2ui-v09-surface.one-untrusted-update-taints-a-trusted-surface-a-later-trusted-update-does-not-la",
27388
27388
  "file": "v2-a2ui-v09-surface.test.ts",
27389
- "line": 261,
27389
+ "line": 269,
27390
27390
  "title": "one untrusted update taints a trusted surface, a later trusted update does not launder it, and the approval does not advance; an all-trusted surface does",
27391
27391
  "explicitId": "openwop.requirement.0209.taint-sticky",
27392
27392
  "citations": [
@@ -27397,6 +27397,19 @@
27397
27397
  }
27398
27398
  ]
27399
27399
  },
27400
+ {
27401
+ "id": "openwop.it.v2-a2ui-v09-surface.records-why-render-needs-root-has-no-server-side-witness-and-where-its-witness-l",
27402
+ "file": "v2-a2ui-v09-surface.test.ts",
27403
+ "line": 308,
27404
+ "title": "records why render-needs-root has no server-side witness, and where its witness lives",
27405
+ "explicitId": "openwop.requirement.0209.render-needs-root",
27406
+ "citations": [
27407
+ {
27408
+ "section": "RFC 0209 §C.9",
27409
+ "requirement": "a renderer does not render a surface before its root component exists"
27410
+ }
27411
+ ]
27412
+ },
27400
27413
  {
27401
27414
  "id": "openwop.it.v2-advertised-fixtures-exist.a-sampled-advertised-fixture-is-actually-creatable-not-just-listed",
27402
27415
  "file": "v2-advertised-fixtures-exist.test.ts",
@@ -27467,7 +27480,7 @@
27467
27480
  {
27468
27481
  "id": "openwop.it.v2-artifact-a2a-shape.a-getartifact-answered-as-application-a2a-json-validates-against-artifact-schema",
27469
27482
  "file": "v2-artifact-a2a-shape.test.ts",
27470
- "line": 80,
27483
+ "line": 87,
27471
27484
  "title": "a getArtifact answered as application/a2a+json validates against artifact.schema.json",
27472
27485
  "explicitId": "openwop.requirement.0205.artifact-a2a-shape",
27473
27486
  "citations": [
@@ -27491,7 +27504,7 @@
27491
27504
  {
27492
27505
  "id": "openwop.it.v2-artifact-a2a-shape.the-artifactid-of-an-application-a2a-json-body-equals-the-artifactid-path-segmen",
27493
27506
  "file": "v2-artifact-a2a-shape.test.ts",
27494
- "line": 92,
27507
+ "line": 99,
27495
27508
  "title": "the artifactId of an application/a2a+json body equals the {artifactId} path segment",
27496
27509
  "explicitId": "openwop.requirement.0205.artifact-id-matches-path",
27497
27510
  "citations": [
@@ -27508,7 +27521,7 @@
27508
27521
  {
27509
27522
  "id": "openwop.it.v2-artifact-a2a-shape.the-url-of-a-url-part-answers-no-2xx-to-a-second-tenant-or-to-no-credential",
27510
27523
  "file": "v2-artifact-a2a-shape.test.ts",
27511
- "line": 103,
27524
+ "line": 110,
27512
27525
  "title": "the url of a url Part answers no 2xx to a second tenant or to no credential",
27513
27526
  "explicitId": "openwop.requirement.0205.artifact-url-part-scoped",
27514
27527
  "citations": [
@@ -27524,6 +27537,19 @@
27524
27537
  }
27525
27538
  ]
27526
27539
  },
27540
+ {
27541
+ "id": "openwop.it.v2-artifact-a2a-shape.records-why-export-alias-equivalence-has-no-wire-witness",
27542
+ "file": "v2-artifact-a2a-shape.test.ts",
27543
+ "line": 132,
27544
+ "title": "records why export-alias-equivalence has no wire witness",
27545
+ "explicitId": "openwop.requirement.0205.export-alias-equivalence",
27546
+ "citations": [
27547
+ {
27548
+ "section": "RFC 0205 §C.11",
27549
+ "requirement": "an export alias and its media type name one format"
27550
+ }
27551
+ ]
27552
+ },
27527
27553
  {
27528
27554
  "id": "openwop.it.v2-assurance-downgrade-audited.an-identity-below-the-lane-floor-is-refused-with-sender-constraint-missing",
27529
27555
  "file": "v2-assurance-downgrade-audited.test.ts",
@@ -27912,7 +27938,7 @@
27912
27938
  {
27913
27939
  "id": "openwop.it.v2-capability-maturity-bounded.no-family-record-advertises-stable-above-its-corpus-declaration-row",
27914
27940
  "file": "v2-capability-maturity-bounded.test.ts",
27915
- "line": 69,
27941
+ "line": 92,
27916
27942
  "title": "no family record advertises `stable` above its corpus declaration row",
27917
27943
  "explicitId": "openwop.requirement.0197.maturity-not-overstated",
27918
27944
  "citations": [
@@ -27933,6 +27959,20 @@
27933
27959
  }
27934
27960
  ]
27935
27961
  },
27962
+ {
27963
+ "id": "openwop.it.v2-capability-maturity-bounded.no-run-emits-a-persisted-member-the-release-has-retired",
27964
+ "file": "v2-capability-maturity-bounded.test.ts",
27965
+ "line": 134,
27966
+ "title": "no run emits a persisted member the release has retired",
27967
+ "explicitId": "openwop.requirement.0197.retired-not-emitted",
27968
+ "citations": [
27969
+ {
27970
+ "section": "RFC 0197 §A.3",
27971
+ "requirement": null,
27972
+ "interpolated": true
27973
+ }
27974
+ ]
27975
+ },
27936
27976
  {
27937
27977
  "id": "openwop.it.v2-capability-record-shape.every-family-record-carries-status-since-and-witness-from-the-wire-legal-sets",
27938
27978
  "file": "v2-capability-record-shape.test.ts",
@@ -33150,6 +33190,47 @@
33150
33190
  }
33151
33191
  ]
33152
33192
  },
33193
+ {
33194
+ "id": "openwop.requirement.0215.no-head-of-line",
33195
+ "file": "v2-webhook-delivery-isolation.test.ts",
33196
+ "line": 109,
33197
+ "title": null,
33198
+ "explicitId": "openwop.requirement.0215.no-head-of-line",
33199
+ "citations": [
33200
+ {
33201
+ "section": "webhooks.md §Surfaces",
33202
+ "requirement": "POST /webhooks MUST answer 201 { webhookId }"
33203
+ },
33204
+ {
33205
+ "section": "webhooks.md §Surfaces",
33206
+ "requirement": "the 201 body MUST carry `webhookId`"
33207
+ },
33208
+ {
33209
+ "section": "runs.md §Create",
33210
+ "requirement": null,
33211
+ "interpolated": true
33212
+ },
33213
+ {
33214
+ "section": "webhooks.md §Durability (RFC 0215 §A.1, §A.2)",
33215
+ "requirement": null,
33216
+ "interpolated": true
33217
+ },
33218
+ {
33219
+ "section": "webhooks.md §Durability (RFC 0215 §A.1, §A.2)",
33220
+ "requirement": null,
33221
+ "interpolated": true
33222
+ },
33223
+ {
33224
+ "section": "webhooks.md §Durability (RFC 0215 §A.1, §A.2)",
33225
+ "requirement": "the held subscriptions' attempts MUST have been dispatched"
33226
+ },
33227
+ {
33228
+ "section": "webhooks.md §Durability (RFC 0215 §A.1, §A.2)",
33229
+ "requirement": null,
33230
+ "interpolated": true
33231
+ }
33232
+ ]
33233
+ },
33153
33234
  {
33154
33235
  "id": "openwop.it.v2-webhook-delivery-shape.a-major-2-subscriber-receives-the-v2-rendering-the-delivery-validates-and-run-st",
33155
33236
  "file": "v2-webhook-delivery-shape.test.ts",
@@ -33640,6 +33721,37 @@
33640
33721
  }
33641
33722
  ]
33642
33723
  },
33724
+ {
33725
+ "id": "openwop.it.v2-webhook-unregister-stops-delivery.no-attempt-for-an-unregistered-subscription-starts-after-the-204-while-its-contr",
33726
+ "file": "v2-webhook-unregister-stops-delivery.test.ts",
33727
+ "line": 83,
33728
+ "title": "no attempt for an unregistered subscription starts after the 204, while its control keeps being retried",
33729
+ "explicitId": "openwop.requirement.0215.unregister-stops-delivery",
33730
+ "citations": [
33731
+ {
33732
+ "section": "webhooks.md §Surfaces",
33733
+ "requirement": "POST /webhooks MUST answer 201 { webhookId }"
33734
+ },
33735
+ {
33736
+ "section": "webhooks.md §Surfaces",
33737
+ "requirement": "the 201 body MUST carry `webhookId`"
33738
+ },
33739
+ {
33740
+ "section": "runs.md §Create",
33741
+ "requirement": null,
33742
+ "interpolated": true
33743
+ },
33744
+ {
33745
+ "section": "webhooks.md §Surfaces",
33746
+ "requirement": "DELETE /webhooks/{webhookId} MUST answer 204 for the caller's own subscription"
33747
+ },
33748
+ {
33749
+ "section": "webhooks.md §Durability (RFC 0215 §B)",
33750
+ "requirement": null,
33751
+ "interpolated": true
33752
+ }
33753
+ ]
33754
+ },
33643
33755
  {
33644
33756
  "id": "openwop.it.v2-well-known-one-resource.a-header-less-get-and-an-openwop-version-2-0-get-are-two-representations-of-one",
33645
33757
  "file": "v2-well-known-one-resource.test.ts",
@@ -36589,10 +36701,10 @@
36589
36701
  ]
36590
36702
  },
36591
36703
  {
36592
- "id": "openwop.it.bundle-maturity-gate.the-committed-corpus-is-green-and-the-report-names-the-overstated-families-and-t",
36704
+ "id": "openwop.it.bundle-maturity-gate.the-committed-corpus-is-green-and-the-gate-states-when-c-7-binds",
36593
36705
  "file": "bundle-maturity-gate.test.ts",
36594
- "line": 76,
36595
- "title": "the committed corpus is green, and the report NAMES the overstated families and the gap row",
36706
+ "line": 85,
36707
+ "title": "the committed corpus is green, and the gate states when §C.7 binds",
36596
36708
  "explicitId": "openwop.requirement.0197.bundle-maturity-bound",
36597
36709
  "citations": [
36598
36710
  {
@@ -36600,14 +36712,6 @@
36600
36712
  "requirement": null,
36601
36713
  "interpolated": true
36602
36714
  },
36603
- {
36604
- "section": "spec/v2/core/capabilities.md §8 (RFC 0197 §C.7)",
36605
- "requirement": "a \"reported\" disposition that printed nothing would be indistinguishable from a clean tree; the report MUST name the gap row that owes the re-cut"
36606
- },
36607
- {
36608
- "section": "spec/v2/core/capabilities.md §8 (RFC 0197 §C.7)",
36609
- "requirement": "the report MUST name the families by key, not just count them — a number nobody can act on is not a report"
36610
- },
36611
36715
  {
36612
36716
  "section": "spec/v2/core/capabilities.md §8 (RFC 0197 §C.7)",
36613
36717
  "requirement": "the gate MUST state which suite version makes §C.7 binding, so \"reported\" has a visible end date"
@@ -36615,10 +36719,10 @@
36615
36719
  ]
36616
36720
  },
36617
36721
  {
36618
- "id": "openwop.it.bundle-maturity-gate.the-same-committed-bundle-re-labelled-onto-the-rfc-s-suite-fails-naming-every-ov",
36722
+ "id": "openwop.it.bundle-maturity-gate.the-superseded-myndhyve-bundle-re-labelled-onto-the-rfc-s-suite-fails-naming-eve",
36619
36723
  "file": "bundle-maturity-gate.test.ts",
36620
- "line": 86,
36621
- "title": "the same committed bundle re-labelled onto the RFC’s suite FAILS, naming every overstated family",
36724
+ "line": 93,
36725
+ "title": "the superseded MyndHyve bundle re-labelled onto the RFC’s suite FAILS, naming every overstated family",
36622
36726
  "explicitId": "openwop.requirement.0197.bundle-maturity-bound",
36623
36727
  "citations": [
36624
36728
  {
@@ -36640,7 +36744,7 @@
36640
36744
  {
36641
36745
  "id": "openwop.it.bundle-maturity-gate.the-count-is-derived-promoting-one-family-in-the-declaration-drops-38-to-37",
36642
36746
  "file": "bundle-maturity-gate.test.ts",
36643
- "line": 95,
36747
+ "line": 102,
36644
36748
  "title": "the count is DERIVED — promoting one family in the declaration drops 38 to 37",
36645
36749
  "explicitId": "openwop.requirement.0197.bundle-maturity-bound",
36646
36750
  "citations": [
@@ -36658,7 +36762,7 @@
36658
36762
  {
36659
36763
  "id": "openwop.it.bundle-maturity-gate.a-bundle-whose-suite-is-below-the-rfc-s-is-reported-not-failed-even-with-the-sam",
36660
36764
  "file": "bundle-maturity-gate.test.ts",
36661
- "line": 103,
36765
+ "line": 110,
36662
36766
  "title": "a bundle whose suite is BELOW the RFC’s is reported, not failed, even with the same 38",
36663
36767
  "explicitId": "openwop.requirement.0197.bundle-maturity-bound",
36664
36768
  "citations": [
@@ -36674,6 +36778,14 @@
36674
36778
  {
36675
36779
  "section": "spec/v2/core/capabilities.md §8 (RFC 0197 §C.7)",
36676
36780
  "requirement": "the reported row must cite COMPATIBILITY.md §2.3 — an old pass stays a measurement at the suite that measured it"
36781
+ },
36782
+ {
36783
+ "section": "spec/v2/core/capabilities.md §8 (RFC 0197 §C.7)",
36784
+ "requirement": "a \"reported\" disposition that printed nothing would be indistinguishable from a clean tree; the report MUST name the gap row that owes the re-cut"
36785
+ },
36786
+ {
36787
+ "section": "spec/v2/core/capabilities.md §8 (RFC 0197 §C.7)",
36788
+ "requirement": "the report MUST name the families by key, not just count them — a number nobody can act on is not a report"
36677
36789
  }
36678
36790
  ]
36679
36791
  },
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
3
3
  "counts": {
4
- "files": 559,
4
+ "files": 561,
5
5
  "v1": 449,
6
- "v2": 119
6
+ "v2": 121
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1557,6 +1557,9 @@
1557
1557
  "v2-version-header-honored.test.ts": [
1558
1558
  2
1559
1559
  ],
1560
+ "v2-webhook-delivery-isolation.test.ts": [
1561
+ 2
1562
+ ],
1560
1563
  "v2-webhook-delivery-shape.test.ts": [
1561
1564
  2
1562
1565
  ],
@@ -1578,6 +1581,9 @@
1578
1581
  "v2-webhook-standard-webhooks-delivery.test.ts": [
1579
1582
  2
1580
1583
  ],
1584
+ "v2-webhook-unregister-stops-delivery.test.ts": [
1585
+ 2
1586
+ ],
1581
1587
  "v2-well-known-one-resource.test.ts": [
1582
1588
  2
1583
1589
  ],
@@ -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.39.5",
5
- "corpusTag": "v2.39.5",
4
+ "version": "2.40.1",
5
+ "corpusTag": "v2.40.1",
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": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "ed925b14e1e3e6f82ec0d0c844c035ae1a3b0dbcf10b3e53b96106327298f5cb",
13
- "api/v2/asyncapi.yaml": "a3ba4c688bd4ee33400ef23718eac3d03bc333df2b3d74644adc6f0b2b3a5b2e",
14
- "api/v2/openapi.yaml": "e4dd5dafff70be8c64de8344c3ae5dd5525515a069fef6f421a226813a305344",
12
+ "api/seams-v2.yaml": "23c87c259cbe4b6d087faa9f358684921d400a11330bde6b27c8cacbf0b16d78",
13
+ "api/v2/asyncapi.yaml": "3629ae4718168e424aee3e93b3c785df904a12229a2c58c0eec0ecaf9f8f5b2e",
14
+ "api/v2/openapi.yaml": "74d9beac93b861a3d191ee5e87ce047a907171df6ecdb3fcb0854d28e8df119c",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,13 +204,13 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "a2f4f866565b5da642b36384a235bf57a293366b7f9faeb0e7f62c3132337932",
207
+ "spec/v1/core-standard-manifest.json": "77d77c3ee245f8f7855a530a3aa610a0a7c00a26df6c97962f1600c93e8b13c1",
208
208
  "spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
211
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
212
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "9424f94441ba5c4d1a8027e1f59be832f46af1a67ff0ca140adca9bb501ee993",
213
+ "spec/v1/gaps.json": "45483b0932d4b2a80b0781419abc697f7d363be46bddfd8163a48364b8f76a36",
214
214
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
215
  "spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -240,7 +240,7 @@
240
240
  "spec/v2/core/security-defaults.md": "500471a8db7af9b776ac40b4a9a278c9f3d45e1c1dead880114e7fadc7bee37d",
241
241
  "spec/v2/core/tool-catalog.md": "35f1a3fd509db0fc0490ddf400686fe94dc4b20c98064d5ebe776da516153e60",
242
242
  "spec/v2/core/versioning.md": "0aded71d090c8358c3ce17763c120cfc1cc42531a5eaa1b8a2a7016ed9594069",
243
- "spec/v2/core/webhooks.md": "9c9169d775875f72982ed3506c1bfe27546ad05806d114af103dd7c28d4d5122",
243
+ "spec/v2/core/webhooks.md": "95880896e37e5b7096693a75203cf0ed70cefa09959230e17b45aa9193078eff",
244
244
  "spec/v2/core/workflow-chain-packs.md": "ee45d3fede3bb6f0cc6abcf0d6c929c7cd213a0fbe862b738a998f929c4d1102",
245
245
  "spec/v2/corrections.json": "cc74ffde74384b0f261a4f625871d922e8661d654d647a8534e21036595cb280",
246
246
  "spec/v2/corrections.schema.json": "4ac595b9a6d7f66d53de03fe0edbe0dc58de46426932387bfdc0ceb3cf949a3e",
@@ -292,9 +292,9 @@
292
292
  "spec/v2/path-manifest.json": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
293
293
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
294
294
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
295
- "spec/v2/release.json": "c3d35fbfae242c3fa3a504edb08fbdbc7ee9dd003250ea7225a0f6641dc614b9",
295
+ "spec/v2/release.json": "c24fde3d12b1732d9f0162ef0a0596ebd24501935cd28801eb892fbb6ec57949",
296
296
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
297
- "spec/v2/surface-baseline.json": "4273f2b75cfde4c620c995b5a4c334cfc15d936dfa7f5b6fa0aa4bf257e8b8b5"
297
+ "spec/v2/surface-baseline.json": "72d51a1bd81cbe3cee07f9ad0ab7b481b60d4e74e651704e9b3242c49c93ec11"
298
298
  },
299
- "corpusCommit": "718fae2de800b73f48a59237cfb843578b0433f1"
299
+ "corpusCommit": "470ee4d7a2cb94ddb893d82d57ade4d95644d74b"
300
300
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * RFC 0197 §A.3 — which persisted-class v2 members are retired at the release
3
+ * the suite ships, and whether an emitted event carries one.
4
+ *
5
+ * A persisted-class retirement keeps the member in the READER schema,
6
+ * annotated `x-openwop-retired-in: "2.N"`, and forbids only its EMISSION after
7
+ * `2.N`. The deprecations register says WHICH rows are persisted-class and
8
+ * when they fall due; the annotation in the v2 schema says WHAT the member is
9
+ * (a property name, or a `const` / single-member `enum` value). This module
10
+ * joins the two so `v2-capability-maturity-bounded`'s `retired-not-emitted`
11
+ * leg can scan real events for it.
12
+ *
13
+ * Pure (the caller passes the parsed register and a schema loader) so the
14
+ * self-test drives the due / not-due / unlocatable branches with fixtures —
15
+ * the register is empty today, so the host leg cannot reach them yet.
16
+ *
17
+ * @see RFCS/0197-v2-surfaces-retired-never-reshaped.md §A.3
18
+ * @see scripts/check-removal-dates.mjs (the corpus gate that requires the annotation on a due persisted row)
19
+ */
20
+
21
+ type Json = Record<string, unknown>;
22
+
23
+ export interface PersistedRetirement {
24
+ readonly id: string;
25
+ readonly removeIn: string;
26
+ readonly due: boolean;
27
+ /** `schemas/v2/…` source files the row cites, relative to the contract root. */
28
+ readonly schemaFiles: readonly string[];
29
+ }
30
+
31
+ export interface RetiredMember {
32
+ readonly rowId: string;
33
+ readonly removeIn: string;
34
+ readonly schemaFile: string;
35
+ /** `property`: an object key; `value`: a string value. */
36
+ readonly kind: 'property' | 'value';
37
+ readonly name: string;
38
+ }
39
+
40
+ export interface RetiredEmission {
41
+ readonly rowId: string;
42
+ readonly member: string;
43
+ readonly eventType: string;
44
+ readonly path: string;
45
+ }
46
+
47
+ const minorOf = (v: string): number | null => {
48
+ const m = /^2\.(\d+)(?:\.\d+)?$/.exec(v.trim());
49
+ return m ? Number(m[1]) : null;
50
+ };
51
+
52
+ const triggersOf = (e: Json): string[] => {
53
+ const t = e['removalTrigger'];
54
+ return Array.isArray(t) ? t.filter((x): x is string => typeof x === 'string') : typeof t === 'string' ? [t] : [];
55
+ };
56
+
57
+ /** Every `v2-minor` row whose `retirement.persistence` is `persisted`, and whether `release` has reached its `removeIn`. */
58
+ export function persistedRetirements(deprecations: unknown, release: string): PersistedRetirement[] {
59
+ const entries = ((deprecations as { entries?: unknown } | null)?.entries ?? []) as Json[];
60
+ const rel = minorOf(release);
61
+ const out: PersistedRetirement[] = [];
62
+ for (const e of Array.isArray(entries) ? entries : []) {
63
+ if (!triggersOf(e).includes('v2-minor')) continue;
64
+ const r = e['retirement'] as Json | undefined;
65
+ if ((r?.['persistence'] ?? 'advertised') !== 'persisted') continue;
66
+ const removeIn = String(e['removeIn'] ?? '');
67
+ const at = minorOf(removeIn);
68
+ const sources = (Array.isArray(e['sources']) ? e['sources'] : []) as Json[];
69
+ out.push({
70
+ id: String(e['id'] ?? '(no id)'),
71
+ removeIn,
72
+ due: rel !== null && at !== null && rel >= at,
73
+ schemaFiles: sources.map((s) => String(s['file'] ?? '')).filter((f) => /^schemas\/v2\//.test(f)),
74
+ });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** The members a due row's schema annotates `x-openwop-retired-in: <removeIn>`. */
80
+ export function retiredMembers(row: PersistedRetirement, loadSchema: (file: string) => unknown): RetiredMember[] {
81
+ const out: RetiredMember[] = [];
82
+ for (const file of row.schemaFiles) {
83
+ const walk = (node: unknown, parentKey: string | null, grandKey: string | null): void => {
84
+ if (Array.isArray(node)) { for (const n of node) walk(n, null, parentKey); return; }
85
+ if (node === null || typeof node !== 'object') return;
86
+ const o = node as Json;
87
+ if (o['x-openwop-retired-in'] === row.removeIn) {
88
+ if (typeof o['const'] === 'string') out.push({ rowId: row.id, removeIn: row.removeIn, schemaFile: file, kind: 'value', name: o['const'] });
89
+ else if (Array.isArray(o['enum']) && o['enum'].length === 1 && typeof o['enum'][0] === 'string') out.push({ rowId: row.id, removeIn: row.removeIn, schemaFile: file, kind: 'value', name: o['enum'][0] });
90
+ else if (grandKey === 'properties' && parentKey !== null) out.push({ rowId: row.id, removeIn: row.removeIn, schemaFile: file, kind: 'property', name: parentKey });
91
+ }
92
+ for (const [k, v] of Object.entries(o)) walk(v, k, parentKey);
93
+ };
94
+ walk(loadSchema(file), null, null);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Every place an event carries a retired member: a key named for a `property` member, or a string equal to a `value` member. */
100
+ export function findRetiredEmissions(events: readonly unknown[], members: readonly RetiredMember[]): RetiredEmission[] {
101
+ const hits: RetiredEmission[] = [];
102
+ for (const ev of events) {
103
+ const eventType = String((ev as Json | null)?.['type'] ?? '(untyped)');
104
+ const walk = (node: unknown, path: string): void => {
105
+ if (typeof node === 'string') {
106
+ for (const m of members) if (m.kind === 'value' && node === m.name) hits.push({ rowId: m.rowId, member: m.name, eventType, path });
107
+ return;
108
+ }
109
+ if (Array.isArray(node)) { node.forEach((n, i) => walk(n, `${path}[${i}]`)); return; }
110
+ if (node === null || typeof node !== 'object') return;
111
+ for (const [k, v] of Object.entries(node as Json)) {
112
+ for (const m of members) if (m.kind === 'property' && k === m.name) hits.push({ rowId: m.rowId, member: m.name, eventType, path: `${path}.${k}` });
113
+ walk(v, `${path}.${k}`);
114
+ }
115
+ };
116
+ walk(ev, '$');
117
+ }
118
+ return hits;
119
+ }
@@ -19,6 +19,14 @@
19
19
  * Every refusal leg carries an admitted control in the same run, so a host that
20
20
  * refuses everything fails as surely as one that refuses nothing.
21
21
  *
22
+ * The last describe accounts for §C.9's `render-needs-root`, whose
23
+ * Falsifiability verdict is `unwitnessable`: the guarantee is render-side, and
24
+ * a server-oriented suite cannot observe a renderer. It records `inapplicable`
25
+ * on EVERY host, before any request and before the seam gate, and names where
26
+ * the real witness lives: a reference-app client probe, `tier: reference-impl`,
27
+ * following the `a2ui-surface-no-code-exec` precedent. It never asserts and
28
+ * cannot become a pass.
29
+ *
22
30
  * @see RFCS/0209-v2-a2ui-surfaces-are-a2ui-v0-9.md
23
31
  * @see spec/v2/ext/a2uiSurface/README.md
24
32
  * @see spec/v2/core/events.md §"The envelope-kind catalog"
@@ -295,3 +303,10 @@ describe('RFC 0209 §C.12 — trust is sticky across the fold (seam-gated)', ()
295
303
  }
296
304
  });
297
305
  });
306
+
307
+ describe('RFC 0209 §C.9 — no render before root (declared unwitnessable here; reference-impl witness)', () => {
308
+ it('records why render-needs-root has no server-side witness, and where its witness lives', () => {
309
+ req('openwop.requirement.0209.render-needs-root', 'RFC 0209 §C.9', 'a renderer does not render a surface before its root component exists');
310
+ return softSkip('inapplicable', 'unwitnessable by this suite (RFC 0209 §C.9 Falsifiability verdict): a render-side guarantee, and a server-oriented suite cannot observe a renderer. The fold guard the server CAN observe is openwop.requirement.0209.fold-guarded, above. The render half is witnessed as a reference-app client probe (tier: reference-impl, the a2ui-surface-no-code-exec precedent): openwop/openwop-app frontend/react/src/chat/a2ui/__tests__/a2ui-v09-render-needs-root.test.tsx (openwop-app#4121, ADR 0749). Removing the `if (!state.renderable)` guard in frontend/react/src/chat/a2ui/v09/A2uiV09Surface.tsx turns 2 of its 4 cases red. This row records that the suite reached the requirement, and it is not a pass.');
311
+ });
312
+ });
@@ -21,6 +21,13 @@
21
21
  * The server-free legs (the schemas against A2A v1.0.1) are corpus rows in
22
22
  * `src/coherence/a2a-parts-schemas.test.ts`.
23
23
  *
24
+ * The last describe accounts for §C.11's `export-alias-equivalence`, whose
25
+ * Falsifiability verdict is `unwitnessable`. It records `inapplicable` with that
26
+ * verdict's reason on EVERY host, before any request, and never asserts. The
27
+ * row exists so a bundle says the suite reached the requirement and why it
28
+ * cannot observe it (`check-accepted-predicate` rule 4, second branch). It is
29
+ * not a witness and cannot become one.
30
+ *
24
31
  * @see RFCS/0205-run-artifacts-and-turns-speak-a2a-parts.md
25
32
  * @see spec/v2/core/runs.md §"Annotations, artifacts, eval summary"
26
33
  */
@@ -120,3 +127,10 @@ describe('RFC 0205 §A.3 — a url Part does not resolve beyond the caller\'s au
120
127
  }
121
128
  });
122
129
  });
130
+
131
+ describe('RFC 0205 §C.11 — an export alias and its media type name one format (declared unwitnessable)', () => {
132
+ it('records why export-alias-equivalence has no wire witness', () => {
133
+ req('openwop.requirement.0205.export-alias-equivalence', 'RFC 0205 §C.11', 'an export alias and its media type name one format');
134
+ return softSkip('inapplicable', 'unwitnessable on the wire (RFC 0205 §C.11 Falsifiability verdict): the equivalence binds a consumer\'s format-matching logic and the pack author\'s host.artifactTypes.export call, and neither is on the wire. The advertised spellings are readable, but a mismatch between them is legal, so no host response can falsify the row. No wire witness exists; this row records that the suite reached the requirement, and it is not a pass.');
135
+ });
136
+ });
@@ -27,21 +27,44 @@
27
27
  * document and 38 families fail. That run is the sabotage proof, recorded in
28
28
  * the RFC 0197 gates PR.
29
29
  *
30
+ * The second describe is RFC 0197 §A.3's emission leg,
31
+ * `openwop.requirement.0197.retired-not-emitted`. Its Falsifiability verdict
32
+ * is "unwitnessable until the first persisted-class retirement row exists", so
33
+ * the leg is CONDITIONAL, never vacuous. It reads the deprecations register
34
+ * the suite ships, and while that has no due `v2-minor` row with
35
+ * `retirement.persistence: "persisted"`, it records `inapplicable` with the
36
+ * RFC's own reason. It must record a reason: rule 4 of
37
+ * `check-accepted-predicate` accepts a declared non-executable row only when a
38
+ * bundle row gives one. Once such a row is due, the leg drives a run and fails
39
+ * on any event that carries a member the v2 schema annotates
40
+ * `x-openwop-retired-in`. It never records `executed-pass` without scanning
41
+ * events. The branches the host leg cannot reach today are self-tested in
42
+ * `lib/v2-retired-members.test.ts`.
43
+ *
30
44
  * @see spec/v2/core/capabilities.md §8
31
45
  * @see spec/v2/declaration.json
32
- * @see RFCS/0197-v2-surfaces-retired-never-reshaped.md §C
46
+ * @see RFCS/0197-v2-surfaces-retired-never-reshaped.md §A.3, §C
33
47
  */
34
48
 
35
49
  import { describe, it, expect } from 'vitest';
36
50
  import { existsSync, readFileSync } from 'node:fs';
37
51
  import { join } from 'node:path';
38
52
  import { v2Discovery } from '../lib/v2.js';
53
+ import { driver } from '../lib/driver.js';
54
+ import { persistedRetirements, retiredMembers, findRetiredEmissions } from '../lib/v2-retired-members.js';
39
55
  import { SCHEMAS_DIR, SPEC_V2_DIR } from '../lib/paths.js';
40
56
  import { softSkip } from '../lib/soft-skip.js';
41
57
  import { req } from '../lib/requirement-ids.js';
42
58
 
43
59
  const DOC = 'spec/v2/core/capabilities.md §8';
44
60
  const ID = 'openwop.requirement.0197.maturity-not-overstated';
61
+ const RETIRED_ID = 'openwop.requirement.0197.retired-not-emitted';
62
+ const RETIRED_DOC = 'RFC 0197 §A.3';
63
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
64
+
65
+ function readJson(path: string): unknown {
66
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
67
+ }
45
68
 
46
69
  interface DeclarationFamily { key: string; maturity?: { technical?: string } }
47
70
 
@@ -106,3 +129,41 @@ describe('v2 capability-maturity-bounded (RFC 0197 §C.7)', () => {
106
129
  expect(records.length, req(ID, DOC, `the scenario asserted over ${records.length} family record(s)${undeclared.length > 0 ? `; ${undeclared.length} carry no declaration row and are left to v2-capabilities-root-closed: ${undeclared.join(', ')}` : ''}`)).toBeGreaterThan(0);
107
130
  });
108
131
  });
132
+
133
+ describe('v2 retired-not-emitted (RFC 0197 §A.3) — conditional on a due persisted-class retirement', () => {
134
+ it('no run emits a persisted member the release has retired', async () => {
135
+ if (SPEC_V2_DIR === null) return softSkip('blocked', 'spec/v2/ is not present in this layout — the retirement register cannot be read, and a register that cannot be read is not an absence');
136
+ const deprecations = readJson(join(SPEC_V2_DIR, '..', 'v1', 'deprecations.json'));
137
+ const migrations = readJson(join(SPEC_V2_DIR, 'migrations.json')) as { rows?: unknown[] } | null;
138
+ const release = (readJson(join(SPEC_V2_DIR, 'release.json')) as { version?: unknown } | null)?.version;
139
+ if (deprecations === null || typeof release !== 'string') return softSkip('blocked', 'spec/v1/deprecations.json or spec/v2/release.json is missing from the shipped corpus — the retirement register cannot be read');
140
+ const rows = persistedRetirements(deprecations, release);
141
+ const due = rows.filter((r) => r.due);
142
+ const census = `corpus ${release}: ${rows.length} persisted-class v2-minor retirement row(s), ${(migrations?.rows ?? []).length} v2→v2 migration row(s)`;
143
+ if (due.length === 0) {
144
+ return softSkip('inapplicable', `unwitnessable until the first persisted-class retirement row is due (RFC 0197 §A.3 Falsifiability): ${census}${rows.length > 0 ? `, none due yet (${rows.map((r) => `${r.id} removeIn ${r.removeIn}`).join(', ')})` : ''} — no retired member exists for a post-2.N event to carry. Before one does, a scenario would be vacuous, and a row that cannot fail is not a witness. This leg asserts the moment such a row falls due.`);
145
+ }
146
+ const contractRoot = join(SPEC_V2_DIR, '..', '..');
147
+ const members = due.flatMap((r) => retiredMembers(r, (file) => readJson(join(contractRoot, file))));
148
+ const unlocated = due.filter((r) => !members.some((m) => m.rowId === r.id));
149
+ if (unlocated.length > 0) return softSkip('blocked', `due persisted-class retirement row(s) whose member carries no x-openwop-retired-in annotation the suite can read: ${unlocated.map((r) => `${r.id} (${r.schemaFiles.join(', ') || 'no schemas/v2 source'})`).join('; ')}. Without the member there is nothing to scan for, and scanning for nothing is not a pass (check-removal-dates owns the missing annotation)`);
150
+ const create = await driver.post('/runs', { workflowId: 'conformance-noop' });
151
+ const runId = String((create.json as { runId?: unknown } | null)?.runId ?? '');
152
+ if (create.status !== 201 || runId === '') return softSkip('blocked', `POST /runs {workflowId: conformance-noop} answered ${create.status} — no post-${due[0]!.removeIn} run to read events from`);
153
+ const deadline = Date.now() + 20_000;
154
+ for (;;) {
155
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
156
+ if (res.status === 200 && TERMINAL.has(String((res.json as { status?: unknown } | null)?.status ?? ''))) break;
157
+ if (Date.now() > deadline) return softSkip('blocked', `conformance-noop run ${runId} did not reach a terminal status within 20 s`);
158
+ await new Promise((r) => setTimeout(r, 200));
159
+ }
160
+ const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
161
+ const events = (poll.json as { events?: unknown } | null)?.events;
162
+ if (poll.status !== 200 || !Array.isArray(events) || events.length === 0) return softSkip('blocked', `the event poll for run ${runId} answered ${poll.status} with no events — there is nothing emitted to scan`);
163
+ const hits = findRetiredEmissions(events, members);
164
+ expect(
165
+ hits.map((h) => `${h.member} (${h.rowId}, retired in ${members.find((m) => m.rowId === h.rowId)?.removeIn}) in ${h.eventType} at ${h.path}`),
166
+ req(RETIRED_ID, RETIRED_DOC, `a host MUST NOT emit a persisted member after the minor that retired it; scanned ${events.length} event(s) for ${members.length} retired member(s): ${members.map((m) => `${m.kind} ${m.name}`).join(', ')}`),
167
+ ).toEqual([]);
168
+ });
169
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * RFC 0215 §A — `webhook-delivery-isolation` (suite 2.40.0, target major 2; gated on `webhooks`).
3
+ *
4
+ * `spec/v2/core/webhooks.md` §Durability: a host MUST NOT make the start of an
5
+ * attempt to one subscription wait for an attempt to a DIFFERENT subscription to
6
+ * finish, and MUST sustain that while at least 8 subscriptions have attempts
7
+ * outstanding that their receivers have not answered (§A.1 + §A.2, one
8
+ * observation at the floor, so one requirement id).
9
+ *
10
+ * The shape (architect review, 2026-09-25): contention first, then the healthy
11
+ * delivery falls due — so the dispatch order the host chooses cannot decide the
12
+ * row. The RFC's first draft registered 9 subscriptions on one event and read a
13
+ * healthy attempt that beat the held ones as `partial-witness`; this file
14
+ * removes that case instead of recording it.
15
+ *
16
+ * - 8 HELD subscriptions filter `run.started`. Their receiver accepts each
17
+ * attempt and never answers, until the leg ends.
18
+ * - 1 HEALTHY subscription filters `run.completed`, and answers 204.
19
+ * - One `conformance-delay` run of `DELAY_MS`. `run.started` makes the held
20
+ * attempts due at t0; `run.completed` makes the healthy one due at
21
+ * t0 + DELAY_MS, by which time 8 attempts are outstanding.
22
+ *
23
+ * Verdicts, each on a positive observation:
24
+ * - pass — the healthy attempt ARRIVED while all 8 held attempts were open.
25
+ * - fail — the healthy attempt arrived only once fewer than 8 were open (the
26
+ * host released one to make room), or never within the window, AND the held
27
+ * attempts had stayed open until the run was terminal: the contention the
28
+ * floor names existed when the healthy delivery fell due, and it waited.
29
+ * - partial-witness — the host's own delivery timeout closed held attempts
30
+ * before the run was terminal. Contention was not sustained when the
31
+ * healthy delivery fell due, so this run of the leg cannot judge §A. The
32
+ * timeout is the host's to choose; a host with a timeout shorter than
33
+ * DELAY_MS is simply not measured by this instrument.
34
+ * - blocked — fewer than 8 held attempts ever arrived AND the healthy one
35
+ * never did either: nothing reached the suite, which says nothing about
36
+ * isolation (`noDeliveryCause`).
37
+ *
38
+ * A bounded pool of k < 9 opens k held attempts, queues the rest and the
39
+ * healthy one, and releases a slot only at its timeout — so it FAILS: its held
40
+ * attempts were open when the run completed, and the healthy attempt waited for
41
+ * one to close. That is the openwop-app WHD-1 defect (RFC 0215 §Motivation 1).
42
+ *
43
+ * @see spec/v2/core/webhooks.md §Durability
44
+ * @see RFCS/0215-webhook-delivery-isolation.md §A
45
+ * @see SECURITY/invariants.yaml `webhook-delivery-isolation`
46
+ */
47
+
48
+ import { afterEach, describe, it, expect } from 'vitest';
49
+ import type { ServerResponse } from 'node:http';
50
+ import { driver } from '../lib/driver.js';
51
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
52
+ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
53
+ import { readErrorCode } from '../lib/error-envelope.js';
54
+ import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
55
+ import { req } from '../lib/requirement-ids.js';
56
+ import { RETRY_WAIT_FLOOR_MS } from '../lib/webhook-retry-window.js';
57
+
58
+ const ID = 'openwop.requirement.0215.no-head-of-line';
59
+ const DOC = 'webhooks.md §Durability (RFC 0215 §A.1, §A.2)';
60
+ const FIXTURE = 'conformance-delay';
61
+ /** §A.2's floor. Changing it is a spec change, not a tuning knob. */
62
+ const FLOOR = 8;
63
+ /**
64
+ * How long after `run.started` the healthy delivery falls due. Short, so a host
65
+ * whose delivery timeout is a few seconds still holds its 8 attempts open when
66
+ * it does; long enough that the held attempts are dispatched first on any host
67
+ * that dispatches at all.
68
+ */
69
+ const DELAY_MS = 2_000;
70
+ /** How long the leg waits for the healthy attempt once the run is terminal. */
71
+ const WINDOW_MS = RETRY_WAIT_FLOOR_MS;
72
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
73
+
74
+ interface Held { readonly webhookId: string; readonly res: ServerResponse; readonly openedAt: number; closedAt: number | null }
75
+
76
+ let receiver: ScopedReceiver | null = null;
77
+ const registered: string[] = [];
78
+ let release: (() => void) | null = null;
79
+ afterEach(async () => {
80
+ release?.();
81
+ release = null;
82
+ // RFC 0215 §B is what makes this cleanup work on a conforming host; on any
83
+ // host it stops this file's subscriptions outliving the exercise.
84
+ for (const id of registered.splice(0)) await driver.delete(`/webhooks/${encodeURIComponent(id)}`).catch(() => undefined);
85
+ if (receiver) { const rx = receiver; receiver = null; await rx.close(); }
86
+ });
87
+
88
+ async function waitTerminal(runId: string, timeoutMs: number): Promise<number | null> {
89
+ const deadline = Date.now() + timeoutMs;
90
+ for (;;) {
91
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
92
+ const status = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : '';
93
+ if (TERMINAL.has(status)) return Date.now();
94
+ if (Date.now() > deadline) return null;
95
+ await new Promise((r) => setTimeout(r, 100));
96
+ }
97
+ }
98
+
99
+ async function waitFor(pred: () => boolean, timeoutMs: number): Promise<boolean> {
100
+ const deadline = Date.now() + timeoutMs;
101
+ while (Date.now() < deadline) {
102
+ if (pred()) return true;
103
+ await new Promise((r) => setTimeout(r, 50));
104
+ }
105
+ return pred();
106
+ }
107
+
108
+ describe('RFC 0215 §A — one subscription\'s receiver does not hold another\'s delivery (gated on webhooks)', () => {
109
+ it(`a healthy subscription's attempt starts while ${FLOOR} other subscriptions' attempts are unanswered`, async () => {
110
+ let doc: Record<string, unknown> | null = null;
111
+ try { doc = await v2Discovery(); } catch { /* recorded below */ }
112
+ if (doc === null) return softSkip('blocked', 'discovery unreachable');
113
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
114
+ if (!(Array.isArray(doc['fixtures']) && (doc['fixtures'] as unknown[]).includes(FIXTURE))) {
115
+ return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run whose completion falls due after its start`);
116
+ }
117
+
118
+ const held = new Map<string, Held>();
119
+ let healthyId: string | null = null;
120
+ let healthyAt: number | null = null;
121
+ let openAtHealthy = -1;
122
+ const openCount = (): number => [...held.values()].filter((h) => h.closedAt === null).length;
123
+
124
+ const rx = await startScopedReceiver((hit, res) => {
125
+ const webhookId = String(hit.headers['openwop-webhook-id'] ?? hit.headers['x-openwop-webhook-id'] ?? '');
126
+ if (webhookId !== '' && webhookId === healthyId) {
127
+ if (healthyAt === null) { healthyAt = Date.now(); openAtHealthy = openCount(); }
128
+ res.writeHead(204); res.end();
129
+ return;
130
+ }
131
+ if (webhookId === '' || held.has(webhookId) || !registered.includes(webhookId)) {
132
+ // A retry of a held attempt, or a stranger: answered, not counted as a new held attempt.
133
+ res.writeHead(204); res.end();
134
+ return;
135
+ }
136
+ const h: Held = { webhookId, res, openedAt: Date.now(), closedAt: null };
137
+ held.set(webhookId, h);
138
+ // The HOST closing the socket before we answer is the host abandoning the attempt.
139
+ res.on('close', () => { if (h.closedAt === null) h.closedAt = Date.now(); });
140
+ });
141
+ receiver = rx;
142
+ release = () => {
143
+ for (const h of held.values()) {
144
+ if (h.closedAt !== null) continue;
145
+ h.closedAt = Date.now();
146
+ try { h.res.writeHead(204); h.res.end(); } catch { /* host already gone */ }
147
+ }
148
+ };
149
+
150
+ const register = async (events: string[]): Promise<string | null> => {
151
+ const reg = await driver.post('/webhooks', { url: rx.url, events });
152
+ if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected' && !rx.tunnelled) return null;
153
+ expect(reg.status, req(ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
154
+ const id = (reg.json as { webhookId?: unknown } | null)?.webhookId;
155
+ expect(typeof id, req(ID, 'webhooks.md §Surfaces', 'the 201 body MUST carry `webhookId`')).toBe('string');
156
+ registered.push(id as string);
157
+ return id as string;
158
+ };
159
+ for (let i = 0; i < FLOOR; i += 1) {
160
+ if ((await register(['run.started'])) === null) {
161
+ return blockedDespiteAssertions('host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel in front of the suite receiver');
162
+ }
163
+ }
164
+ healthyId = await register(['run.completed']);
165
+ if (healthyId === null) return blockedDespiteAssertions('host SSRF guard rejected the loopback receiver');
166
+
167
+ const create = await driver.post('/runs', { workflowId: FIXTURE, inputs: { delayMs: DELAY_MS } });
168
+ expect(create.status, req(ID, 'runs.md §Create', `POST /runs MUST answer 201 for ${FIXTURE}`)).toBe(201);
169
+ const runId = (create.json as { runId: string }).runId;
170
+ const terminalAt = await waitTerminal(runId, DELAY_MS + 30_000);
171
+ if (terminalAt === null) return blockedDespiteAssertions(`${FIXTURE} run ${runId} did not reach a terminal status — the healthy delivery never fell due`);
172
+ const openAtTerminal = openCount();
173
+ const everOpened = held.size;
174
+ await waitFor(() => healthyAt !== null, WINDOW_MS);
175
+ const earliestClose = Math.min(...[...held.values()].map((h) => h.closedAt ?? Infinity));
176
+ const detail = `${everOpened} held attempt(s) arrived; ${openAtTerminal} were still open when the run was seen terminal; `
177
+ + (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`);
178
+
179
+ if (healthyAt === null && everOpened === 0) {
180
+ if (absenceIsUnmeasured(rx)) return blockedDespiteAssertions(noDeliveryCause(rx, 'webhook attempt'));
181
+ return blockedDespiteAssertions(`no attempt for any of the ${FLOOR + 1} subscriptions arrived — ${noDeliveryCause(rx, 'webhook attempt')}`);
182
+ }
183
+ if (healthyAt !== null && openAtHealthy >= FLOOR) {
184
+ expect(openAtHealthy, req(ID, DOC, `an attempt to one subscription MUST NOT wait for attempts to other subscriptions — ${detail}`)).toBeGreaterThanOrEqual(FLOOR);
185
+ } else if (openAtTerminal >= FLOOR) {
186
+ // The healthy attempt waited (or never came), and the contention §A.2
187
+ // names was present when it fell due.
188
+ 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}`));
189
+ } else if (everOpened >= FLOOR || earliestClose < terminalAt) {
190
+ // Contention was not sustained to the due time. Either the host's own
191
+ // delivery timeout released the held attempts early (its choice), or it
192
+ // never opened FLOOR of them at once. The second is the defect, the first
193
+ // is not, and they differ in WHEN the first held attempt closed.
194
+ expect(everOpened, req(ID, DOC, 'the held subscriptions\' attempts MUST have been dispatched')).toBeGreaterThan(0);
195
+ return softSkip('skipped', `partial-witness: 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}`);
196
+ } else {
197
+ 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}`));
198
+ }
199
+ }, DELAY_MS + 30_000 + RETRY_WAIT_FLOOR_MS + 30_000);
200
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * RFC 0215 §B — `webhook-unregister-stops-delivery` (suite 2.40.0, target major 2; gated on `webhooks`).
3
+ *
4
+ * `spec/v2/core/webhooks.md` §Durability: after `unregisterWebhook` answers
5
+ * `204`, a host MUST NOT start any further attempt for that subscription,
6
+ * including attempts already scheduled for retry. An attempt the host had begun
7
+ * sending before the `204` MAY complete.
8
+ *
9
+ * Absence is not evidence on its own (architect review, 2026-09-25). "No retry
10
+ * arrived after the 204" proves nothing about a host whose next retry falls
11
+ * outside the window, and `webhooks.retryPolicy` carries no interval, so the
12
+ * suite cannot compute when that is. The leg therefore runs a CONTROL:
13
+ *
14
+ * - two subscriptions on one receiver, both filtering `run.completed`, both
15
+ * answered `500` on every attempt, so both have a retry scheduled;
16
+ * - the TARGET is unregistered the moment its first attempt has been
17
+ * answered; the CONTROL is left alone;
18
+ * - the control's retries are what the target's would have been — same
19
+ * event, same moment, same policy.
20
+ *
21
+ * Verdicts:
22
+ * - pass — the control was retried after `204 + GRACE_MS` and the target
23
+ * was not: a retry that would have come did not.
24
+ * - fail — an attempt for the target arrived after `204 + GRACE_MS`.
25
+ * - partial-witness — neither had an attempt after `204 + GRACE_MS` within
26
+ * the window: the schedule finished inside the grace, or runs past the
27
+ * window. Nothing was contradicted and nothing was shown.
28
+ * - inapplicable — `retryPolicy.maxAttempts: 1`: there is no retry to stop.
29
+ *
30
+ * GRACE_MS is an instrument setting, not a spec number: webhooks.md states the
31
+ * in-flight allowance as "an attempt the host had begun sending", and the
32
+ * receiver keys on ARRIVAL, so the grace only has to cover transit of a request
33
+ * already on the wire (RFC 0215 Unresolved question 3).
34
+ *
35
+ * @see spec/v2/core/webhooks.md §Durability
36
+ * @see RFCS/0215-webhook-delivery-isolation.md §B
37
+ * @see SECURITY/invariants.yaml `webhook-unregister-stops-delivery`
38
+ */
39
+
40
+ import { afterEach, describe, it, expect } from 'vitest';
41
+ import { driver } from '../lib/driver.js';
42
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
43
+ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedReceiver } from '../lib/scoped-receiver.js';
44
+ import { readErrorCode } from '../lib/error-envelope.js';
45
+ import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
46
+ import { req } from '../lib/requirement-ids.js';
47
+ import { retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
48
+
49
+ const ID = 'openwop.requirement.0215.unregister-stops-delivery';
50
+ const DOC = 'webhooks.md §Durability (RFC 0215 §B)';
51
+ const FIXTURE = 'conformance-noop';
52
+ const GRACE_MS = 5_000;
53
+ /** Once the control has been retried past the grace, how long to keep listening for the target's matching retry. */
54
+ const SETTLE_MS = 5_000;
55
+ const CAP_MS = retryWaitCapMs();
56
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
57
+
58
+ let receiver: ScopedReceiver | null = null;
59
+ const registered: string[] = [];
60
+ afterEach(async () => {
61
+ for (const id of registered.splice(0)) await driver.delete(`/webhooks/${encodeURIComponent(id)}`).catch(() => undefined);
62
+ if (receiver) { const rx = receiver; receiver = null; await rx.close(); }
63
+ });
64
+
65
+ function advertisedRetryPolicy(doc: Record<string, unknown>): { maxAttempts?: number; backoff?: string } | null {
66
+ const read = (holder: unknown): { maxAttempts?: number; backoff?: string } | null => {
67
+ const rp = holder && typeof holder === 'object' ? (holder as { retryPolicy?: unknown }).retryPolicy : undefined;
68
+ return rp && typeof rp === 'object' ? (rp as { maxAttempts?: number; backoff?: string }) : null;
69
+ };
70
+ return read(doc['webhooks']) ?? read(doc['triggerBridge']);
71
+ }
72
+
73
+ async function waitFor(pred: () => boolean, timeoutMs: number): Promise<boolean> {
74
+ const deadline = Date.now() + timeoutMs;
75
+ while (Date.now() < deadline) {
76
+ if (pred()) return true;
77
+ await new Promise((r) => setTimeout(r, 100));
78
+ }
79
+ return pred();
80
+ }
81
+
82
+ describe('RFC 0215 §B — unregistering stops the attempts (gated on webhooks)', () => {
83
+ it('no attempt for an unregistered subscription starts after the 204, while its control keeps being retried', async () => {
84
+ let doc: Record<string, unknown> | null = null;
85
+ try { doc = await v2Discovery(); } catch { /* recorded below */ }
86
+ if (doc === null) return softSkip('blocked', 'discovery unreachable');
87
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
88
+ if (!(Array.isArray(doc['fixtures']) && (doc['fixtures'] as unknown[]).includes(FIXTURE))) {
89
+ return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
90
+ }
91
+ const policy = advertisedRetryPolicy(doc);
92
+ if (policy?.maxAttempts === 1) return softSkip('inapplicable', 'webhooks.retryPolicy.maxAttempts is 1 — the host schedules no retry, so there is none for unregistering to stop');
93
+ const windowMs = retryWaitFor(policy, CAP_MS);
94
+
95
+ const arrivals = new Map<string, number[]>();
96
+ let targetId: string | null = null;
97
+ let unregisteredAt: number | null = null;
98
+ let unregisterStatus = 0;
99
+ let unregistering: Promise<void> | null = null;
100
+
101
+ const rx = await startScopedReceiver((hit, res) => {
102
+ const webhookId = String(hit.headers['openwop-webhook-id'] ?? hit.headers['x-openwop-webhook-id'] ?? '');
103
+ const list = arrivals.get(webhookId) ?? [];
104
+ list.push(Date.now());
105
+ arrivals.set(webhookId, list);
106
+ res.writeHead(500);
107
+ res.end();
108
+ // Unregister the target once its first attempt has been ANSWERED, so the
109
+ // host has a retry scheduled and nothing of its own in flight.
110
+ if (webhookId === targetId && unregistering === null) {
111
+ unregistering = (async () => {
112
+ const del = await driver.delete(`/webhooks/${encodeURIComponent(webhookId)}`);
113
+ unregisterStatus = del.status;
114
+ if (del.status === 204) {
115
+ unregisteredAt = Date.now();
116
+ const i = registered.indexOf(webhookId);
117
+ if (i >= 0) registered.splice(i, 1);
118
+ }
119
+ })();
120
+ }
121
+ });
122
+ receiver = rx;
123
+
124
+ const register = async (): Promise<string | null> => {
125
+ const reg = await driver.post('/webhooks', { url: rx.url, events: ['run.completed'] });
126
+ if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected' && !rx.tunnelled) return null;
127
+ expect(reg.status, req(ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
128
+ const id = (reg.json as { webhookId?: unknown } | null)?.webhookId;
129
+ expect(typeof id, req(ID, 'webhooks.md §Surfaces', 'the 201 body MUST carry `webhookId`')).toBe('string');
130
+ registered.push(id as string);
131
+ return id as string;
132
+ };
133
+ const controlId = await register();
134
+ targetId = await register();
135
+ if (controlId === null || targetId === null) {
136
+ return blockedDespiteAssertions('host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel in front of the suite receiver');
137
+ }
138
+ const target = targetId;
139
+
140
+ const create = await driver.post('/runs', { workflowId: FIXTURE });
141
+ expect(create.status, req(ID, 'runs.md §Create', `POST /runs MUST answer 201 for ${FIXTURE}`)).toBe(201);
142
+ const runId = (create.json as { runId: string }).runId;
143
+ const deadline = Date.now() + 30_000;
144
+ for (;;) {
145
+ const r = await driver.get(`/runs/${encodeURIComponent(runId)}`);
146
+ if (r.status === 200 && TERMINAL.has(String((r.json as { status?: unknown } | null)?.status ?? ''))) break;
147
+ if (Date.now() > deadline) return blockedDespiteAssertions(`${FIXTURE} run ${runId} did not reach a terminal status — no run.completed to deliver`);
148
+ await new Promise((r2) => setTimeout(r2, 250));
149
+ }
150
+
151
+ const firstTarget = await waitFor(() => (arrivals.get(target)?.length ?? 0) > 0, windowMs);
152
+ if (!firstTarget) {
153
+ if (absenceIsUnmeasured(rx)) return blockedDespiteAssertions(noDeliveryCause(rx, 'run.completed attempt'));
154
+ return blockedDespiteAssertions(`the target subscription's first attempt never arrived, so there was nothing to unregister after — ${noDeliveryCause(rx, 'run.completed attempt')}`);
155
+ }
156
+ await waitFor(() => unregistering !== null, 5_000);
157
+ if (unregistering !== null) await unregistering;
158
+ expect(unregisterStatus, req(ID, 'webhooks.md §Surfaces', 'DELETE /webhooks/{webhookId} MUST answer 204 for the caller\'s own subscription')).toBe(204);
159
+ const cutoff = (unregisteredAt as number | null ?? Date.now()) + GRACE_MS;
160
+
161
+ const after = (id: string): number[] => (arrivals.get(id) ?? []).filter((t) => t > cutoff);
162
+ await waitFor(() => after(controlId).length > 0 || after(target).length > 0, windowMs);
163
+ if (after(controlId).length > 0) await new Promise((r) => setTimeout(r, SETTLE_MS));
164
+
165
+ const targetLate = after(target);
166
+ const controlLate = after(controlId);
167
+ const detail = `target ${target}: ${arrivals.get(target)?.length ?? 0} attempt(s), ${targetLate.length} after the 204 + ${GRACE_MS}ms; control ${controlId}: ${arrivals.get(controlId)?.length ?? 0} attempt(s), ${controlLate.length} after that point`;
168
+ expect(
169
+ targetLate.length,
170
+ req(ID, DOC, `after unregisterWebhook answers 204 the host MUST NOT start another attempt for that subscription, including retries already scheduled — ${detail}`),
171
+ ).toBe(0);
172
+ if (controlLate.length === 0) {
173
+ return softSkip('skipped', `partial-witness: 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}`);
174
+ }
175
+ }, CAP_MS * 2 + 90_000);
176
+ });