@openwop/openwop-conformance 2.3.0 → 2.3.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,19 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.3.1] — 2026-09-17 — the webhook body is finally read
4
+
5
+ **Why a patch.** One scenario file is added, which `PROTOCOL-STATUS.md` calls a minor — but the previous cut (2.3.0) was a minor ninety minutes ago and this file witnesses a MUST that predates it; cut as a patch alongside the register and ext-rule edits it ships with. If that reads as bending the rule, the rule wins and this becomes 2.4.0 on review.
6
+
7
+ ### Added
8
+
9
+ - **`v2-webhook-delivery-shape.test.ts`** — gated on the `webhooks` family. A major-2
10
+ subscriber's delivery body validates against `run-event.schema.json` (v2) and
11
+ `owner` is exactly `{tenant, subject}`; a major-1 subscriber still receives
12
+ `principal`; a seeded era-2 run (seam-gated) is delivered projected. The
13
+ second leg is the one that keeps the first honest: projecting BOTH channels
14
+ to v2 is as wrong as projecting none. Sabotage on the reporting host: drop
15
+ the fan-out projection → leg 1 reds, leg 2 stays green.
16
+
3
17
  ## [2.3.0] — 2026-09-17 — the three RFC 0186 payload seats are witnessed
4
18
 
5
19
  **Why a minor.** `PROTOCOL-STATUS.md` §suite version: minor on scenario add/remove. One scenario file is added.
package/README.md CHANGED
@@ -117,7 +117,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
117
117
 
118
118
  ## What's Covered
119
119
 
120
- The current suite has 520 scenario files under `src/scenarios/`.
120
+ The current suite has 521 scenario files under `src/scenarios/`.
121
121
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
122
122
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
123
123
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -462,7 +462,7 @@ Server-required (added in 1.7.0):
462
462
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
463
463
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
464
464
 
465
- Current source tree: 520 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
465
+ Current source tree: 521 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
466
466
 
467
467
  ## Remaining Gaps
468
468
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.3.0",
4
- "stampSha256": "d96276f51b1207530f41ee5233041c08280fe859446168454c5d5ea3a2cb0120"
3
+ "version": "2.3.1",
4
+ "stampSha256": "a1a2aa236d43d69c8c79578154eb02fad1679816bc8f11052dab46a735f4ea8b"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.3.0",
3
+ "version": "2.3.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.3.0"
59
+ "@openwop/spec-artifacts": "2.3.1"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 569,
6
- "tests": 2165,
7
- "withStableId": 2165,
5
+ "files": 570,
6
+ "tests": 2168,
7
+ "withStableId": 2168,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2120
9
+ "explicitIds": 2123
10
10
  },
11
11
  "records": [
12
12
  {
@@ -29226,6 +29226,55 @@
29226
29226
  }
29227
29227
  ]
29228
29228
  },
29229
+ {
29230
+ "id": "openwop.it.v2-webhook-delivery-shape.a-major-2-subscriber-receives-the-v2-payload-the-body-validates-and-owner-is-exa",
29231
+ "file": "v2-webhook-delivery-shape.test.ts",
29232
+ "line": 107,
29233
+ "title": "a major-2 subscriber receives the v2 payload: the body validates and owner is exactly {tenant, subject}",
29234
+ "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29235
+ "citations": [
29236
+ {
29237
+ "section": "spec/v2/core/webhooks.md §Deliveries",
29238
+ "requirement": null,
29239
+ "interpolated": true
29240
+ },
29241
+ {
29242
+ "section": "spec/v2/core/webhooks.md §Deliveries",
29243
+ "requirement": "a major-2 delivery's owner MUST be exactly {tenant, subject} — a v1 owner block (principal, principalKind) on a v2 delivery is the fan-out forwarding the in-process dialect instead of projecting"
29244
+ }
29245
+ ]
29246
+ },
29247
+ {
29248
+ "id": "openwop.it.v2-webhook-delivery-shape.a-major-1-subscriber-still-receives-principal-the-v1-wire-does-not-move-mid-over",
29249
+ "file": "v2-webhook-delivery-shape.test.ts",
29250
+ "line": 123,
29251
+ "title": "a major-1 subscriber still receives principal — the v1 wire does not move mid-overlap",
29252
+ "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29253
+ "citations": [
29254
+ {
29255
+ "section": "spec/v2/core/versioning.md §1.2",
29256
+ "requirement": "a major-1 subscriber MUST still receive the v1 owner block (principal) — projecting every channel to v2 is as wrong as projecting none; the projection is contract-scoped"
29257
+ }
29258
+ ]
29259
+ },
29260
+ {
29261
+ "id": "openwop.it.v2-webhook-delivery-shape.a-seeded-era-2-run-is-delivered-projected-too-the-fan-out-branch-a-fresh-run-can",
29262
+ "file": "v2-webhook-delivery-shape.test.ts",
29263
+ "line": 140,
29264
+ "title": "a seeded era-2 run is delivered projected too — the fan-out branch a fresh run cannot reach (seam-gated)",
29265
+ "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29266
+ "citations": [
29267
+ {
29268
+ "section": "spec/v2/core/webhooks.md §Deliveries",
29269
+ "requirement": null,
29270
+ "interpolated": true
29271
+ },
29272
+ {
29273
+ "section": "spec/v2/core/webhooks.md §Deliveries",
29274
+ "requirement": "a projected era-2 delivery MUST NOT carry the v1 owner block"
29275
+ }
29276
+ ]
29277
+ },
29229
29278
  {
29230
29279
  "id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
29231
29280
  "file": "v2-webhook-durable-delivery.test.ts",
@@ -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": 520,
4
+ "files": 521,
5
5
  "v1": 445,
6
- "v2": 77
6
+ "v2": 78
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1451,6 +1451,9 @@
1451
1451
  "v2-version-header-honored.test.ts": [
1452
1452
  2
1453
1453
  ],
1454
+ "v2-webhook-delivery-shape.test.ts": [
1455
+ 2
1456
+ ],
1454
1457
  "v2-webhook-durable-delivery.test.ts": [
1455
1458
  2
1456
1459
  ],
@@ -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.3.0",
5
- "corpusTag": "v2.3.0",
4
+ "version": "2.3.1",
5
+ "corpusTag": "v2.3.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": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "4d90674341e8de143a3149d58303ae79e46dc44a1b1287a779a4690885775f9e",
13
- "api/v2/asyncapi.yaml": "680960e1acaf412175cb57f5eaceb2bd42a443c216fb4f4be6394f7fc095f7cf",
14
- "api/v2/openapi.yaml": "7cdd4e2aa97d8b042df5f0d53943d56b83c1ad00f0c9687fa0ab9e8d9dc9d822",
12
+ "api/seams-v2.yaml": "435a0973ffffcf4cc97a976d74960f5009a192928024892f16ce3a09032be58f",
13
+ "api/v2/asyncapi.yaml": "92073792728f18b3f6359c0890e476c7fd8605f4ead64e5ccf2158122be10288",
14
+ "api/v2/openapi.yaml": "05a0e36372f012ceac51f6a20c757e73d6a61c77c902f2730a886390c7dba545",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -169,7 +169,7 @@
169
169
  "schemas/v2/residency.schema.json": "3fd23c9732beb140504e81560fc426eb6764d547e56da5fb92c5a5c7bf04db3a",
170
170
  "schemas/v2/run-ancestry-response.schema.json": "334c17f2062dea62726ca1d07b8be3f169cdc422af843c1def5fb317039814c9",
171
171
  "schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
172
- "schemas/v2/run-event-payloads.schema.json": "72f48eacb8aa3ed9b2f33c33de1f993c4cff4e086f943fd5793a1bb73c42335d",
172
+ "schemas/v2/run-event-payloads.schema.json": "29dec16f0d40c9022c169d4d413742ddd775a3d8c895d3a60ccc093e2a872439",
173
173
  "schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
174
174
  "schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
175
175
  "schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
@@ -200,8 +200,8 @@
200
200
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
201
201
  "spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
202
202
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
203
- "spec/v1/core-standard-manifest.json": "353689969f787aafdc836925f762fb988640e6cef14924bc0b6af7d7c02ed7a8",
204
- "spec/v1/deprecations.json": "a2022c2d6eb501bf406b5d75fe2d431a3fe0158e8509125d2453985eb1bf511a",
203
+ "spec/v1/core-standard-manifest.json": "a6fecfb708deb4d6fa5fb65b28fcdaff5101b73f8b9812295ab5c6e44105cd25",
204
+ "spec/v1/deprecations.json": "508071308e762c281807b487e8f3840a1a69b978071894593e18f2e2d3e4b5c0",
205
205
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
206
206
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
207
207
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
@@ -238,6 +238,7 @@
238
238
  "spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
239
239
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
240
240
  "spec/v2/event-codemap.schema.json": "b173f2a9bcc0bf9b62a474ce3b91e1431d597e64fd1560fc58662cd8606eaf9c",
241
+ "spec/v2/ext/README.md": "c7dea12c48772047f18b111e73be1c236460e72d9bf98026351d148a2ac4f608",
241
242
  "spec/v2/ext/a2uiSurface/README.md": "176cb0542e1c236ffcd6d682536ba0081242684444e2dec790abd480a44a571e",
242
243
  "spec/v2/ext/brand/README.md": "1a80102a9cd766dcb6098c999e552af88f5b83c88951acde18d17cc0b29aa09d",
243
244
  "spec/v2/ext/canvas/README.md": "a2d5f28523753e8e85cc9042db39faf9207a2eebce1a8553be22e341f7ea58d9",
@@ -245,16 +246,16 @@
245
246
  "spec/v2/ext/coordination/README.md": "a193aa9a59a2d9ac8d2e9775e5be40c29b6f7476ffef35231dbab49377af9207",
246
247
  "spec/v2/ext/dataIntegration/README.md": "8361c626ed9f77dc48a3bcaa0c9f769cb6706632941014700f7683efe0918bd1",
247
248
  "spec/v2/ext/entities/README.md": "c3bf6c59ddf2255c22a522e689f6e7a11646fbcd309b17699b6cf69a4e2e0f91",
248
- "spec/v2/ext/grpc-transport/README.md": "7fa692715744d6573443f994c521cbff0bf0be2e81ffdbb01e42f58f32a3ffaf",
249
+ "spec/v2/ext/grpc-transport/README.md": "9913c3f5433b719533e0721af477917bf7a993372388aa800be8bff9d4f8e203",
249
250
  "spec/v2/ext/kanban/README.md": "6792926f7f0cb8a4dcb0acdedb86a1988574fb0b5de164b37159dfe3ba35f908",
250
251
  "spec/v2/ext/knowledge/README.md": "d2615320c7de4aec279f276381376a8881e962dd4e86b1435462d742dd00a52f",
251
252
  "spec/v2/ext/launchStudio/README.md": "bfe417e774689773f3bbced9fdfa0c18809577aa6040a1312d914c821beed026",
252
253
  "spec/v2/ext/messaging/README.md": "8a4702935483ce7900c0ee14793f9dfbeeb09d5e43f8943c946f8f94de2efa6f",
253
- "spec/v2/ext/portability/README.md": "d9caf3dd723212676a0dd7f847284ce8aa3f0f5ebd4b273580c34a260ece96d7",
254
- "spec/v2/ext/provider-idempotency/README.md": "c7620aadaf4e7ded997487c917e1583c8bc9ba20aa5353476d66f229207dbd1a",
254
+ "spec/v2/ext/portability/README.md": "7525fdc595b05dfd0b2bd22e2f7b3a13302164ebf49e4327c2e29e9da80766f4",
255
+ "spec/v2/ext/provider-idempotency/README.md": "5efa5b220e22d8f9a93918d20d3be0c698445d828693c8e487f37abc39bfca7c",
255
256
  "spec/v2/ext/provider-idempotency/registry.json": "5b5fecad604fb4ab38da39f5abe697a4e57b05406c398f8afeb377331b2bd965",
256
257
  "spec/v2/ext/restTransport/README.md": "b39f4d1ddbabd72dd945a39e90218d8413e46baea1269798b96dffb4e429cb4f",
257
- "spec/v2/ext/sandbox-runtime-notes/README.md": "36c5517082b1cce0f1ccec5153e4bea10144bba88503b3b023f920444f3c15e6",
258
+ "spec/v2/ext/sandbox-runtime-notes/README.md": "bd0c4ca4d0fbcba2ae948a02ddc768f50b08a33d9f0622efa1c9e2b7beab2969",
258
259
  "spec/v2/ext/webResearch/README.md": "941dfe8323fd536791167f9fc7d119dd4e165bb7937e3382a19f15cda3d9f19a",
259
260
  "spec/v2/facets/a2a.schema.json": "6b0bceddec6d18d5115aa7d04720a0cc1ad7a9a0a78f53c208b34becaba5d16e",
260
261
  "spec/v2/facets/aiProviders.schema.json": "0c76e48e5d487b5be3b3ab1f6512a4e206c840ee5087d1336c7d7e2853b58d03",
@@ -270,8 +271,8 @@
270
271
  "spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
271
272
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
272
273
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
273
- "spec/v2/release.json": "4dac86d9d990d76f5e0971eb5e1b55d366ad9372e07e68a67a4012dce56be377",
274
+ "spec/v2/release.json": "1538b049aded80392b532e908ad2014d0ffef5332d3e669e96ce67955222d146",
274
275
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
275
276
  },
276
- "corpusCommit": "c4d3f8528dc380210f87a94d595fff246775c00a"
277
+ "corpusCommit": "01a0723e291e8ef278530aff82b5d9c66c754781"
277
278
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `spec/v2/core/webhooks.md` §Deliveries — a major-2 webhook delivery carries the
3
+ * v2 payload, not the host's in-process dialect (suite 2.3.1, target major 2;
4
+ * gated on the `webhooks` family; creates one run per leg).
5
+ *
6
+ * `webhooks.md:22`: "The delivery envelope is generated from the same payload
7
+ * definition as the event itself and the CloudEvents mapping — one source,
8
+ * three renderings (RFC 0171 §A.4)." That was normative before this file
9
+ * existed. What did not exist was a reader: seven webhook scenarios checked
10
+ * signature, headers, durability, isolation and SSRF, and NONE opened the body
11
+ * against a schema. A tier-1 host had two major-2 egress channels; poll/SSE
12
+ * projected the payload and the webhook fan-out forwarded the raw in-process
13
+ * event, so a major-2 subscriber received the v1 owner block (`principal`,
14
+ * `principalKind`) — with every webhook scenario green. The wire truth is per
15
+ * channel, and a channel nobody's scenario reads is a channel that can drift.
16
+ *
17
+ * Three legs, and the SECOND is the one that keeps the first honest. Leg 1: a
18
+ * major-2 subscription's delivery body validates against `run-event.schema.json`
19
+ * (v2) and `owner` is exactly `{tenant, subject}`. Leg 2: a major-1
20
+ * subscription still receives `principal` — `versioning.md` §1.2 forbids moving
21
+ * the v1 wire mid-overlap, so a host that projects BOTH channels to v2 is also
22
+ * wrong, and a v2-only check would reward it. Sabotage on the reporting host:
23
+ * dropping the fan-out projection reds leg 1 and leaves leg 2 green. Leg 3 is
24
+ * seam-gated: the tier-2 host's fan-out is ERA-aware, not major-aware — an era-3
25
+ * row was projected on write and passes through; only era-2 rows take the read
26
+ * projection — so a single fresh (era-3) run witnesses one branch. A seeded
27
+ * era-2 run witnesses the other.
28
+ *
29
+ * @see spec/v2/core/webhooks.md §Deliveries
30
+ * @see spec/v2/core/versioning.md §1.2
31
+ */
32
+ import { afterEach, describe, it, expect } from 'vitest';
33
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
34
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+ import { Ajv2020 } from 'ajv/dist/2020.js';
37
+ import addFormats from 'ajv-formats';
38
+ import { driver } from '../lib/driver.js';
39
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
40
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
41
+ import { readErrorCode } from '../lib/error-envelope.js';
42
+ import { softSkip } from '../lib/soft-skip.js';
43
+ import { req } from '../lib/requirement-ids.js';
44
+ import { era2Gate, seedEra2Log, v1FixtureLog } from '../lib/era2-seed.js';
45
+ import { SCHEMAS_DIR } from '../lib/paths.js';
46
+
47
+ const ID = 'openwop.requirement.0171.webhook-delivery-shape';
48
+ const DOC = 'spec/v2/core/webhooks.md §Deliveries';
49
+ const FIXTURE = 'conformance-noop';
50
+
51
+ type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
52
+
53
+ async function startReceiver(): Promise<{ server: Server; url: string; deliveries: Delivery[] }> {
54
+ const deliveries: Delivery[] = [];
55
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
56
+ const chunks: Buffer[] = [];
57
+ request.on('data', (c: Buffer) => chunks.push(c));
58
+ request.on('end', () => {
59
+ deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers });
60
+ res.writeHead(204); res.end();
61
+ });
62
+ });
63
+ const { bind, advertise } = receiverBinding();
64
+ await new Promise<void>((resolve) => server.listen(0, bind, () => resolve()));
65
+ const addr = server.address();
66
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
67
+ return { server, url: `http://${advertise}:${port}/hook`, deliveries };
68
+ }
69
+
70
+ /** Register a subscription; `major` selects the contract the host speaks to this subscriber. */
71
+ async function register(url: string, major: 1 | 2): Promise<string | null> {
72
+ const registration = resolveRegistrationUrl(url);
73
+ const reg = await driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }, { headers: { 'OpenWOP-Version': major === 2 ? '2.0' : '1.0' } });
74
+ if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
75
+ softSkip('blocked', 'host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https receiver to witness');
76
+ return null;
77
+ }
78
+ expect(reg.status, req(ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
79
+ const id = (reg.json as { webhookId?: unknown } | null)?.webhookId;
80
+ return typeof id === 'string' ? id : null;
81
+ }
82
+
83
+ async function driveRun(): Promise<string> {
84
+ const create = await driver.post('/runs', { workflowId: FIXTURE });
85
+ expect(create.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
86
+ return (create.json as { runId: string }).runId;
87
+ }
88
+
89
+ async function waitFor<T>(fn: () => T | undefined, ms: number): Promise<T | undefined> {
90
+ const until = Date.now() + ms;
91
+ while (Date.now() < until) { const v = fn(); if (v !== undefined) return v; await new Promise((r) => setTimeout(r, 200)); }
92
+ return fn();
93
+ }
94
+
95
+ function v2RunEventValidator(): (doc: unknown) => { ok: boolean; errors: string } {
96
+ const a = new Ajv2020({ strict: false, allErrors: true }); addFormats(a);
97
+ const dir = join(SCHEMAS_DIR, 'v2');
98
+ for (const f of readdirSync(dir)) { if (f.endsWith('.schema.json') && !statSync(join(dir, f)).isDirectory()) { try { a.addSchema(JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>); } catch { /* dup */ } } }
99
+ const fn = a.compile({ $ref: 'https://openwop.dev/spec/v2/run-event.schema.json' });
100
+ return (doc) => ({ ok: fn(doc) === true, errors: a.errorsText(fn.errors, { separator: '; ' }) });
101
+ }
102
+
103
+ describe('webhook delivery shape is per-contract (webhooks.md §Deliveries, versioning.md §1.2)', () => {
104
+ let active: Server | null = null;
105
+ afterEach(async () => { const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
106
+
107
+ it('a major-2 subscriber receives the v2 payload: the body validates and owner is exactly {tenant, subject}', async () => {
108
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
109
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
110
+ const receiver = await startReceiver(); active = receiver.server;
111
+ if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
112
+ const runId = await driveRun();
113
+ const d = await waitFor(() => receiver.deliveries.find((x) => x.body.includes(runId)), 15_000);
114
+ if (!d) return softSkip('blocked', 'no delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery\'s claim, not this file\'s');
115
+ const parsed = JSON.parse(d.body) as { event?: Record<string, unknown> };
116
+ const event = parsed.event ?? (parsed as Record<string, unknown>);
117
+ const v = v2RunEventValidator()(event);
118
+ expect(v.ok, req(ID, DOC, `a major-2 delivery body MUST validate against run-event.schema.json (v2) — "one source, three renderings" (RFC 0171 §A.4). ${v.errors}`)).toBe(true);
119
+ const owner = (event as { owner?: Record<string, unknown> }).owner ?? {};
120
+ expect(Object.keys(owner).sort(), req(ID, DOC, 'a major-2 delivery\'s owner MUST be exactly {tenant, subject} — a v1 owner block (principal, principalKind) on a v2 delivery is the fan-out forwarding the in-process dialect instead of projecting')).toEqual(['subject', 'tenant']);
121
+ });
122
+
123
+ it('a major-1 subscriber still receives principal — the v1 wire does not move mid-overlap', async () => {
124
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
125
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
126
+ const disc = await v2Discovery();
127
+ const supported = ((disc?.versions as { supported?: unknown[] } | undefined)?.supported ?? []) as string[];
128
+ if (!supported.some((s) => String(s).startsWith('1.'))) return softSkip('inapplicable', 'host advertises no 1.x contract — there is no v1 wire to keep still');
129
+ const receiver = await startReceiver(); active = receiver.server;
130
+ if ((await register(receiver.url, 1)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
131
+ const runId = await driveRun();
132
+ const d = await waitFor(() => receiver.deliveries.find((x) => x.body.includes(runId)), 15_000);
133
+ if (!d) return softSkip('blocked', 'no delivery for this run arrived inside 15s');
134
+ const parsed = JSON.parse(d.body) as { event?: Record<string, unknown> };
135
+ const event = parsed.event ?? (parsed as Record<string, unknown>);
136
+ const owner = (event as { owner?: Record<string, unknown> }).owner ?? {};
137
+ expect('principal' in owner, req(ID, 'spec/v2/core/versioning.md §1.2', 'a major-1 subscriber MUST still receive the v1 owner block (principal) — projecting every channel to v2 is as wrong as projecting none; the projection is contract-scoped')).toBe(true);
138
+ });
139
+
140
+ it('a seeded era-2 run is delivered projected too — the fan-out branch a fresh run cannot reach (seam-gated)', async () => {
141
+ const disc = await v2Discovery();
142
+ if (!disc) return softSkip('blocked', 'v2 discovery unreachable');
143
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
144
+ const seeded = era2Gate(disc);
145
+ if (seeded === null) return softSkip('inapplicable', 'no era-2 seed seam advertised — the era-2 fan-out branch is unreachable without one (recorded under openwop.family.conformance)');
146
+ const receiver = await startReceiver(); active = receiver.server;
147
+ if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
148
+ const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
149
+ if (!('runId' in log) || typeof (log as { runId?: unknown }).runId !== 'string') return softSkip('blocked', 'era-2 seed did not return a runId');
150
+ const runId = (log as { runId: string }).runId;
151
+ const d = await waitFor(() => receiver.deliveries.find((x) => x.body.includes(runId)), 15_000);
152
+ if (!d) return softSkip('blocked', 'no delivery for the seeded era-2 run inside 15s — a host MAY not fan out seeded history; recorded blocked, not failed');
153
+ const parsed = JSON.parse(d.body) as { event?: Record<string, unknown> };
154
+ const event = parsed.event ?? (parsed as Record<string, unknown>);
155
+ const v = v2RunEventValidator()(event);
156
+ expect(v.ok, req(ID, DOC, `an era-2 row delivered to a major-2 subscriber MUST be projected — the read projection applies at the fan-out as at poll/SSE (events.md §Era-2). ${v.errors}`)).toBe(true);
157
+ const owner = (event as { owner?: Record<string, unknown> }).owner ?? {};
158
+ expect('principal' in owner, req(ID, DOC, 'a projected era-2 delivery MUST NOT carry the v1 owner block')).toBe(false);
159
+ });
160
+ });