@openwop/openwop-conformance 2.45.25 → 2.45.26

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,11 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.26] — 2026-10-08 — RFC 0236's legs run without the seam (RFC 0241 Active)
4
+
5
+ - **No scenario change.** RFC 0237 goes `Accepted` on the v2 reference host's certified 2.45.25 cut; its gaps G1 and G2 close, which changes the packed `spec/v1/gaps.json`.
6
+ - **`v2-host-event-delivery` causes events through RFC 0241's `POST /host/events/test`** when the host lists the reserved `host-test.durable-triggered` / `host-test.ephemeral-triggered` types, else through the §G seam, so a seam-free production host witnesses all six `openwop.requirement.0236.*` rows. New leg `openwop.requirement.0241.trigger.bound-by-listing`: each listed class is accepted with its reserved type, an unlisted class is `400 validation_error`, and nothing is served (`404`/`405`) when no `host-test.*` type is listed. Proven on a seams-off v2 reference host: all eight rows `executed-pass`; a trigger answering a real type, and a frame reaching a second tenant, each `executed-fail`. The self-test double gains the trigger choice and the binding judge.
7
+ - **`v2-host-event-delivery`: `0236.webhook.ephemeral-refused` no longer gates on the seams profile.** It registers a webhook naming an ephemeral type and expects `400 validation_error`; it causes no event, so the §G emit seam was never needed. It now runs on any host advertising `hostEvents` with an ephemeral type, preferring an `example.*` one (a production host's `channel.presence` will do), and records `inapplicable` when none is listed. Proven on a seam-free v2 reference host: `executed-pass`, and `executed-fail` with the refusal removed. The five legs that cause an event use RFC 0241's trigger when the host lists it (next bullet).
8
+
3
9
  ## [2.45.25] — 2026-10-07 — `v2-wasm-abi-reject`; the frontend-plugin schema legs leave host bundles
4
10
 
5
11
  - **New `v2-wasm-abi-reject` (`openwop.requirement.node-pack-runtimes.wasm-abi-reject`).** Gated on `nodePackRuntimes.wasm` and the new operator-installed fixture `conformance-wasm-pack-abi-mismatch`, which invokes `vendor.openwop.misbehaving.abi-bomb` (the `misbehaving-abi` pack, ABI version 999). Loading is not a protocol operation, so the leg reads what a rejection at load prevents: the pack in an advertised `loadedPacks`, and the node completing. The run must be refused at creation (4xx) or end without that node completing; a 401/403/429/5xx is `blocked`. Proof: a 9-case self-test double (`lib/wasm-abi-reject-witness.test.ts`). No RFC was needed: both observations already exist.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.45.25",
4
- "stampSha256": "071daf4502be07fdcaa748880b10f91bcbddaee45417cfdcd43d8c53b15e1e88"
3
+ "version": "2.45.26",
4
+ "stampSha256": "01d0c29c58bd680baa38517e507d8c14b73ca1bf9aa888d79efc7d78a94d9e1b"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.45.25",
3
+ "version": "2.45.26",
4
4
  "description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
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.45.25"
59
+ "@openwop/spec-artifacts": "2.45.26"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -3,8 +3,8 @@
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
5
  "files": 737,
6
- "tests": 2781,
7
- "withStableId": 2781,
6
+ "tests": 2782,
7
+ "withStableId": 2782,
8
8
  "interpolatedTitles": 0,
9
9
  "explicitIds": 2644
10
10
  },
@@ -31353,7 +31353,7 @@
31353
31353
  {
31354
31354
  "id": "openwop.it.v2-host-event-delivery.an-emitted-durable-host-event-arrives-on-host-events-as-a-valid-envelope",
31355
31355
  "file": "v2-host-event-delivery.test.ts",
31356
- "line": 131,
31356
+ "line": 160,
31357
31357
  "title": "an emitted durable host event arrives on /host/events as a valid envelope",
31358
31358
  "explicitId": "openwop.requirement.0236.host-event.envelope",
31359
31359
  "citations": [
@@ -31366,7 +31366,7 @@
31366
31366
  {
31367
31367
  "id": "openwop.it.v2-host-event-delivery.an-ephemeral-host-event-has-no-id-and-is-not-redelivered-after-last-event-id",
31368
31368
  "file": "v2-host-event-delivery.test.ts",
31369
- "line": 145,
31369
+ "line": 174,
31370
31370
  "title": "an ephemeral host event has no id: and is not redelivered after Last-Event-ID",
31371
31371
  "explicitId": null,
31372
31372
  "citations": []
@@ -31374,7 +31374,7 @@
31374
31374
  {
31375
31375
  "id": "openwop.it.v2-host-event-delivery.registerwebhook-refuses-an-ephemeral-host-event-type-with-400-validation-error",
31376
31376
  "file": "v2-host-event-delivery.test.ts",
31377
- "line": 162,
31377
+ "line": 191,
31378
31378
  "title": "registerWebhook refuses an ephemeral host-event type with 400 validation_error",
31379
31379
  "explicitId": "openwop.requirement.0236.webhook.ephemeral-refused",
31380
31380
  "citations": [
@@ -31392,7 +31392,7 @@
31392
31392
  {
31393
31393
  "id": "openwop.it.v2-host-event-delivery.a-subscribed-durable-host-event-is-delivered-as-hostevent-signed",
31394
31394
  "file": "v2-host-event-delivery.test.ts",
31395
- "line": 175,
31395
+ "line": 207,
31396
31396
  "title": "a subscribed durable host event is delivered as { hostEvent }, signed",
31397
31397
  "explicitId": "openwop.requirement.0236.webhook.host-variant",
31398
31398
  "citations": [
@@ -31406,15 +31406,23 @@
31406
31406
  {
31407
31407
  "id": "openwop.it.v2-host-event-delivery.the-same-subscription-never-receives-the-ephemeral-host-event",
31408
31408
  "file": "v2-host-event-delivery.test.ts",
31409
- "line": 211,
31409
+ "line": 243,
31410
31410
  "title": "the same subscription never receives the ephemeral host event",
31411
31411
  "explicitId": null,
31412
31412
  "citations": []
31413
31413
  },
31414
+ {
31415
+ "id": "openwop.it.v2-host-event-delivery.emittesthostevent-is-served-exactly-for-the-listed-host-test-classes",
31416
+ "file": "v2-host-event-delivery.test.ts",
31417
+ "line": 256,
31418
+ "title": "emitTestHostEvent is served exactly for the listed host-test classes",
31419
+ "explicitId": null,
31420
+ "citations": []
31421
+ },
31414
31422
  {
31415
31423
  "id": "openwop.it.v2-host-event-delivery.a-second-tenant-receives-nothing-for-the-first-tenant-s-host-event",
31416
31424
  "file": "v2-host-event-delivery.test.ts",
31417
- "line": 224,
31425
+ "line": 270,
31418
31426
  "title": "a second tenant receives nothing for the first tenant’s host event",
31419
31427
  "explicitId": null,
31420
31428
  "citations": []
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.45.25",
5
- "corpusTag": "v2.45.25",
4
+ "version": "2.45.26",
5
+ "corpusTag": "v2.45.26",
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": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "0692397ec226e29431dbb2f0d7e645d42ac76a3e4f851f1342136186e55ede78",
13
- "api/v2/asyncapi.yaml": "416a78c622b4b2bad33cc73d4f2676e53685d1ee99ab6576338dd426a5a0e0fb",
14
- "api/v2/openapi.yaml": "907a0bbf13669b0db55effd35eae67bafc2df0a496f11de37d876f69cc7fbe98",
12
+ "api/seams-v2.yaml": "d3b59eb1f87f42ba118bdbbdfb74e6f499081d8d5ed5fba3b06d5ae561bc90bd",
13
+ "api/v2/asyncapi.yaml": "dc2ed7cd47e0ca326e89badc1f0b0c3a64b2030859559786c711230e5e7474f1",
14
+ "api/v2/openapi.yaml": "3fbb8672105a847d457e13cde09bf65b02b02163cf1082f08b3cec68088a416a",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -152,7 +152,7 @@
152
152
  "schemas/v2/goal.schema.json": "ad104ed5a3866736b8fed9d7c0b23dc82af526b5bef4afe848f01fc3af2161e9",
153
153
  "schemas/v2/heartbeat-evaluated.schema.json": "c897b0792fd518d6e12879851027252af1529f5e420c3eaf434ad7af284ecc05",
154
154
  "schemas/v2/heartbeat-state-changed.schema.json": "eb68d0082aa996650e3a6b8ccf32457f959cebc7e6728cc7c331b9d171a4bef6",
155
- "schemas/v2/host-event.schema.json": "d2e2233d6464442c761ca985b0302c8350cabe2d694d3894f65d1696d80bdc98",
155
+ "schemas/v2/host-event.schema.json": "655b5475b1aeb41c8fc4ff7b6fc7e31a0fcc18fd59f368336ccbc138e6ba395e",
156
156
  "schemas/v2/ids.schema.json": "65cfaa193be0ca9cf48af39c18c553979e3ec1f7fc04509aaa513b322d7b7e4a",
157
157
  "schemas/v2/localized-content-language-settings.schema.json": "3c8350a178037a460f5d87466ccaf9ed5fe3d41497786d2504cc66e42152c8cc",
158
158
  "schemas/v2/localized-content-page-response.schema.json": "ef48329749758afb21e4d3cbc3f017abb911f75726819457f35b9f1390332c75",
@@ -208,14 +208,14 @@
208
208
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
209
209
  "spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
210
210
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
211
- "spec/v1/core-standard-manifest.json": "53a46c4dc0224f13f06c4aa6f07ac6aaa0db84407c4ef2127a9d9d5652255b9e",
211
+ "spec/v1/core-standard-manifest.json": "cd739e168fa36dd811fd9ed1f506e0716c592e7a61c0d12164f82732ca366177",
212
212
  "spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
213
213
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
214
214
  "spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
215
215
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
216
216
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
217
217
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
218
- "spec/v1/gaps.json": "a97d03cce8f59be6f84c66eeeacc56e01e500c7a32845afc95f4d4c4d91b4bfd",
218
+ "spec/v1/gaps.json": "a0aec8f5ddf1002d46a461a9c7d930c8f0656a19b68bb1f52d183b97cc1962cf",
219
219
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
220
220
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
221
221
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -228,10 +228,10 @@
228
228
  "spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
229
229
  "spec/v2/core/conversation.md": "8d6abedf6af408a74577753bfca24719d318e2632e10b45ff5d63bdcf31a8aaa",
230
230
  "spec/v2/core/errors.md": "e48838701b86bbfe0ffee77b18a8ffc4e5f04724b4318507d9771da653f8da0f",
231
- "spec/v2/core/events.md": "3eb297c1ca8a4c20017248051fbe9c5319bb51ef1ba4cb476e29840cbbe141a6",
231
+ "spec/v2/core/events.md": "d0f902edc080e9789e4fd05850a1264d905ebe5b06045b6a7a22486e22ef3091",
232
232
  "spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
233
233
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
234
- "spec/v2/core/headers.md": "18e52477898e67b0b3778d1fdd22e07bc43d561c03b501573404cd53245edef6",
234
+ "spec/v2/core/headers.md": "f69fe81e4a3ad21879bb85948660c16860eb05bf152928c31aa4898f6d7d1887",
235
235
  "spec/v2/core/host-services.md": "168a40935a03867ee82461b5d83d05578be25ee3e5af4eb080587b891231e242",
236
236
  "spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
237
237
  "spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
@@ -310,12 +310,12 @@
310
310
  "spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
311
311
  "spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
312
312
  "spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
313
- "spec/v2/path-manifest.json": "50fc251f296b06ec5fad35724f5c63b1a32e0e8fc222a85cd29b785cebdb9c98",
313
+ "spec/v2/path-manifest.json": "7b5679a4dad98c80d855361d88a98c8a4e045af15e310862a05b285b815dc9fc",
314
314
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
315
315
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
316
- "spec/v2/release.json": "0fdc846e4aacfad6b0fe7a255002c4ad167bc41f14174ba6f2dcf2498cb540f3",
316
+ "spec/v2/release.json": "7ce8ba7234bfe7eff4c767cc0aa4ddea0495fae08212fe98f63ae312cab06c74",
317
317
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
318
- "spec/v2/surface-baseline.json": "91233acd0e69f99afbe4319260298ede7bddc2bd665d30305ecd443bef65ab58"
318
+ "spec/v2/surface-baseline.json": "da9da09f5133be09b75820d138c5b4422365b2b8d113597c7fa89c99913069b3"
319
319
  },
320
- "corpusCommit": "f204da96cb286ba47bed89d4f66d70bbfdcda5a4"
320
+ "corpusCommit": "906642cfb7cea6d4be59f0471866f8ffa3e4c702"
321
321
  }
@@ -4,7 +4,7 @@
4
4
  * self-test (`host-event-witness.test.ts`) can feed each one a defect and show
5
5
  * it convicts.
6
6
  *
7
- * The scenario owns the I/O (the `/host/events` stream, the emit seam, the
7
+ * The scenario owns the I/O (the `/host/events` stream, the RFC 0241 trigger or the emit seam, the
8
8
  * scoped webhook receiver). This file owns only "given what arrived, which
9
9
  * `events.md` §Host events / `webhooks.md` §Delivery rule failed?".
10
10
  */
@@ -30,6 +30,52 @@ export function exampleType(types: readonly AdvertisedType[], delivery: Advertis
30
30
  return types.find((t) => t.type.startsWith('example.') && t.delivery === delivery)?.type ?? null;
31
31
  }
32
32
 
33
+ /**
34
+ * Any advertised type of the class, preferring the conformance `example.*` one. For a leg
35
+ * that only names a type and causes no event, so it runs on a host without the §G seam.
36
+ */
37
+ export function advertisedTypeOf(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): string | null {
38
+ return exampleType(types, delivery) ?? types.find((t) => t.delivery === delivery)?.type ?? null;
39
+ }
40
+
41
+ /** RFC 0241 §A — the reserved test types, one per delivery class. */
42
+ export const TEST_TYPES = { durable: 'host-test.durable-triggered', ephemeral: 'host-test.ephemeral-triggered' } as const;
43
+
44
+ /** The reserved test type of a class, when the host lists it (RFC 0241 §B.1: listing it binds the trigger). */
45
+ export function testTypeListed(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): string | null {
46
+ return types.some((t) => t.type === TEST_TYPES[delivery] && t.delivery === delivery) ? TEST_TYPES[delivery] : null;
47
+ }
48
+
49
+ /** How the suite causes a host event: the normative trigger when a test type is listed, else the §G seam. */
50
+ export type Trigger = 'normative' | 'seam';
51
+ export function triggerOf(types: readonly AdvertisedType[], seamsProfile: boolean): Trigger | null {
52
+ if (testTypeListed(types, 'durable') !== null || testTypeListed(types, 'ephemeral') !== null) return 'normative';
53
+ return seamsProfile ? 'seam' : null;
54
+ }
55
+
56
+ /**
57
+ * RFC 0241 §B.1–§B.2 — `emitTestHostEvent` is served exactly for the listed classes: a class whose
58
+ * type is not listed is refused (`404`/`405` when none is listed, `400 validation_error` otherwise),
59
+ * and a listed class is accepted with `202` and its reserved type.
60
+ */
61
+ export function judgeTriggerBinding(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery'], status: number, code: string | undefined, returnedType: unknown): Finding[] {
62
+ const listed = testTypeListed(types, delivery) !== null;
63
+ const anyListed = triggerOf(types, false) === 'normative';
64
+ if (listed) {
65
+ return [status === 202 && returnedType === TEST_TYPES[delivery]
66
+ ? ok(`emitTestHostEvent accepts the listed ${delivery} class`)
67
+ : bad(`emitTestHostEvent MUST accept the listed ${delivery} class with 202 and type ${TEST_TYPES[delivery]}; got ${status} ${String(code ?? returnedType ?? '')}`.trim())];
68
+ }
69
+ if (!anyListed) {
70
+ return [status === 404 || status === 405
71
+ ? ok('emitTestHostEvent is not served while no host-test type is listed')
72
+ : bad(`a host listing no host-test.* type MUST NOT serve emitTestHostEvent; got ${status}`)];
73
+ }
74
+ return [status === 400 && code === 'validation_error'
75
+ ? ok(`emitTestHostEvent refuses the unlisted ${delivery} class`)
76
+ : bad(`emitTestHostEvent MUST refuse the unlisted ${delivery} class with 400 validation_error; got ${status} ${code ?? ''}`.trim())];
77
+ }
78
+
33
79
  /** The frame carrying `eventId`, parsed, or null. */
34
80
  export function frameFor(frames: readonly Frame[], eventId: string): { frame: Frame; envelope: Record<string, unknown> } | null {
35
81
  for (const frame of frames) {
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * v2 — host events (RFC 0236; `spec/v2/core/events.md` §Host events,
3
- * `webhooks.md` §Delivery; suite 2.45.20, target major 2; seam-gated).
3
+ * `webhooks.md` §Delivery; suite 2.45.20, target major 2; RFC 0241 trigger in 2.45.26).
4
4
  *
5
5
  * A host event belongs to no run: its envelope has no `runId` and no
6
6
  * `sequence`. It rides `/host/events` beside the heartbeat messages and, when
7
- * durable, webhooks. The suite cannot cause one unaided, so every leg drives
8
- * the §G emit seam with the `example.*` types a seams-profile host advertises.
7
+ * durable, webhooks. The suite causes one through `POST /host/events/test`
8
+ * (RFC 0241) when the host lists the reserved `host-test.*` types, so a
9
+ * seam-free production host witnesses every leg; otherwise through the RFC 0236
10
+ * §G emit seam with the `example.*` types a seams-profile host advertises.
9
11
  *
10
12
  * 0236.host-event.envelope an emitted durable event arrives as a valid
11
13
  * envelope, `event:` = type, `id:` = eventId;
@@ -22,16 +24,22 @@
22
24
  * the host-variant leg observed: a bundle
23
25
  * records one requirement id per `it`);
24
26
  * 0236.host-event.tenant-isolation a second tenant's stream receives nothing
25
- * for the first tenant's event.
27
+ * for the first tenant's event;
28
+ * 0241.trigger.bound-by-listing `emitTestHostEvent` accepts each listed
29
+ * `host-test.*` class and refuses the rest
30
+ * (`404`/`405` when none is listed).
26
31
  *
27
32
  * Judged by `lib/host-event-witness.ts`, whose self-test convicts each defect.
28
33
  *
29
- * Dispositions: discovery unreadable ⇒ `blocked`; seams profile or `hostEvents`
30
- * not advertised ⇒ `inapplicable`; seam unwired, or no `example.*` type of the
31
- * needed class advertised ⇒ `seamAbsent`; no second-tenant credential
34
+ * Dispositions: discovery unreadable ⇒ `blocked`; `hostEvents` not advertised ⇒
35
+ * `inapplicable`; neither a listed `host-test.*` type nor the seams profile ⇒
36
+ * `inapplicable` for every event-causing leg (not `ephemeral-refused`, which causes no event and runs on any advertised
37
+ * ephemeral type, none listed ⇒ `inapplicable`); a listed trigger that fails ⇒ `blocked`; seam unwired, or no `example.*`
38
+ * type of the needed class advertised ⇒ `seamAbsent`; no second-tenant credential
32
39
  * (`OPENWOP_TEST_TENANT_B_API_KEY`) ⇒ `inapplicable` for the tenant leg.
33
40
  *
34
41
  * @see RFCS/0236-host-events.md
42
+ * @see RFCS/0241-host-event-test-trigger.md
35
43
  * @see spec/v2/core/events.md §Host events
36
44
  */
37
45
 
@@ -46,7 +54,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
46
54
  import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedHit, type ScopedReceiver } from '../lib/scoped-receiver.js';
47
55
  import { createReceiverState, verifyWebhookDelivery } from '../lib/webhook-receiver.js';
48
56
  import {
49
- advertisedTypes, exampleType, judgeEnvelope, judgeHostBody, judgeNoFanOut, judgeNoResume, judgeTenantScope, type AdvertisedType, type Finding,
57
+ TEST_TYPES, advertisedTypeOf, advertisedTypes, exampleType, judgeEnvelope, judgeTriggerBinding, testTypeListed, triggerOf, type Trigger, judgeHostBody, judgeNoFanOut, judgeNoResume, judgeTenantScope, type AdvertisedType, type Finding,
50
58
  } from '../lib/host-event-witness.js';
51
59
 
52
60
  export const REQUIRES_HOST_CALLBACK = 'the host POSTs host-event webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
@@ -60,6 +68,8 @@ const ID_HOST_BODY = 'openwop.requirement.0236.webhook.host-variant';
60
68
  const ID_NO_FAN_OUT = 'openwop.requirement.0236.ephemeral.no-fan-out';
61
69
  const ID_TENANT = 'openwop.requirement.0236.host-event.tenant-isolation';
62
70
  const SEAM = seamPath('/v1/host/sample/host-events/emit');
71
+ const TRIGGER = '/host/events/test';
72
+ const ID_BINDING = 'openwop.requirement.0241.trigger.bound-by-listing';
63
73
  const V2 = { 'OpenWOP-Version': '2.0' };
64
74
  const STREAM_MS = 3_000;
65
75
  const SECRET = 'openwop-conformance-host-event-secret-0236';
@@ -77,30 +87,49 @@ const assertAll = (id: string, doc: string, findings: readonly Finding[]): void
77
87
  type Skip = { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string };
78
88
  const skip = (s: Skip): undefined => (s.kind === 'seam' ? seamAbsent(s.reason) : softSkip(s.kind, s.reason));
79
89
 
80
- /** The shared gate: the advertised types, or why the leg cannot run. */
81
- async function gate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown> } | Skip> {
90
+ /** The family gate: the advertised types, or why the leg cannot run. Needs no seam. */
91
+ async function familyGate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown>; doc: Record<string, unknown> } | Skip> {
82
92
  let doc: Record<string, unknown> | null;
83
93
  try { doc = await v2Discovery(); } catch { doc = null; }
84
94
  if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
85
95
  const record = await familyAdvertised('hostEvents');
86
96
  if (record === null) return { kind: 'inapplicable', reason: 'the host does not advertise hostEvents' };
87
- if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'seams profile not advertised — the suite cannot cause a host event without the RFC 0236 §G emit seam' };
88
- return { types: advertisedTypes(record), record };
97
+ return { types: advertisedTypes(record), record, doc };
89
98
  }
90
99
 
91
- function pick(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): AdvertisedType | Skip {
92
- const type = exampleType(types, delivery);
100
+ /**
101
+ * The shared gate for the legs that cause an event: the family gate plus a way to cause one —
102
+ * the RFC 0241 trigger when the host lists a `host-test.*` type, else the RFC 0236 §G seam.
103
+ */
104
+ async function gate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown>; trigger: Trigger } | Skip> {
105
+ const g = await familyGate();
106
+ if ('kind' in g) return g;
107
+ const trigger = triggerOf(g.types, seamsProfileAdvertised(g.doc));
108
+ if (trigger === null) return { kind: 'inapplicable', reason: 'no way to cause a host event: hostEvents.types lists no host-test.* type (RFC 0241) and the seams profile is not advertised (RFC 0236 §G)' };
109
+ return { types: g.types, record: g.record, trigger };
110
+ }
111
+
112
+ /** The type the suite causes for a class: the reserved test type under the trigger, the `example.*` type under the seam. */
113
+ function pick(g: { types: readonly AdvertisedType[]; trigger: Trigger }, delivery: AdvertisedType['delivery']): AdvertisedType | Skip {
114
+ if (g.trigger === 'normative') {
115
+ const type = testTypeListed(g.types, delivery);
116
+ return type === null ? { kind: 'inapplicable', reason: `hostEvents.types lists no ${TEST_TYPES[delivery]}, so the suite cannot cause a ${delivery} host event (RFC 0241 §B.1)` } : { type, delivery };
117
+ }
118
+ const type = exampleType(g.types, delivery);
93
119
  return type === null
94
120
  ? { kind: 'seam', reason: `no example.* ${delivery} type is advertised in hostEvents.types — RFC 0236 §G: a seams-profile host advertises one for the suite to drive` }
95
121
  : { type, delivery };
96
122
  }
97
123
 
98
- async function emit(type: string): Promise<string | Skip> {
99
- const res = await http(() => driver.post(SEAM, { type }));
100
- if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
101
- if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — RFC 0236 §G` };
124
+ /** Cause one host event of `t` through the gate's trigger; its `eventId`, or why none was produced. */
125
+ async function emit(trigger: Trigger, t: AdvertisedType): Promise<string | Skip> {
126
+ const path = trigger === 'normative' ? TRIGGER : SEAM;
127
+ const body = trigger === 'normative' ? { delivery: t.delivery } : { type: t.type };
128
+ const res = await http(() => driver.post(path, body));
129
+ if (res === null) return { kind: 'blocked', reason: `${path} unreachable (fetch failed)` };
130
+ if (trigger === 'seam' && (res.status === 404 || res.status === 405)) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — RFC 0236 §G` };
102
131
  const eventId = (res.json as { eventId?: unknown } | null)?.eventId;
103
- if (res.status !== 202 || typeof eventId !== 'string') return { kind: 'blocked', reason: `${SEAM} answered ${res.status} ${readErrorCode(res.json) ?? ''} for ${type}, so no event was produced`.trim() };
132
+ if (res.status !== 202 || typeof eventId !== 'string') return { kind: 'blocked', reason: `${path} answered ${res.status} ${readErrorCode(res.json) ?? ''} for ${t.type}, so no event was produced`.trim() };
104
133
  return eventId;
105
134
  }
106
135
 
@@ -127,14 +156,14 @@ afterEach(async () => {
127
156
  if (receiver) { receiver.server.close(); receiver = null; }
128
157
  });
129
158
 
130
- describe('v2 host events (RFC 0236 — seam-gated)', () => {
159
+ describe('v2 host events (RFC 0236; caused through RFC 0241 or the §G seam)', () => {
131
160
  it('an emitted durable host event arrives on /host/events as a valid envelope', async () => {
132
161
  const g = await gate();
133
162
  if ('kind' in g) return skip(g);
134
- const d = pick(g.types, 'durable');
163
+ const d = pick(g, 'durable');
135
164
  if ('kind' in d) return skip(d);
136
165
  let eventId: string | Skip = '';
137
- const frames = await streamWhile(async () => { eventId = await emit(d.type); });
166
+ const frames = await streamWhile(async () => { eventId = await emit(g.trigger, d); });
138
167
  if (typeof eventId !== 'string') return skip(eventId);
139
168
  assertAll(ID_ENVELOPE, DOC, judgeEnvelope(frames, eventId, d, hostEvent));
140
169
  if (await familyAdvertised('channelPresence')) {
@@ -145,13 +174,13 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
145
174
  it('an ephemeral host event has no id: and is not redelivered after Last-Event-ID', async () => {
146
175
  const g = await gate();
147
176
  if ('kind' in g) return skip(g);
148
- const d = pick(g.types, 'durable');
149
- const e = pick(g.types, 'ephemeral');
177
+ const d = pick(g, 'durable');
178
+ const e = pick(g, 'ephemeral');
150
179
  if ('kind' in d) return skip(d);
151
180
  if ('kind' in e) return skip(e);
152
181
  let durableId: string | Skip = '';
153
182
  let ephemeralId: string | Skip = '';
154
- const frames = await streamWhile(async () => { durableId = await emit(d.type); await sleep(150); ephemeralId = await emit(e.type); });
183
+ const frames = await streamWhile(async () => { durableId = await emit(g.trigger, d); await sleep(150); ephemeralId = await emit(g.trigger, e); });
155
184
  if (typeof durableId !== 'string') return skip(durableId);
156
185
  if (typeof ephemeralId !== 'string') return skip(ephemeralId);
157
186
  assertAll(ID_NO_RESUME, DOC, judgeEnvelope(frames, ephemeralId, e, hostEvent));
@@ -160,10 +189,13 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
160
189
  }, 20_000);
161
190
 
162
191
  it('registerWebhook refuses an ephemeral host-event type with 400 validation_error', async () => {
163
- const g = await gate();
192
+ // Causes no event, so it needs no §G seam: any advertised ephemeral type will do (a
193
+ // production host with `channel.presence` witnesses it).
194
+ const g = await familyGate();
164
195
  if ('kind' in g) return skip(g);
165
- const e = pick(g.types, 'ephemeral');
166
- if ('kind' in e) return skip(e);
196
+ const type = advertisedTypeOf(g.types, 'ephemeral');
197
+ if (type === null) return softSkip('inapplicable', 'hostEvents.types lists no ephemeral type, so there is none to refuse');
198
+ const e = { type, delivery: 'ephemeral' as const };
167
199
  const res = await http(() => driver.post('/webhooks', { url: 'https://example.com/openwop-conformance-0236', events: [e.type], secret: SECRET }));
168
200
  if (res === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
169
201
  const id = (res.json as { webhookId?: unknown } | null)?.webhookId;
@@ -177,8 +209,8 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
177
209
  const g = await gate();
178
210
  if ('kind' in g) return skip(g);
179
211
  if ((await familyAdvertised('webhooks')) === null) return softSkip('inapplicable', 'the host does not advertise webhooks');
180
- const d = pick(g.types, 'durable');
181
- const e = pick(g.types, 'ephemeral');
212
+ const d = pick(g, 'durable');
213
+ const e = pick(g, 'ephemeral');
182
214
  if ('kind' in d) return skip(d);
183
215
  if ('kind' in e) return skip(e);
184
216
  const hits: ScopedHit[] = [];
@@ -192,9 +224,9 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
192
224
  const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
193
225
  expect(reg.status, req(ID_HOST_BODY, 'spec/v2/core/webhooks.md §Surfaces', `events[] naming the advertised durable host-event type ${d.type} MUST be accepted; got ${reg.status} ${readErrorCode(reg.json) ?? ''}`)).toBe(201);
194
226
  if (typeof webhookId === 'string') registered.push(webhookId);
195
- const ephemeralId = await emit(e.type);
227
+ const ephemeralId = await emit(g.trigger, e);
196
228
  if (typeof ephemeralId !== 'string') return skip(ephemeralId);
197
- const durableId = await emit(d.type);
229
+ const durableId = await emit(g.trigger, d);
198
230
  if (typeof durableId !== 'string') return skip(durableId);
199
231
  for (let waited = 0; waited < 15_000 && !hits.some((h) => h.body.includes(durableId)); waited += 250) await sleep(250);
200
232
  const hit = hits.find((h) => h.body.includes(durableId));
@@ -213,25 +245,39 @@ describe('v2 host events (RFC 0236 — seam-gated)', () => {
213
245
  const g = await gate();
214
246
  if ('kind' in g) return skip(g);
215
247
  if ((await familyAdvertised('webhooks')) === null) return softSkip('inapplicable', 'the host does not advertise webhooks');
216
- const d = pick(g.types, 'durable');
217
- const e = pick(g.types, 'ephemeral');
248
+ const d = pick(g, 'durable');
249
+ const e = pick(g, 'ephemeral');
218
250
  if ('kind' in d) return skip(d);
219
251
  if ('kind' in e) return skip(e);
220
252
  if ('skipped' in fanOut) return softSkip('blocked', `no-fan-out reads the deliveries the host-variant leg observed, and ${fanOut.skipped}`);
221
253
  assertAll(ID_NO_FAN_OUT, DOC, [judgeNoFanOut(fanOut.bodies, fanOut.ephemeralId)]);
222
254
  });
223
255
 
256
+ it('emitTestHostEvent is served exactly for the listed host-test classes', async () => {
257
+ // RFC 0241 §B.1–§B.2: listing a host-test type binds the trigger for that class, and only it.
258
+ const g = await familyGate();
259
+ if ('kind' in g) return skip(g);
260
+ const findings: Finding[] = [];
261
+ for (const delivery of ['durable', 'ephemeral'] as const) {
262
+ // Each class: a listed one is accepted with its reserved type, an unlisted one refused.
263
+ const res = await http(() => driver.post(TRIGGER, { delivery }));
264
+ if (res === null) return softSkip('blocked', `${TRIGGER} unreachable (fetch failed)`);
265
+ findings.push(...judgeTriggerBinding(g.types, delivery, res.status, readErrorCode(res.json) ?? undefined, (res.json as { type?: unknown } | null)?.type));
266
+ }
267
+ assertAll(ID_BINDING, 'spec/v2/core/events.md §Host events', findings);
268
+ }, 20_000);
269
+
224
270
  it('a second tenant receives nothing for the first tenant’s host event', async () => {
225
271
  const g = await gate();
226
272
  if ('kind' in g) return skip(g);
227
- const d = pick(g.types, 'durable');
273
+ const d = pick(g, 'durable');
228
274
  if ('kind' in d) return skip(d);
229
275
  const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
230
276
  if (!other) return softSkip('inapplicable', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant check needs it');
231
277
  if (other === process.env['OPENWOP_API_KEY']?.trim()) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a different tenant');
232
278
  let eventId: string | Skip = '';
233
279
  const otherStream = streamWhile(async () => undefined, { bearer: other });
234
- const own = await streamWhile(async () => { eventId = await emit(d.type); });
280
+ const own = await streamWhile(async () => { eventId = await emit(g.trigger, d); });
235
281
  const theirs = await otherStream;
236
282
  if (typeof eventId !== 'string') return skip(eventId);
237
283
  assertAll(ID_TENANT, DOC, judgeTenantScope(own, theirs, eventId));