@openwop/openwop-conformance 2.45.18 → 2.45.20

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,20 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.20] — 2026-10-05 — host events (RFC 0236 Active); `channelPresence` delivery witnessed through the §13 seam at v2
4
+
5
+ - **New `v2-channel-presence-delivery` (seam-gated).** `conversation.md` now restates two RFC 0110 rules: a host advertising `channelPresence` MUST emit it, and MUST NOT deliver it to a non-member. Two legs check them through the snapshot seam, asking as the member first so an unwired seam is never read as a refusal.
6
+ - **The §13 presence seam exists at v2.** It was never ported to `api/seams-v2.yaml`, so v2 had no way to observe presence. It gains an `observer` field; a non-member observer MUST NOT get `200`.
7
+ - **Proof:** against a double, a leak to a non-member fails only the delivery leg, a refused member fails only the emit leg, and an unwired seam records `blocked`. No host advertises `channelPresence` at v2, so no bundle gains a blocked row.
8
+
9
+ - **New `v2-host-event-delivery` (RFC 0236, seam-gated).** Six requirement ids over `/host/events` and webhooks: the envelope, ephemeral no-resume, ephemeral no-fan-out, ephemeral registration refused, the `{ hostEvent }` body, and tenant isolation (with `OPENWOP_TEST_TENANT_B_API_KEY`; `inapplicable` without it). Judged by `lib/host-event-witness.ts`.
10
+ - **Proof:** the judges' self-test convicts each defect; a double with one defect per mode fails exactly that leg; the v2 reference host passes 6/6, and a cross-tenant frame or an accepted ephemeral registration fails only its leg there.
11
+
12
+ ## [2.45.19] — 2026-10-05 — a minted tenant-bound id carries no `~`; RFC 0235's registers close
13
+
14
+ - **New leg `openwop.requirement.0184.mint-no-tilde`** in `v2-bound-id-path-projection`. RFC 0184 §A.2: a host MUST NOT mint a tenant-bound id containing `~`. Only the run this leg creates is checked, because ids minted earlier MUST still resolve.
15
+ - **It bites only under a non-clean tenant.** openwop-app minted `user~3A<hash>/<id>` for personal and org tenants (app ADR 0814). No leg saw it, because the suite's key sat in the clean tenant `default`. On a clean tenant the defect still mints a clean id.
16
+ - **RFC 0235 registers closed (packed `spec/v1/gaps.json`).** Gaps G1 (the v2 reference host's byte-exact comparison) and G2 (no certified witness) close on that host's certified 2.45.18 cut. No scenario changes.
17
+
3
18
  ## [2.45.18] — 2026-10-04 — `If-None-Match` per RFC 9110 (RFC 0235); corpus-only legs leave the host bundle; `.supported` leaves the v2 schemas
4
19
 
5
20
  - **Seven `v2-*-static` scenarios move to `src/coherence/`.** They read only the corpus, and `conformance.md` §Two products says such a check MUST NOT appear in a host bundle. The bundle schema now excludes their ids.
package/README.md CHANGED
@@ -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 642 scenario files under `src/scenarios/`.
138
+ The current suite has 644 scenario files under `src/scenarios/`.
139
139
  - 2026-09-29 (suite 2.45.0, openwop#1763): NEW `v2-cors-preflight.test.ts` — `headers.md` §Cross-origin preflight. For every operation it preflights with `Origin`, the method, and the operation's declared request headers (plus `authorization` / `content-type` where they apply, from `spec/v2/path-manifest.json`), and reads the answer as the Fetch standard's CORS-preflight check does. Self-gated: `inapplicable` when no operation's preflight grants `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`).
140
140
  - 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.
141
141
  - 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.
@@ -482,7 +482,7 @@ Server-required (added in 1.7.0):
482
482
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
483
483
  | **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. |
484
484
 
485
- Current source tree: 642 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
+ Current source tree: 644 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
486
486
 
487
487
  ## Remaining Gaps
488
488
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.45.18",
4
- "stampSha256": "ebbae5f7e2d21d3cceb270610d2c4e808e9fe98ab7b6e282960281b36fb60d1b"
3
+ "version": "2.45.20",
4
+ "stampSha256": "3fedfa905f02f5fc87e0ecea242b9485a56be19b66f7cb2e04c37d6785888604"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.45.18",
3
+ "version": "2.45.20",
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.45.18"
59
+ "@openwop/spec-artifacts": "2.45.20"
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": 726,
6
- "tests": 2754,
7
- "withStableId": 2754,
5
+ "files": 728,
6
+ "tests": 2762,
7
+ "withStableId": 2762,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2620
9
+ "explicitIds": 2626
10
10
  },
11
11
  "records": [
12
12
  {
@@ -28969,7 +28969,7 @@
28969
28969
  {
28970
28970
  "id": "openwop.it.v2-bound-id-path-projection.a-tenant-bound-id-is-readable-at-its-escaped-path-segment-links-carry-that-form",
28971
28971
  "file": "v2-bound-id-path-projection.test.ts",
28972
- "line": 51,
28972
+ "line": 69,
28973
28973
  "title": "a tenant-bound id is readable at its ~-escaped path segment, links carry that form, and a malformed escape is refused",
28974
28974
  "explicitId": "openwop.requirement.0184.bound-id-path-projection",
28975
28975
  "citations": [
@@ -29013,6 +29013,20 @@
29013
29013
  }
29014
29014
  ]
29015
29015
  },
29016
+ {
29017
+ "id": "openwop.it.v2-bound-id-path-projection.a-tenant-bound-id-the-host-mints-for-this-run-contains-no-bites-only-when-the-cr",
29018
+ "file": "v2-bound-id-path-projection.test.ts",
29019
+ "line": 154,
29020
+ "title": "a tenant-bound id the host mints for this run contains no ~ (bites only when the credential tenant is not grammar-clean)",
29021
+ "explicitId": "openwop.requirement.0184.mint-no-tilde",
29022
+ "citations": [
29023
+ {
29024
+ "section": "spec/v2/core/identity.md §5",
29025
+ "requirement": null,
29026
+ "interpolated": true
29027
+ }
29028
+ ]
29029
+ },
29016
29030
  {
29017
29031
  "id": "openwop.it.v2-budget-enforcement.a-budgeted-run-emits-budget-reserved-budget-threshold-crossed-and-budget-exhaust",
29018
29032
  "file": "v2-budget-enforcement.test.ts",
@@ -29400,6 +29414,44 @@
29400
29414
  }
29401
29415
  ]
29402
29416
  },
29417
+ {
29418
+ "id": "openwop.it.v2-channel-presence-delivery.a-host-advertising-channelpresence-emits-it-a-member-receives-a-closed-non-vacuo",
29419
+ "file": "v2-channel-presence-delivery.test.ts",
29420
+ "line": 68,
29421
+ "title": "a host advertising channelPresence emits it: a member receives a closed, non-vacuous snapshot",
29422
+ "explicitId": "openwop.requirement.channel-presence.advertised-emits",
29423
+ "citations": [
29424
+ {
29425
+ "section": "spec/v2/core/conversation.md §channelPresence",
29426
+ "requirement": null,
29427
+ "interpolated": true
29428
+ },
29429
+ {
29430
+ "section": "spec/v2/core/conversation.md §channelPresence",
29431
+ "requirement": null,
29432
+ "interpolated": true
29433
+ },
29434
+ {
29435
+ "section": "spec/v2/core/conversation.md §channelPresence",
29436
+ "requirement": null,
29437
+ "interpolated": true
29438
+ }
29439
+ ]
29440
+ },
29441
+ {
29442
+ "id": "openwop.it.v2-channel-presence-delivery.a-host-does-not-deliver-channel-presence-to-a-non-member",
29443
+ "file": "v2-channel-presence-delivery.test.ts",
29444
+ "line": 78,
29445
+ "title": "a host does not deliver channel.presence to a non-member",
29446
+ "explicitId": "openwop.requirement.channel-presence.non-member-not-delivered",
29447
+ "citations": [
29448
+ {
29449
+ "section": "spec/v2/core/conversation.md §channelPresence",
29450
+ "requirement": null,
29451
+ "interpolated": true
29452
+ }
29453
+ ]
29454
+ },
29403
29455
  {
29404
29456
  "id": "openwop.it.v2-client-version-header.a-client-at-the-floor-is-not-below-it-equal-on-major-minor-the-patch-never-decid",
29405
29457
  "file": "v2-client-version-header.test.ts",
@@ -31454,6 +31506,67 @@
31454
31506
  }
31455
31507
  ]
31456
31508
  },
31509
+ {
31510
+ "id": "openwop.it.v2-host-event-delivery.an-emitted-durable-host-event-arrives-on-host-events-as-a-valid-envelope",
31511
+ "file": "v2-host-event-delivery.test.ts",
31512
+ "line": 122,
31513
+ "title": "an emitted durable host event arrives on /host/events as a valid envelope",
31514
+ "explicitId": "openwop.requirement.0236.host-event.envelope",
31515
+ "citations": [
31516
+ {
31517
+ "section": "spec/v2/core/conversation.md §channelPresence",
31518
+ "requirement": "a host advertising channelPresence MUST list channel.presence under hostEvents as ephemeral"
31519
+ }
31520
+ ]
31521
+ },
31522
+ {
31523
+ "id": "openwop.it.v2-host-event-delivery.an-ephemeral-host-event-has-no-id-and-is-not-redelivered-after-last-event-id",
31524
+ "file": "v2-host-event-delivery.test.ts",
31525
+ "line": 136,
31526
+ "title": "an ephemeral host event has no id: and is not redelivered after Last-Event-ID",
31527
+ "explicitId": null,
31528
+ "citations": []
31529
+ },
31530
+ {
31531
+ "id": "openwop.it.v2-host-event-delivery.registerwebhook-refuses-an-ephemeral-host-event-type-with-400-validation-error",
31532
+ "file": "v2-host-event-delivery.test.ts",
31533
+ "line": 153,
31534
+ "title": "registerWebhook refuses an ephemeral host-event type with 400 validation_error",
31535
+ "explicitId": "openwop.requirement.0236.webhook.ephemeral-refused",
31536
+ "citations": [
31537
+ {
31538
+ "section": "spec/v2/core/webhooks.md §Surfaces",
31539
+ "requirement": null,
31540
+ "interpolated": true
31541
+ },
31542
+ {
31543
+ "section": "spec/v2/core/webhooks.md §Surfaces",
31544
+ "requirement": "the refusal code MUST be validation_error"
31545
+ }
31546
+ ]
31547
+ },
31548
+ {
31549
+ "id": "openwop.it.v2-host-event-delivery.a-subscribed-durable-host-event-is-delivered-as-hostevent-signed-the-ephemeral-o",
31550
+ "file": "v2-host-event-delivery.test.ts",
31551
+ "line": 166,
31552
+ "title": "a subscribed durable host event is delivered as { hostEvent }, signed; the ephemeral one is not",
31553
+ "explicitId": "openwop.requirement.0236.webhook.host-variant",
31554
+ "citations": [
31555
+ {
31556
+ "section": "spec/v2/core/webhooks.md §Surfaces",
31557
+ "requirement": null,
31558
+ "interpolated": true
31559
+ }
31560
+ ]
31561
+ },
31562
+ {
31563
+ "id": "openwop.it.v2-host-event-delivery.a-second-tenant-receives-nothing-for-the-first-tenant-s-host-event",
31564
+ "file": "v2-host-event-delivery.test.ts",
31565
+ "line": 200,
31566
+ "title": "a second tenant receives nothing for the first tenant’s host event",
31567
+ "explicitId": null,
31568
+ "citations": []
31569
+ },
31457
31570
  {
31458
31571
  "id": "openwop.it.v2-i18n-negotiation.the-advertised-i18n-record-is-well-formed-and-lists-its-default-locale",
31459
31572
  "file": "v2-i18n-negotiation.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": 642,
4
+ "files": 644,
5
5
  "v1": 455,
6
- "v2": 202
6
+ "v2": 204
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1365,6 +1365,9 @@
1365
1365
  "v2-channel-presence-advertisement.test.ts": [
1366
1366
  2
1367
1367
  ],
1368
+ "v2-channel-presence-delivery.test.ts": [
1369
+ 2
1370
+ ],
1368
1371
  "v2-client-version-header.test.ts": [
1369
1372
  2
1370
1373
  ],
@@ -1485,6 +1488,9 @@
1485
1488
  "v2-header-scheme.test.ts": [
1486
1489
  2
1487
1490
  ],
1491
+ "v2-host-event-delivery.test.ts": [
1492
+ 2
1493
+ ],
1488
1494
  "v2-i18n-negotiation.test.ts": [
1489
1495
  2
1490
1496
  ],
@@ -1,19 +1,19 @@
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.18",
5
- "corpusTag": "v2.45.18",
4
+ "version": "2.45.20",
5
+ "corpusTag": "v2.45.20",
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": "56dabe76a0104e27ec5db429d10130c710fd6cc77f9e8c83060ac8642e1bf296",
13
- "api/v2/asyncapi.yaml": "99cf46de7c1009d6a7f317a69d1746d8664db94602a3936140f5a51ad0647293",
14
- "api/v2/openapi.yaml": "03618b3e70be4a916a88b344f40090e8c0477925c0a40f1447d87872f6772281",
12
+ "api/seams-v2.yaml": "3d62475a6e5d2f0e005aa58b95c197f8846104d8cb7753c2161d7b6948cb89b4",
13
+ "api/v2/asyncapi.yaml": "44ea76f380b18c54ccdb51eaa091289212cd044e656c4d97e94df7bf048972e9",
14
+ "api/v2/openapi.yaml": "4ebf762aabf5d25318923ac0c8a2b865e11d7e07b2a2456ab6b535d91e56da33",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
- "schemas/README.md": "bd1c00fae7b6fda506a75aff841bbdc438d4ce092642a72041203bee999b88de",
16
+ "schemas/README.md": "a829e3208413062388e52ab6ba714d35889c9c7645db38a290b0c4f4992cd3da",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
18
18
  "schemas/a2ui-surface-delta-frame.schema.json": "970d434e4fdcb925de64aaace91eb83bfbcfd8f76757a9ce9f339eda5fb3f4e8",
19
19
  "schemas/agent-deployment-transition.schema.json": "1ac987fed0f380fa5b4e9a40e8e13316e5ae42d03a0ff42db4be6273e1bfe9f4",
@@ -117,9 +117,9 @@
117
117
  "schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
118
118
  "schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
119
119
  "schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
120
- "schemas/v2/capabilities.schema.json": "c7a01b2f569139f99ba5e1a5329a42556fbd82bc743f8fd96963304395fc9ef0",
120
+ "schemas/v2/capabilities.schema.json": "a24c99d07971dde0e4596eba0bf5616e3df4459dc2174e8aebb2c544f964337c",
121
121
  "schemas/v2/certification-bundle.schema.json": "9a3c67860f3b0867ff4733d3f8ae0f5d5d747b815ab39b6c255589f103119fa6",
122
- "schemas/v2/channel-presence-payload.schema.json": "9a5227f8bcdb2ecd6c606938c8d835781f4654b88a7a30b0d7e8cd4dde1975ad",
122
+ "schemas/v2/channel-presence-payload.schema.json": "fc03df0c20cc12a25f7ee6a47503a4c5931d79fe3d5ff24815e8fd7c1bcb9a87",
123
123
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
124
124
  "schemas/v2/chat-card-pack-manifest.schema.json": "92a57db2fd4b2463f14ef155fef8e3f18afc78960b646459dcf32efc79ff6bf5",
125
125
  "schemas/v2/compact-tool-descriptor.schema.json": "b5c418d72082d5b713bf318212a83397e5be98e56d25ca056302b41b8dd8273b",
@@ -152,6 +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
156
  "schemas/v2/ids.schema.json": "65cfaa193be0ca9cf48af39c18c553979e3ec1f7fc04509aaa513b322d7b7e4a",
156
157
  "schemas/v2/localized-content-language-settings.schema.json": "3c8350a178037a460f5d87466ccaf9ed5fe3d41497786d2504cc66e42152c8cc",
157
158
  "schemas/v2/localized-content-page-response.schema.json": "ef48329749758afb21e4d3cbc3f017abb911f75726819457f35b9f1390332c75",
@@ -192,8 +193,8 @@
192
193
  "schemas/v2/trigger-subscription-registration.schema.json": "5f7959f3e13f49399783e47f73101d6af95e5abccea1af2f0014ccbe3e51006c",
193
194
  "schemas/v2/trigger-subscription.schema.json": "06abe021712a4bfc5a107a067053cd3cc9704f47ae7066d8449a508c368a0ab2",
194
195
  "schemas/v2/ui-plugin-message.schema.json": "879d65867c01656b3962d64283e66364dcb8f626ad26c5fcf2d2d55a79ae0ce9",
195
- "schemas/v2/webhook-dead-letter-page.schema.json": "543f5ccc26d8f777684a037172ab7b9fdf70735e5394eeb42e5fa6569503f528",
196
- "schemas/v2/webhook-delivery.schema.json": "4e8ab3b722ad1731adf0548c1217f0de62da40de14e8abf77533aee4ace4c079",
196
+ "schemas/v2/webhook-dead-letter-page.schema.json": "78f3284722c2b42d52ff8a1e6d9b9092ba1ac35c2dbe036f3ff2470b34086d1e",
197
+ "schemas/v2/webhook-delivery.schema.json": "5f5361ede12098b5c2d685f0aa70fcb07219fd6b68c6ad6af393ca8dd266d56a",
197
198
  "schemas/v2/webhook-verification.schema.json": "76e4e0d81143ffd2649bc7adba2aa34f2525e2c4e9f0300a425d908b3163196b",
198
199
  "schemas/v2/workflow-chain-pack-manifest.schema.json": "c0e38aa96b029c539415fb7e8a793fd770c3eb5a1b44a818661f9c127c99191b",
199
200
  "schemas/v2/workflow-definition.schema.json": "cb1194631d2b96b0975940b916be43a70363b78b1b6716f9cd84b162d3c96615",
@@ -207,14 +208,14 @@
207
208
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
208
209
  "spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
209
210
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
210
- "spec/v1/core-standard-manifest.json": "62c1d7adc81e42adb7ec62f634f86e8812835ba07bda83c4ce2e5b67d27d87d5",
211
+ "spec/v1/core-standard-manifest.json": "d7c4865e30b6eed18c932ca0c4e4bbcbcb3f4cfedfdd560876a436d0ed774ba0",
211
212
  "spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
212
213
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
213
214
  "spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
214
215
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
215
216
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
216
217
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
217
- "spec/v1/gaps.json": "7fb75c8cceaa8aa6893aa9dc68ae83e57fe0178225a23f376b6de54e74b15ead",
218
+ "spec/v1/gaps.json": "b745aa2620ae8c9f79e7df7bd7c81559b8929f0bdfae62a7ea15debc29e8f19a",
218
219
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
219
220
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
220
221
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -222,12 +223,12 @@
222
223
  "spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
223
224
  "spec/v2/README.md": "ddea90d7cf14aefffe1a43eeeecd4222dadad1d1e5ac64dceb073990eb93d9ef",
224
225
  "spec/v2/core/artifact-type-packs.md": "f734740cc3963f86c9f273a7dace26291bba2855161ddd3540618131a2c8d13e",
225
- "spec/v2/core/capabilities.md": "ed4664a38f53cee32d0082d9fe99091ce13a9874081150fdc72b9b41d4ef9e72",
226
+ "spec/v2/core/capabilities.md": "503fd78ce55aca2cf83b872a5d6086f016470df2d9bcd7fb427b8f16fb9d044a",
226
227
  "spec/v2/core/conformance.md": "3ca291e63bc70a6ef829ae8759ad0ded3741ea7aa507bbbdffc57de443ab2ae2",
227
228
  "spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
228
- "spec/v2/core/conversation.md": "354fa57c73d11062e523a44aa75a6353c5c81aefaf306d30c42416a228c82c86",
229
+ "spec/v2/core/conversation.md": "e0351883a57e90f13d2e9f6f5d3e80080b46be4988617448cb7a0e0c7c0bfbad",
229
230
  "spec/v2/core/errors.md": "91f46acc9c9a5532a98cf62c69285d68575c100ff6064f0b1bf94731a087e34a",
230
- "spec/v2/core/events.md": "c1a20e3aa6736119ea07aa2cf66faa808a1874d2cde4933033b65506d1742883",
231
+ "spec/v2/core/events.md": "00746062621ec98052ee323755fbdb10f4fb070439058039e44feb6bb19b073a",
231
232
  "spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
232
233
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
233
234
  "spec/v2/core/headers.md": "24b0d84ab5521e20f2669fca8006a11f9faf5c2311199e57375b8fa64ce763ed",
@@ -249,11 +250,11 @@
249
250
  "spec/v2/core/storage.md": "dc94b985847c1341c7a064f04a6010f0fdbe6c3ab3e555d4362878c81d764c18",
250
251
  "spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
251
252
  "spec/v2/core/versioning.md": "348a6dc5bd3425efc0016bb6772d5d04ebf578b73eebcdfec6c2d82346a8577e",
252
- "spec/v2/core/webhooks.md": "daf6ec03092f4b3fde1eb74a5e2e801ec48db5139a13fcc0fd7fb4a004635ae3",
253
+ "spec/v2/core/webhooks.md": "b3be00624d07466606c81ff5069744428d16761e16d79807517b171d8293c262",
253
254
  "spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
254
255
  "spec/v2/corrections.json": "3a8d93e33865b692d26a3b6bfd3819766e206b2e0c2a69669baa4bf833d05a49",
255
256
  "spec/v2/corrections.schema.json": "46bbffb954d13bd9995aebc912ceeb2544f7064331abf25fd6ab85c81a5cec94",
256
- "spec/v2/declaration.json": "e963d3aa73892ee08281227c8a01aa6c06f1b825cdcb26aab029b1ab3f4155bf",
257
+ "spec/v2/declaration.json": "5ae9b7c84ab841930dd2885f048d99342ee81ba08cf6a6c07015070f8e17b9d1",
257
258
  "spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
258
259
  "spec/v2/eos-override.json": "4c72d2f6a658943b01c1de1643cf294642091933af7832f38ee5495df684cb2d",
259
260
  "spec/v2/errors.json": "83f048b0a06952b4af6f125fb66c4212acab51056b411390f1010acd6ab9cdc0",
@@ -287,6 +288,7 @@
287
288
  "spec/v2/facets/connections.schema.json": "f998bb206a5814183bb4720cc34fa43b7ab64d61e39a79437b009c589ce8b78e",
288
289
  "spec/v2/facets/envelopeStrictness.schema.json": "28be0a27f880bd45ec1222a16155f5e9167bbca57bf0322970fe1eb2654562aa",
289
290
  "spec/v2/facets/heartbeat.schema.json": "81d7fa1895a8b0241bd07cf00a7e7905bef0fd14eb3427325261c62eee5746a6",
291
+ "spec/v2/facets/hostEvents.schema.json": "973ff452cb91356b4c625d0b06c8d2ef335e18183de09e1afde8acead22290b3",
290
292
  "spec/v2/facets/interrupt.schema.json": "2110ef32c3f880bf706475ef4cf8ddf97ff06871b8e6003a5edbcf4439f1fb70",
291
293
  "spec/v2/facets/mcp.schema.json": "7060657dfcff170f079beed160e3db7c1d3cc4410cd3cf64863baf6459f781cd",
292
294
  "spec/v2/facets/oauth.schema.json": "3a0cbe249d3bd5a5982349ae7ba2534776cc958e4a57e0fbeabf2bdab6e99e69",
@@ -309,9 +311,9 @@
309
311
  "spec/v2/path-manifest.json": "b84c7561f50c7df0cb61b202bd6595acad3ee7edca7a45f711949bea3c69bd27",
310
312
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
311
313
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
312
- "spec/v2/release.json": "74d260237e39bc6e3ffd8740a263e9dbe2fc6e75866b51a6d900dffe32b75ad4",
314
+ "spec/v2/release.json": "84c88ee17dd416060ba7d564fb3d47fdb528c4efa9cd8e483749b77557dac816",
313
315
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
314
- "spec/v2/surface-baseline.json": "4bc82b54af36b5f7611668523349a9bfcc370c4d86812cef8d11dd54f1f14405"
316
+ "spec/v2/surface-baseline.json": "11600f3b5d689962b34aaa1bdfa4c3e2a47632b914a945b356b825dd494ec9db"
315
317
  },
316
- "corpusCommit": "332098b793ec36500833b99f40bab66ec5573d77"
318
+ "corpusCommit": "d6b2495ddf9ac4b82580a9e0f055f29beccfe24a"
317
319
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * RFC 0236 — host events: the judges `v2-host-event-delivery` applies to what
3
+ * it observed. Pure: each takes an observation and returns findings, so the
4
+ * self-test (`host-event-witness.test.ts`) can feed each one a defect and show
5
+ * it convicts.
6
+ *
7
+ * The scenario owns the I/O (the `/host/events` stream, the emit seam, the
8
+ * scoped webhook receiver). This file owns only "given what arrived, which
9
+ * `events.md` §Host events / `webhooks.md` §Delivery rule failed?".
10
+ */
11
+
12
+ export interface Finding { readonly ok: boolean; readonly message: string }
13
+ export interface AdvertisedType { readonly type: string; readonly delivery: 'durable' | 'ephemeral' }
14
+ export interface Frame { readonly event: string; readonly data: string; readonly id: string | null }
15
+
16
+ const ok = (message: string): Finding => ({ ok: true, message });
17
+ const bad = (message: string): Finding => ({ ok: false, message });
18
+
19
+ /** The advertised `hostEvents.types[]`, keeping only well-formed entries. */
20
+ export function advertisedTypes(record: Readonly<Record<string, unknown>> | null): AdvertisedType[] {
21
+ const raw = Array.isArray(record?.['types']) ? (record['types'] as unknown[]) : [];
22
+ return raw.flatMap((t) => {
23
+ const r = t as { type?: unknown; delivery?: unknown };
24
+ return typeof r.type === 'string' && (r.delivery === 'durable' || r.delivery === 'ephemeral') ? [{ type: r.type, delivery: r.delivery }] : [];
25
+ });
26
+ }
27
+
28
+ /** The conformance types the §G seam drives: an `example.*` type of the given class. */
29
+ export function exampleType(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): string | null {
30
+ return types.find((t) => t.type.startsWith('example.') && t.delivery === delivery)?.type ?? null;
31
+ }
32
+
33
+ /** The frame carrying `eventId`, parsed, or null. */
34
+ export function frameFor(frames: readonly Frame[], eventId: string): { frame: Frame; envelope: Record<string, unknown> } | null {
35
+ for (const frame of frames) {
36
+ let env: unknown;
37
+ try { env = JSON.parse(frame.data); } catch { continue; }
38
+ if (env !== null && typeof env === 'object' && (env as { eventId?: unknown }).eventId === eventId) return { frame, envelope: env as Record<string, unknown> };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * §A/§B: an emitted event arrives as a valid envelope, labelled with its type,
45
+ * of its advertised class, with `id:` = `eventId` when durable and no `id:` when
46
+ * ephemeral. `validate` is the host-event schema validator.
47
+ */
48
+ export function judgeEnvelope(
49
+ frames: readonly Frame[], eventId: string, advertised: AdvertisedType,
50
+ validate: (doc: unknown) => { ok: boolean; errors: string },
51
+ ): Finding[] {
52
+ const hit = frameFor(frames, eventId);
53
+ if (hit === null) return [bad(`no frame on /host/events carried eventId ${eventId} (${frames.length} frame(s) arrived)`)];
54
+ const { frame, envelope } = hit;
55
+ const v = validate(envelope);
56
+ const out: Finding[] = [
57
+ v.ok ? ok('the frame is a valid host-event envelope') : bad(`the frame MUST be a valid host-event envelope (no runId, no sequence; closed): ${v.errors}`),
58
+ frame.event === advertised.type ? ok('event: is the type') : bad(`the frame's event: MUST be the host-event type ${advertised.type}; got ${frame.event}`),
59
+ envelope['delivery'] === advertised.delivery ? ok('delivery is the advertised class') : bad(`delivery MUST be the advertised class ${advertised.delivery}; got ${JSON.stringify(envelope['delivery'])}`),
60
+ ];
61
+ if (advertised.delivery === 'durable') out.push(frame.id === eventId ? ok('durable id: is eventId') : bad(`a durable event's frame id: MUST be its eventId ${eventId}; got ${JSON.stringify(frame.id)}`));
62
+ else out.push(frame.id === null ? ok('ephemeral frame has no id:') : bad(`an ephemeral event's frame MUST carry no id:; got ${JSON.stringify(frame.id)}`));
63
+ return out;
64
+ }
65
+
66
+ /** §C: after a reconnect with `Last-Event-ID`, the ephemeral event is not redelivered. */
67
+ export function judgeNoResume(resumed: readonly Frame[], ephemeralEventId: string): Finding {
68
+ return frameFor(resumed, ephemeralEventId) === null
69
+ ? ok('the ephemeral event was not redelivered')
70
+ : bad(`a host MUST NOT redeliver an ephemeral host event on reconnection; eventId ${ephemeralEventId} arrived again after Last-Event-ID`);
71
+ }
72
+
73
+ /** §C: no webhook delivery carries the ephemeral event. `bodies` are the raw delivery bodies the subscription received. */
74
+ export function judgeNoFanOut(bodies: readonly string[], ephemeralEventId: string): Finding {
75
+ const leaked = bodies.some((b) => b.includes(ephemeralEventId));
76
+ return leaked ? bad(`a host MUST NOT deliver an ephemeral host event through webhooks; a delivery carried eventId ${ephemeralEventId}`) : ok('no delivery carried the ephemeral event');
77
+ }
78
+
79
+ /** §E: the host body validates, names the event and type, and verifies under the subscription secret. */
80
+ export function judgeHostBody(
81
+ body: string, eventType: string | undefined, verified: boolean, eventId: string, type: string,
82
+ validate: (doc: unknown) => { ok: boolean; errors: string },
83
+ ): Finding[] {
84
+ let doc: unknown;
85
+ try { doc = JSON.parse(body); } catch { return [bad(`the delivery body MUST be JSON; got ${body.slice(0, 120)}`)]; }
86
+ const v = validate(doc);
87
+ const he = (doc as { hostEvent?: { eventId?: unknown; type?: unknown } } | null)?.hostEvent;
88
+ return [
89
+ v.ok && he !== undefined ? ok('the body is the { hostEvent } delivery') : bad(`a host-event delivery MUST be { hostEvent } and validate against webhook-delivery.schema.json: ${v.ok ? 'no hostEvent' : v.errors}`),
90
+ he?.eventId === eventId ? ok('the body carries the emitted event') : bad(`the delivery MUST carry the emitted eventId ${eventId}; got ${JSON.stringify(he?.eventId)}`),
91
+ eventType === type ? ok('OpenWOP-Event-Type is the host-event type') : bad(`OpenWOP-Event-Type MUST be ${type}; got ${JSON.stringify(eventType)}`),
92
+ verified ? ok('the signature verifies') : bad('the delivery MUST verify under the subscription secret (webhooks.md §Signing is unchanged for host events)'),
93
+ ];
94
+ }
95
+
96
+ /** §D: the second tenant's stream received nothing for the first tenant's event, while the first tenant's did (non-vacuous). */
97
+ export function judgeTenantScope(own: readonly Frame[], other: readonly Frame[], eventId: string): Finding[] {
98
+ return [
99
+ frameFor(own, eventId) !== null ? ok('the owning tenant received it') : bad(`the owning tenant's stream MUST receive its own event ${eventId} (otherwise the isolation check is vacuous)`),
100
+ frameFor(other, eventId) === null ? ok('the other tenant did not') : bad(`a host MUST deliver a host event only within its tenant; a second tenant's stream received ${eventId}`),
101
+ ];
102
+ }
@@ -22,12 +22,29 @@
22
22
  * `v2-created-run-readable` already witnesses it. Duplicating it here would red
23
23
  * two rows for one defect and tell a bundle reader nothing new.
24
24
  *
25
+ * **Minting (RFC 0184 §A.2), a second `it`.** A host MUST NOT mint a
26
+ * tenant-bound id containing `~`; ids already minted MUST still resolve, so only
27
+ * an id this run mints is checked, never every id a body carries. Unescaped, a
28
+ * literal `~` is ambiguous with the codec's own marker. openwop-app minted
29
+ * `user~3A<hash>/<id>` for personal and org tenants (app ADR 0814: it projected
30
+ * the tenant key instead of mapping it), and no leg saw it, because the
31
+ * conformance key sat in the clean tenant `default`. **The leg bites only when
32
+ * the suite's credential sits in a tenant whose key is not already clean**: on a
33
+ * clean tenant a host with that defect still mints a clean id, so a green row is
34
+ * evidence only under a non-clean posture; the `it` title says so. Proven on
35
+ * openwop-app 800722836 (2026-10-05): under `acme@corp.example` the ADR 0814 map
36
+ * passes (`x-61636d65…/…`) and the reverted projection fails
37
+ * (`acme~40corp.example/…`); under `default` both builds pass. The in-process
38
+ * harness (`conformance/run.ts`) overwrites `OPENWOP_API_KEYS` with a `:*` key, so
39
+ * moving the tenant needs that line edited, not just the env var.
40
+ *
25
41
  * The codec's own edge cases (marker escaping, UTF-8 vs UTF-16, malformed
26
42
  * decode) are a unit concern and live in `src/lib/bound-id.test.ts`, which is
27
43
  * sabotage-checked. This file asserts only what needs a HOST to answer.
28
44
  *
29
45
  * @see spec/v2/core/identity.md §5
30
46
  * @see RFCS/0184-bound-id-path-projection.md §A.1
47
+ * @see RFCS/0184-bound-id-path-projection.md §A.2
31
48
  */
32
49
 
33
50
  import { describe, it, expect } from 'vitest';
@@ -40,6 +57,7 @@ import { projectBoundId } from '../lib/bound-id.js';
40
57
  import { BOUND_ID as BOUND } from '../lib/bound-id.js';
41
58
 
42
59
  const ID = 'openwop.requirement.0184.bound-id-path-projection';
60
+ const ID_MINT = 'openwop.requirement.0184.mint-no-tilde';
43
61
  const DOC = 'spec/v2/core/identity.md §5';
44
62
  const NOOP_WORKFLOW_ID = 'conformance-noop';
45
63
 
@@ -132,4 +150,21 @@ describe('v2 bound-id path projection (identity.md §5)', () => {
132
150
  req(ID, DOC, `a path segment whose '~' is not followed by two hex digits MUST be refused 400 validation_error — got ${malformed?.status ?? 'no response'} ${readErrorCode(malformed?.json) ?? ''}`.trim()),
133
151
  ).toBe(400);
134
152
  });
153
+
154
+ it('a tenant-bound id the host mints for this run contains no ~ (bites only when the credential tenant is not grammar-clean)', async () => {
155
+ try { if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable'); } catch { return softSkip('blocked', 'v2 discovery unreachable'); }
156
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
157
+ if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
158
+ if (created.status === 429) return softSkip('blocked', 'POST /runs answered 429 — the run budget, not the wire');
159
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
160
+ if (created.status !== 201 || typeof runId !== 'string') {
161
+ return softSkip('blocked', `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim());
162
+ }
163
+ // The body carries the bound id, never the projection (identity.md §5: ids in
164
+ // documents and bodies are bound). So a `~` here was minted, not escaped.
165
+ expect(
166
+ runId.includes('~'),
167
+ req(ID_MINT, DOC, `a host MUST NOT mint a tenant-bound id containing ~ (RFC 0184 §A.2) — the created runId is ${runId}. A literal ~ is ambiguous with the path projection's escape marker; map the tenant key to a clean segment instead of projecting it`),
168
+ ).toBe(false);
169
+ });
135
170
  });
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * record the record validates against its capabilities-schema seat;
7
7
  *
8
- * Not here: presence delivery needs a channel conversation the suite can create, and the non-persistence rule lives only in a schema description (TODO §8).
8
+ * Not here: delivery is `v2-channel-presence-delivery` (seam-gated: v2 has no route that opens a channel), and whether presence is logged is open in `conversation.md`.
9
9
  *
10
10
  * Dispositions: discovery unreadable ⇒ `blocked`; `channelPresence` absent, or the
11
11
  * facet a leg reads absent ⇒ `inapplicable`.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * v2 — `channel.presence` delivery (`spec/v2/core/conversation.md`
3
+ * §`channelPresence`; suite 2.45.20, target major 2; seam-gated).
4
+ *
5
+ * v2 mints no client route that opens a channel conversation
6
+ * (`conversation.md`), so presence is observable only through the snapshot
7
+ * seam (`host-sample-test-seams.md` §13, `api/seams-v2.yaml`
8
+ * `snapshotChannelPresence`). The seam transiently joins `member` and returns
9
+ * the payload the host would deliver to `observer`, through its production
10
+ * membership gate.
11
+ *
12
+ * advertised-emits a host advertising `channelPresence` MUST emit
13
+ * `channel.presence`: a member's snapshot answers
14
+ * 200, validates against the closed payload, and
15
+ * lists that member;
16
+ * non-member-not-delivered a host MUST NOT deliver it to a non-member: asked
17
+ * as an outsider, the seam MUST NOT answer 200.
18
+ *
19
+ * Every leg asks as `member` first. A 404 there means the seam is not wired
20
+ * (`seamAbsent`), so the outsider's 403/404 is never mistaken for an unwired
21
+ * seam, and a refusal is never mistaken for a pass.
22
+ *
23
+ * Not here: cross-tenant delivery needs a second tenant's credential; the
24
+ * suite holds one. Whether presence is logged is unresolved in the corpus
25
+ * (`conversation.md` names it open), so no leg reads the event log.
26
+ *
27
+ * Dispositions: discovery unreadable ⇒ `blocked`; seams profile or
28
+ * `channelPresence` not advertised ⇒ `inapplicable`; seam unwired ⇒
29
+ * `seamAbsent`.
30
+ *
31
+ * @see spec/v2/core/conversation.md §channelPresence
32
+ * @see spec/v1/host-sample-test-seams.md §13
33
+ */
34
+
35
+ import { describe, it, expect } from 'vitest';
36
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
37
+ import { v2Discovery, familyAdvertised, v2Validator } from '../lib/v2.js';
38
+ import { seamPath, seamsProfileAdvertised } from '../lib/seams.js';
39
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
40
+ import { req } from '../lib/requirement-ids.js';
41
+
42
+ const DOC = 'spec/v2/core/conversation.md §channelPresence';
43
+ const ID_EMITS = 'openwop.requirement.channel-presence.advertised-emits';
44
+ const ID_NON_MEMBER = 'openwop.requirement.channel-presence.non-member-not-delivered';
45
+ const SEAM = seamPath('/v1/host/sample/channel-presence/snapshot');
46
+ const CONVERSATION = 'conf:channel-presence:delivery';
47
+ const MEMBER = 'user:conformance-runner';
48
+ const OUTSIDER = 'user:conformance-outsider';
49
+
50
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
51
+ try { return await fn(); } catch { return null; }
52
+ }
53
+
54
+ /** The gate both legs share, then the member's snapshot; a skip reason otherwise. */
55
+ async function asMember(): Promise<OpenWOPResponse | { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string }> {
56
+ let doc: Record<string, unknown> | null;
57
+ try { doc = await v2Discovery(); } catch { doc = null; }
58
+ if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
59
+ if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'seams profile not advertised (conformance.seamsProfile) — presence is observable only through the §13 seam' };
60
+ if (!(await familyAdvertised('channelPresence'))) return { kind: 'inapplicable', reason: 'the host does not advertise channelPresence' };
61
+ const res = await http(() => driver.post(SEAM, { conversationId: CONVERSATION, member: MEMBER }));
62
+ if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
63
+ if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §13` };
64
+ return res;
65
+ }
66
+
67
+ describe('v2 channel-presence delivery (conversation.md §channelPresence — seam-gated)', () => {
68
+ it('a host advertising channelPresence emits it: a member receives a closed, non-vacuous snapshot', async () => {
69
+ const m = await asMember();
70
+ if (!('status' in m)) return m.kind === 'seam' ? seamAbsent(m.reason) : softSkip(m.kind, m.reason);
71
+ expect(m.status, req(ID_EMITS, DOC, `a host advertising channelPresence MUST emit channel.presence; the member snapshot answered ${m.status}`)).toBe(200);
72
+ const v = v2Validator('channel-presence-payload')(m.json);
73
+ expect(v.ok, req(ID_EMITS, DOC, `the snapshot MUST be the closed channel.presence payload (${v.errors})`)).toBe(true);
74
+ const present = (m.json as { present?: unknown } | null)?.present;
75
+ expect(Array.isArray(present) && present.includes(MEMBER), req(ID_EMITS, DOC, `the joined member ${MEMBER} MUST be present (got ${JSON.stringify(present)})`)).toBe(true);
76
+ });
77
+
78
+ it('a host does not deliver channel.presence to a non-member', async () => {
79
+ const m = await asMember();
80
+ if (!('status' in m)) return m.kind === 'seam' ? seamAbsent(m.reason) : softSkip(m.kind, m.reason);
81
+ if (m.status !== 200) return softSkip('blocked', `the member snapshot answered ${m.status}, so the seam cannot show what an outsider receives (the advertised-emits leg owns that failure)`);
82
+ const out = await http(() => driver.post(SEAM, { conversationId: CONVERSATION, member: MEMBER, observer: OUTSIDER }));
83
+ if (out === null) return softSkip('blocked', `${SEAM} unreachable for the outsider request (fetch failed)`);
84
+ expect(out.status, req(ID_NON_MEMBER, DOC, `a host MUST NOT deliver channel.presence to a non-member: asked as ${OUTSIDER}, the seam answered 200 with ${out.text.slice(0, 200)}`)).not.toBe(200);
85
+ });
86
+ });
@@ -0,0 +1,215 @@
1
+ /**
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).
4
+ *
5
+ * A host event belongs to no run: its envelope has no `runId` and no
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.
9
+ *
10
+ * 0236.host-event.envelope an emitted durable event arrives as a valid
11
+ * envelope, `event:` = type, `id:` = eventId;
12
+ * `channel.presence` is listed ephemeral
13
+ * whenever `channelPresence` is advertised;
14
+ * 0236.ephemeral.no-resume an ephemeral frame has no `id:` and is not
15
+ * redelivered after `Last-Event-ID`;
16
+ * 0236.webhook.ephemeral-refused `registerWebhook` naming the ephemeral type
17
+ * is `400 validation_error`;
18
+ * 0236.webhook.host-variant a subscribed durable event arrives as
19
+ * `{ hostEvent }`, typed and signed;
20
+ * 0236.ephemeral.no-fan-out the same subscription never receives the
21
+ * ephemeral event;
22
+ * 0236.host-event.tenant-isolation a second tenant's stream receives nothing
23
+ * for the first tenant's event.
24
+ *
25
+ * Judged by `lib/host-event-witness.ts`, whose self-test convicts each defect.
26
+ *
27
+ * Dispositions: discovery unreadable ⇒ `blocked`; seams profile or `hostEvents`
28
+ * not advertised ⇒ `inapplicable`; seam unwired, or no `example.*` type of the
29
+ * needed class advertised ⇒ `seamAbsent`; no second-tenant credential
30
+ * (`OPENWOP_TEST_TENANT_B_API_KEY`) ⇒ `inapplicable` for the tenant leg.
31
+ *
32
+ * @see RFCS/0236-host-events.md
33
+ * @see spec/v2/core/events.md §Host events
34
+ */
35
+
36
+ import { afterEach, describe, it, expect } from 'vitest';
37
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
38
+ import { v2Discovery, familyAdvertised, v2Validator } from '../lib/v2.js';
39
+ import { seamPath, seamsProfileAdvertised } from '../lib/seams.js';
40
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
41
+ import { req } from '../lib/requirement-ids.js';
42
+ import { subscribe, type SseEvent } from '../lib/sse.js';
43
+ import { readErrorCode } from '../lib/error-envelope.js';
44
+ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedHit, type ScopedReceiver } from '../lib/scoped-receiver.js';
45
+ import { createReceiverState, verifyWebhookDelivery } from '../lib/webhook-receiver.js';
46
+ import {
47
+ advertisedTypes, exampleType, judgeEnvelope, judgeHostBody, judgeNoFanOut, judgeNoResume, judgeTenantScope, type AdvertisedType, type Finding,
48
+ } from '../lib/host-event-witness.js';
49
+
50
+ export const REQUIRES_HOST_CALLBACK = 'the host POSTs host-event webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
51
+
52
+ const DOC = 'spec/v2/core/events.md §Host events';
53
+ const WDOC = 'spec/v2/core/webhooks.md §Delivery';
54
+ const ID_ENVELOPE = 'openwop.requirement.0236.host-event.envelope';
55
+ const ID_NO_RESUME = 'openwop.requirement.0236.ephemeral.no-resume';
56
+ const ID_REFUSED = 'openwop.requirement.0236.webhook.ephemeral-refused';
57
+ const ID_HOST_BODY = 'openwop.requirement.0236.webhook.host-variant';
58
+ const ID_NO_FAN_OUT = 'openwop.requirement.0236.ephemeral.no-fan-out';
59
+ const ID_TENANT = 'openwop.requirement.0236.host-event.tenant-isolation';
60
+ const SEAM = seamPath('/v1/host/sample/host-events/emit');
61
+ const V2 = { 'OpenWOP-Version': '2.0' };
62
+ const STREAM_MS = 3_000;
63
+ const SECRET = 'openwop-conformance-host-event-secret-0236';
64
+
65
+ const hostEvent = v2Validator('host-event');
66
+ const webhookDelivery = v2Validator('webhook-delivery');
67
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
68
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
69
+ try { return await fn(); } catch { return null; }
70
+ }
71
+ const assertAll = (id: string, doc: string, findings: readonly Finding[]): void => {
72
+ for (const f of findings) expect(f.ok, req(id, doc, f.message)).toBe(true);
73
+ };
74
+
75
+ type Skip = { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string };
76
+ const skip = (s: Skip): undefined => (s.kind === 'seam' ? seamAbsent(s.reason) : softSkip(s.kind, s.reason));
77
+
78
+ /** The shared gate: the advertised types, or why the leg cannot run. */
79
+ async function gate(): Promise<{ types: AdvertisedType[]; record: Record<string, unknown> } | Skip> {
80
+ let doc: Record<string, unknown> | null;
81
+ try { doc = await v2Discovery(); } catch { doc = null; }
82
+ if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
83
+ const record = await familyAdvertised('hostEvents');
84
+ if (record === null) return { kind: 'inapplicable', reason: 'the host does not advertise hostEvents' };
85
+ 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' };
86
+ return { types: advertisedTypes(record), record };
87
+ }
88
+
89
+ function pick(types: readonly AdvertisedType[], delivery: AdvertisedType['delivery']): AdvertisedType | Skip {
90
+ const type = exampleType(types, delivery);
91
+ return type === null
92
+ ? { 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` }
93
+ : { type, delivery };
94
+ }
95
+
96
+ async function emit(type: string): Promise<string | Skip> {
97
+ const res = await http(() => driver.post(SEAM, { type }));
98
+ if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
99
+ if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — RFC 0236 §G` };
100
+ const eventId = (res.json as { eventId?: unknown } | null)?.eventId;
101
+ 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() };
102
+ return eventId;
103
+ }
104
+
105
+ /** Open `/host/events`, run `act` while it is open, and return every frame. */
106
+ async function streamWhile(act: () => Promise<void>, opts: { lastEventId?: string; bearer?: string } = {}): Promise<SseEvent[]> {
107
+ const headers: Record<string, string> = { ...V2, ...(opts.bearer ? { Authorization: `Bearer ${opts.bearer}` } : {}) };
108
+ const sub = subscribe('/host/events', { timeoutMs: STREAM_MS, extraHeaders: headers, ...(opts.lastEventId ? { lastEventId: opts.lastEventId } : {}) });
109
+ await sleep(400);
110
+ await act();
111
+ return [...(await sub).events];
112
+ }
113
+
114
+ const registered: string[] = [];
115
+ let receiver: ScopedReceiver | null = null;
116
+ afterEach(async () => {
117
+ for (const id of registered.splice(0)) await driver.delete(`/webhooks/${encodeURIComponent(id)}`).catch(() => undefined);
118
+ if (receiver) { receiver.server.close(); receiver = null; }
119
+ });
120
+
121
+ describe('v2 host events (RFC 0236 — seam-gated)', () => {
122
+ it('an emitted durable host event arrives on /host/events as a valid envelope', async () => {
123
+ const g = await gate();
124
+ if ('kind' in g) return skip(g);
125
+ const d = pick(g.types, 'durable');
126
+ if ('kind' in d) return skip(d);
127
+ let eventId: string | Skip = '';
128
+ const frames = await streamWhile(async () => { eventId = await emit(d.type); });
129
+ if (typeof eventId !== 'string') return skip(eventId);
130
+ assertAll(ID_ENVELOPE, DOC, judgeEnvelope(frames, eventId, d, hostEvent));
131
+ if (await familyAdvertised('channelPresence')) {
132
+ expect(g.types.some((t) => t.type === 'channel.presence' && t.delivery === 'ephemeral'), req(ID_ENVELOPE, 'spec/v2/core/conversation.md §channelPresence', 'a host advertising channelPresence MUST list channel.presence under hostEvents as ephemeral')).toBe(true);
133
+ }
134
+ }, 20_000);
135
+
136
+ it('an ephemeral host event has no id: and is not redelivered after Last-Event-ID', async () => {
137
+ const g = await gate();
138
+ if ('kind' in g) return skip(g);
139
+ const d = pick(g.types, 'durable');
140
+ const e = pick(g.types, 'ephemeral');
141
+ if ('kind' in d) return skip(d);
142
+ if ('kind' in e) return skip(e);
143
+ let durableId: string | Skip = '';
144
+ let ephemeralId: string | Skip = '';
145
+ const frames = await streamWhile(async () => { durableId = await emit(d.type); await sleep(150); ephemeralId = await emit(e.type); });
146
+ if (typeof durableId !== 'string') return skip(durableId);
147
+ if (typeof ephemeralId !== 'string') return skip(ephemeralId);
148
+ assertAll(ID_NO_RESUME, DOC, judgeEnvelope(frames, ephemeralId, e, hostEvent));
149
+ const resumed = await streamWhile(async () => undefined, { lastEventId: durableId });
150
+ assertAll(ID_NO_RESUME, DOC, [judgeNoResume(resumed, ephemeralId)]);
151
+ }, 20_000);
152
+
153
+ it('registerWebhook refuses an ephemeral host-event type with 400 validation_error', async () => {
154
+ const g = await gate();
155
+ if ('kind' in g) return skip(g);
156
+ const e = pick(g.types, 'ephemeral');
157
+ if ('kind' in e) return skip(e);
158
+ const res = await http(() => driver.post('/webhooks', { url: 'https://example.com/openwop-conformance-0236', events: [e.type], secret: SECRET }));
159
+ if (res === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
160
+ const id = (res.json as { webhookId?: unknown } | null)?.webhookId;
161
+ if (typeof id === 'string') registered.push(id);
162
+ expect(res.status, req(ID_REFUSED, 'spec/v2/core/webhooks.md §Surfaces', `events[] naming the ephemeral host-event type ${e.type} MUST be refused 400 validation_error; got ${res.status} ${readErrorCode(res.json) ?? ''}`)).toBe(400);
163
+ expect(readErrorCode(res.json), req(ID_REFUSED, 'spec/v2/core/webhooks.md §Surfaces', 'the refusal code MUST be validation_error')).toBe('validation_error');
164
+ }, 20_000);
165
+
166
+ it('a subscribed durable host event is delivered as { hostEvent }, signed; the ephemeral one is not', async () => {
167
+ const g = await gate();
168
+ if ('kind' in g) return skip(g);
169
+ if ((await familyAdvertised('webhooks')) === null) return softSkip('inapplicable', 'the host does not advertise webhooks');
170
+ const d = pick(g.types, 'durable');
171
+ const e = pick(g.types, 'ephemeral');
172
+ if ('kind' in d) return skip(d);
173
+ if ('kind' in e) return skip(e);
174
+ const hits: ScopedHit[] = [];
175
+ const rx = await startScopedReceiver((hit, res) => { hits.push(hit); res.writeHead(204); res.end(); });
176
+ receiver = rx;
177
+ const reg = await http(() => driver.post('/webhooks', { url: rx.url, events: [d.type], secret: SECRET }));
178
+ if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
179
+ if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected' && !rx.tunnelled) {
180
+ return softSkip('blocked', 'the host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel');
181
+ }
182
+ const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
183
+ 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);
184
+ if (typeof webhookId === 'string') registered.push(webhookId);
185
+ const ephemeralId = await emit(e.type);
186
+ if (typeof ephemeralId !== 'string') return skip(ephemeralId);
187
+ const durableId = await emit(d.type);
188
+ if (typeof durableId !== 'string') return skip(durableId);
189
+ for (let waited = 0; waited < 15_000 && !hits.some((h) => h.body.includes(durableId)); waited += 250) await sleep(250);
190
+ const hit = hits.find((h) => h.body.includes(durableId));
191
+ if (hit === undefined) {
192
+ return absenceIsUnmeasured(rx) ? softSkip('blocked', noDeliveryCause(rx)) : softSkip('blocked', `no delivery of ${durableId} arrived within 15 s — ${noDeliveryCause(rx)}`);
193
+ }
194
+ const h = (name: string): string | undefined => { const v = hit.headers[name]; return Array.isArray(v) ? v[0] : v; };
195
+ const verified = verifyWebhookDelivery(SECRET, h('openwop-signature') ?? '', h('openwop-signature-algorithm'), h('openwop-timestamp') ?? '', hit.body, createReceiverState()).accepted === true;
196
+ assertAll(ID_HOST_BODY, WDOC, judgeHostBody(hit.body, h('openwop-event-type'), verified, durableId, d.type, webhookDelivery));
197
+ assertAll(ID_NO_FAN_OUT, DOC, [judgeNoFanOut(hits.map((x) => x.body), ephemeralId)]);
198
+ }, 30_000);
199
+
200
+ it('a second tenant receives nothing for the first tenant’s host event', async () => {
201
+ const g = await gate();
202
+ if ('kind' in g) return skip(g);
203
+ const d = pick(g.types, 'durable');
204
+ if ('kind' in d) return skip(d);
205
+ const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
206
+ 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');
207
+ 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');
208
+ let eventId: string | Skip = '';
209
+ const otherStream = streamWhile(async () => undefined, { bearer: other });
210
+ const own = await streamWhile(async () => { eventId = await emit(d.type); });
211
+ const theirs = await otherStream;
212
+ if (typeof eventId !== 'string') return skip(eventId);
213
+ assertAll(ID_TENANT, DOC, judgeTenantScope(own, theirs, eventId));
214
+ }, 20_000);
215
+ });