@openwop/openwop-conformance 2.45.25 → 2.45.27
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 +14 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +36 -28
- package/schemas/CORPUS-STAMP.json +14 -14
- package/src/lib/host-event-witness.ts +47 -1
- package/src/scenarios/v2-approval-reject-disposition.test.ts +8 -2
- package/src/scenarios/v2-host-event-delivery.test.ts +82 -36
- package/src/scenarios/v2-minimum-version-refused.test.ts +1 -1
- package/src/scenarios/v2-negotiation-authenticated.test.ts +1 -1
- package/src/scenarios/v2-pack-isolation.test.ts +20 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.45.27] — 2026-10-08 — a seam-free host's seam-driven legs record `inapplicable`, not `blocked`
|
|
4
|
+
|
|
5
|
+
- **Three scenarios recorded `blocked` on a host without the seams profile**, which denies the bundle certification, where the rest of the suite records a seam-driven leg `inapplicable`. Found by the v2 reference host's first seams-off cut (RFC 0241's evidence): 11 rows `blocked`, all `executed-pass` with the seams profile.
|
|
6
|
+
- **`v2-pack-isolation` (8 rows):** `invoke` recorded `inapplicable`, then each leg added `blocked` (`seam unavailable`), which outranks it. A leg now returns the kind `invoke` recorded; a seams host whose sandbox seam answers 404 is still `blocked`.
|
|
7
|
+
- **`v2-negotiation-authenticated` and `v2-minimum-version-refused`, MCP legs:** `blocked` without the seams profile, where their A2A twins say `inapplicable`. Now `inapplicable`.
|
|
8
|
+
- **`v2-approval-reject-disposition`, the quorum leg:** the resume value's `voter` is a suite convention, not a protocol field, so a seam-free host rightly counts the bearer, and one bearer cannot cause a majority. That is now `inapplicable`; a seams host that ignores `voter` is still `blocked`.
|
|
9
|
+
- Proven on the v2 reference host: seams off, the 11 rows `inapplicable` and none `blocked`; seams on, all 28 rows of the four files `executed-pass`; seams on with `voter` ignored, the quorum row `blocked`.
|
|
10
|
+
|
|
11
|
+
## [2.45.26] — 2026-10-08 — RFC 0236's legs run without the seam (RFC 0241 Active)
|
|
12
|
+
|
|
13
|
+
- **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`.
|
|
14
|
+
- **`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.
|
|
15
|
+
- **`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).
|
|
16
|
+
|
|
3
17
|
## [2.45.25] — 2026-10-07 — `v2-wasm-abi-reject`; the frontend-plugin schema legs leave host bundles
|
|
4
18
|
|
|
5
19
|
- **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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.45.
|
|
3
|
+
"version": "2.45.27",
|
|
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.
|
|
59
|
+
"@openwop/spec-artifacts": "2.45.27"
|
|
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":
|
|
7
|
-
"withStableId":
|
|
6
|
+
"tests": 2782,
|
|
7
|
+
"withStableId": 2782,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
9
|
"explicitIds": 2644
|
|
10
10
|
},
|
|
@@ -28193,7 +28193,7 @@
|
|
|
28193
28193
|
{
|
|
28194
28194
|
"id": "openwop.it.v2-approval-reject-disposition.a-rejected-gate-with-no-routing-edge-fails-the-run-with-approval-rejected",
|
|
28195
28195
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28196
|
-
"line":
|
|
28196
|
+
"line": 143,
|
|
28197
28197
|
"title": "a rejected gate with no routing edge fails the run with approval_rejected",
|
|
28198
28198
|
"explicitId": null,
|
|
28199
28199
|
"citations": [
|
|
@@ -28216,7 +28216,7 @@
|
|
|
28216
28216
|
{
|
|
28217
28217
|
"id": "openwop.it.v2-approval-reject-disposition.interrupt-resolved-records-action-reject-and-decision-rejected",
|
|
28218
28218
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28219
|
-
"line":
|
|
28219
|
+
"line": 154,
|
|
28220
28220
|
"title": "interrupt.resolved records action reject and decision rejected",
|
|
28221
28221
|
"explicitId": null,
|
|
28222
28222
|
"citations": [
|
|
@@ -28235,7 +28235,7 @@
|
|
|
28235
28235
|
{
|
|
28236
28236
|
"id": "openwop.it.v2-approval-reject-disposition.the-gate-node-fails-with-approval-rejected-not-retryable-and-run-failed-names-it",
|
|
28237
28237
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28238
|
-
"line":
|
|
28238
|
+
"line": 167,
|
|
28239
28239
|
"title": "the gate node fails with approval_rejected, not retryable, and run.failed names it",
|
|
28240
28240
|
"explicitId": null,
|
|
28241
28241
|
"citations": [
|
|
@@ -28256,7 +28256,7 @@
|
|
|
28256
28256
|
{
|
|
28257
28257
|
"id": "openwop.it.v2-approval-reject-disposition.a-majority-reject-decides-the-quorum-gate-on-the-deciding-vote-and-fails-the-run",
|
|
28258
28258
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28259
|
-
"line":
|
|
28259
|
+
"line": 180,
|
|
28260
28260
|
"title": "a majority reject decides the quorum gate on the deciding vote and fails the run with approval_rejected",
|
|
28261
28261
|
"explicitId": null,
|
|
28262
28262
|
"citations": [
|
|
@@ -28282,7 +28282,7 @@
|
|
|
28282
28282
|
{
|
|
28283
28283
|
"id": "openwop.it.v2-approval-reject-disposition.a-rejected-gate-routed-over-an-any-failed-edge-fails-as-a-node-its-all-success-s",
|
|
28284
28284
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28285
|
-
"line":
|
|
28285
|
+
"line": 207,
|
|
28286
28286
|
"title": "a rejected gate routed over an any_failed edge fails as a node, its all_success sibling never runs, and the run completes",
|
|
28287
28287
|
"explicitId": null,
|
|
28288
28288
|
"citations": [
|
|
@@ -28308,7 +28308,7 @@
|
|
|
28308
28308
|
{
|
|
28309
28309
|
"id": "openwop.it.v2-approval-reject-disposition.an-unresolved-gate-past-its-timeoutms-with-no-ontimeout-is-resolved-rejected-by",
|
|
28310
28310
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28311
|
-
"line":
|
|
28311
|
+
"line": 226,
|
|
28312
28312
|
"title": "an unresolved gate past its timeoutMs with no onTimeout is resolved rejected by the host and fails the run naming the gate",
|
|
28313
28313
|
"explicitId": null,
|
|
28314
28314
|
"citations": [
|
|
@@ -28329,7 +28329,7 @@
|
|
|
28329
28329
|
{
|
|
28330
28330
|
"id": "openwop.it.v2-approval-reject-disposition.a-gate-whose-ontimeout-is-approve-still-resolves-rejected-on-timeout-and-fails-t",
|
|
28331
28331
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28332
|
-
"line":
|
|
28332
|
+
"line": 245,
|
|
28333
28333
|
"title": "a gate whose onTimeout is approve still resolves rejected on timeout and fails the run: a timeout never grants",
|
|
28334
28334
|
"explicitId": null,
|
|
28335
28335
|
"citations": [
|
|
@@ -28351,7 +28351,7 @@
|
|
|
28351
28351
|
{
|
|
28352
28352
|
"id": "openwop.it.v2-approval-reject-disposition.a-rejected-gate-looped-back-to-itself-is-asked-again-under-a-new-key-and-the-fir",
|
|
28353
28353
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28354
|
-
"line":
|
|
28354
|
+
"line": 263,
|
|
28355
28355
|
"title": "a rejected gate looped back to itself is asked again under a new key, and the first rejection is not replayed",
|
|
28356
28356
|
"explicitId": null,
|
|
28357
28357
|
"citations": [
|
|
@@ -28377,7 +28377,7 @@
|
|
|
28377
28377
|
{
|
|
28378
28378
|
"id": "openwop.it.v2-approval-reject-disposition.a-replay-fork-of-a-decided-reject-derives-the-rejection-from-the-log-and-never-r",
|
|
28379
28379
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28380
|
-
"line":
|
|
28380
|
+
"line": 339,
|
|
28381
28381
|
"title": "a replay fork of a decided reject derives the rejection from the log and never re-decides it (leg A)",
|
|
28382
28382
|
"explicitId": null,
|
|
28383
28383
|
"citations": [
|
|
@@ -28390,7 +28390,7 @@
|
|
|
28390
28390
|
{
|
|
28391
28391
|
"id": "openwop.it.v2-approval-reject-disposition.a-replay-fork-of-a-timed-out-gate-derives-the-timeout-rejection-from-the-log-and",
|
|
28392
28392
|
"file": "v2-approval-reject-disposition.test.ts",
|
|
28393
|
-
"line":
|
|
28393
|
+
"line": 354,
|
|
28394
28394
|
"title": "a replay fork of a timed-out gate derives the timeout rejection from the log and never re-arms it (leg B)",
|
|
28395
28395
|
"explicitId": null,
|
|
28396
28396
|
"citations": [
|
|
@@ -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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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": []
|
|
@@ -34054,7 +34062,7 @@
|
|
|
34054
34062
|
{
|
|
34055
34063
|
"id": "openwop.it.v2-pack-isolation.sandbox-isolationmodel-names-a-real-mechanism-never-node-vm",
|
|
34056
34064
|
"file": "v2-pack-isolation.test.ts",
|
|
34057
|
-
"line":
|
|
34065
|
+
"line": 89,
|
|
34058
34066
|
"title": "sandbox.isolationModel names a real mechanism, never node:vm",
|
|
34059
34067
|
"explicitId": "openwop.requirement.0173.pack-isolation",
|
|
34060
34068
|
"citations": [
|
|
@@ -34072,7 +34080,7 @@
|
|
|
34072
34080
|
{
|
|
34073
34081
|
"id": "openwop.it.v2-pack-isolation.the-sandbox-invoke-seam-answers-the-seam-contract",
|
|
34074
34082
|
"file": "v2-pack-isolation.test.ts",
|
|
34075
|
-
"line":
|
|
34083
|
+
"line": 103,
|
|
34076
34084
|
"title": "the sandbox-invoke seam answers the seam contract",
|
|
34077
34085
|
"explicitId": "openwop.requirement.0173.pack-isolation.seam",
|
|
34078
34086
|
"citations": [
|
|
@@ -34086,7 +34094,7 @@
|
|
|
34086
34094
|
{
|
|
34087
34095
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-fs-gated-a-host-filesystem-read-is-refused",
|
|
34088
34096
|
"file": "v2-pack-isolation.test.ts",
|
|
34089
|
-
"line":
|
|
34097
|
+
"line": 115,
|
|
34090
34098
|
"title": "node-pack-sandbox-fs-gated: a host filesystem read is refused",
|
|
34091
34099
|
"explicitId": null,
|
|
34092
34100
|
"citations": []
|
|
@@ -34094,7 +34102,7 @@
|
|
|
34094
34102
|
{
|
|
34095
34103
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-fs-gated-a-host-filesystem-write-is-refused",
|
|
34096
34104
|
"file": "v2-pack-isolation.test.ts",
|
|
34097
|
-
"line":
|
|
34105
|
+
"line": 122,
|
|
34098
34106
|
"title": "node-pack-sandbox-fs-gated: a host filesystem write is refused",
|
|
34099
34107
|
"explicitId": null,
|
|
34100
34108
|
"citations": []
|
|
@@ -34102,7 +34110,7 @@
|
|
|
34102
34110
|
{
|
|
34103
34111
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-no-env-the-host-environment-does-not-leak",
|
|
34104
34112
|
"file": "v2-pack-isolation.test.ts",
|
|
34105
|
-
"line":
|
|
34113
|
+
"line": 129,
|
|
34106
34114
|
"title": "node-pack-sandbox-no-env: the host environment does not leak",
|
|
34107
34115
|
"explicitId": null,
|
|
34108
34116
|
"citations": []
|
|
@@ -34110,7 +34118,7 @@
|
|
|
34110
34118
|
{
|
|
34111
34119
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-network-gated-ungated-network-egress-is-refused",
|
|
34112
34120
|
"file": "v2-pack-isolation.test.ts",
|
|
34113
|
-
"line":
|
|
34121
|
+
"line": 136,
|
|
34114
34122
|
"title": "node-pack-sandbox-network-gated: ungated network egress is refused",
|
|
34115
34123
|
"explicitId": null,
|
|
34116
34124
|
"citations": []
|
|
@@ -34118,7 +34126,7 @@
|
|
|
34118
34126
|
{
|
|
34119
34127
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-no-process-host-process-access-is-refused",
|
|
34120
34128
|
"file": "v2-pack-isolation.test.ts",
|
|
34121
|
-
"line":
|
|
34129
|
+
"line": 143,
|
|
34122
34130
|
"title": "node-pack-sandbox-no-process: host process access is refused",
|
|
34123
34131
|
"explicitId": null,
|
|
34124
34132
|
"citations": []
|
|
@@ -34126,7 +34134,7 @@
|
|
|
34126
34134
|
{
|
|
34127
34135
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-timeout-exceeding-wallclocklimitms-fails-with-sandbox-timeout",
|
|
34128
34136
|
"file": "v2-pack-isolation.test.ts",
|
|
34129
|
-
"line":
|
|
34137
|
+
"line": 150,
|
|
34130
34138
|
"title": "node-pack-sandbox-timeout: exceeding wallClockLimitMs fails with sandbox_timeout",
|
|
34131
34139
|
"explicitId": "openwop.requirement.0173.pack-isolation.timeout",
|
|
34132
34140
|
"citations": [
|
|
@@ -34139,7 +34147,7 @@
|
|
|
34139
34147
|
{
|
|
34140
34148
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-memory-cap-exceeding-memorylimitbytes-fails-with-sandbox-memor",
|
|
34141
34149
|
"file": "v2-pack-isolation.test.ts",
|
|
34142
|
-
"line":
|
|
34150
|
+
"line": 157,
|
|
34143
34151
|
"title": "node-pack-sandbox-memory-cap: exceeding memoryLimitBytes fails with sandbox_memory_exceeded",
|
|
34144
34152
|
"explicitId": "openwop.requirement.0173.pack-isolation.memory-cap",
|
|
34145
34153
|
"citations": [
|
|
@@ -34152,7 +34160,7 @@
|
|
|
34152
34160
|
{
|
|
34153
34161
|
"id": "openwop.it.v2-pack-isolation.node-pack-sandbox-isolated-context-a-pack-cannot-mutate-state-another-invocation",
|
|
34154
34162
|
"file": "v2-pack-isolation.test.ts",
|
|
34155
|
-
"line":
|
|
34163
|
+
"line": 164,
|
|
34156
34164
|
"title": "node-pack-sandbox-isolated-context: a pack cannot mutate state another invocation sees",
|
|
34157
34165
|
"explicitId": "openwop.requirement.0173.pack-isolation.isolated-context",
|
|
34158
34166
|
"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.
|
|
5
|
-
"corpusTag": "v2.45.
|
|
4
|
+
"version": "2.45.27",
|
|
5
|
+
"corpusTag": "v2.45.27",
|
|
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": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "9b9455816461b5fc267d1f9a1624667f9f6bd81d2e251731767cca142cd9d0e0",
|
|
13
|
+
"api/v2/asyncapi.yaml": "ec80cec238dcb350f741b49138ed92c9d60f110fcb8ad4bdff2cf44c83c8e1d8",
|
|
14
|
+
"api/v2/openapi.yaml": "e3cff1191d97def0575096d0ec450622a39c1126ca4baaa72c24079ab3b50774",
|
|
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": "
|
|
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": "
|
|
211
|
+
"spec/v1/core-standard-manifest.json": "7e44d633d25ceae4d0d8de5462337e666c3a827c1a003e27b7005edd915fc909",
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
316
|
+
"spec/v2/release.json": "5edf0eb76b4b2d61addd3ebda82c0fa7cbfe9228665050da798cf1f26962cdbe",
|
|
317
317
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
318
|
-
"spec/v2/surface-baseline.json": "
|
|
318
|
+
"spec/v2/surface-baseline.json": "d75c8f150df9625594cb6405b603e4f6f4d0e8d0d62ce198d0028c1133e9cbfd"
|
|
319
319
|
},
|
|
320
|
-
"corpusCommit": "
|
|
320
|
+
"corpusCommit": "4bf2cc5a47102975d8710f50f619b3e4f1fcd9d8"
|
|
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) {
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
* The quorum leg presents distinct votes on one bearer through the resume
|
|
60
60
|
* value's `voter` member, as `interrupt-quorum-resolution` does at major 1. A
|
|
61
61
|
* host that counts both votes as one principal cannot be driven to a majority
|
|
62
|
-
* this way
|
|
62
|
+
* this way: that records `blocked` on a host advertising the seams profile, and
|
|
63
|
+
* `inapplicable` on one without it, which rightly counts only the bearer.
|
|
63
64
|
*
|
|
64
65
|
* @see spec/v2/core/interrupt.md §Rejection
|
|
65
66
|
* @see spec/v2/errors.json approval_rejected
|
|
@@ -68,7 +69,8 @@
|
|
|
68
69
|
|
|
69
70
|
import { describe, it, expect } from 'vitest';
|
|
70
71
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
71
|
-
import { gateFamily } from '../lib/v2.js';
|
|
72
|
+
import { gateFamily, v2Discovery } from '../lib/v2.js';
|
|
73
|
+
import { seamsProfileAdvertised } from '../lib/seams.js';
|
|
72
74
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
73
75
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
74
76
|
import { softSkip } from '../lib/soft-skip.js';
|
|
@@ -190,6 +192,10 @@ describe('RFC 0223 — v2-approval-reject-disposition (gated on interrupt + conf
|
|
|
190
192
|
const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
191
193
|
if (between === 'waiting-approval' && terminal?.['status'] === 'waiting-approval') {
|
|
192
194
|
await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
|
|
195
|
+
// `voter` is a suite convention, not a protocol field: a host without the
|
|
196
|
+
// seams profile rightly counts the bearer, and one bearer cannot cause a
|
|
197
|
+
// majority there. Only a seams host that ignores it is `blocked`.
|
|
198
|
+
if (!seamsProfileAdvertised(await v2Discovery().catch(() => null))) return softSkip('inapplicable', 'the host counted both votes as one approver: one bearer, and the resume value\'s voter stand-in is a seams-profile convention this seam-free host rightly ignores; a majority needs distinct principals');
|
|
193
199
|
return softSkip('blocked', 'the host counted both votes as one approver (one bearer; the resume value\'s voter was not honoured), so a majority could not be caused');
|
|
194
200
|
}
|
|
195
201
|
expect(between, req(id, DOC, 'one reject of three under majority does not decide the gate: the run MUST stay waiting-approval')).toBe('waiting-approval');
|
|
@@ -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;
|
|
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
|
|
8
|
-
*
|
|
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`;
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
81
|
-
async function
|
|
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
|
-
|
|
88
|
-
return { types: advertisedTypes(record), record };
|
|
97
|
+
return { types: advertisedTypes(record), record, doc };
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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: `${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
149
|
-
const e = pick(g
|
|
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
|
|
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
|
-
|
|
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
|
|
166
|
-
if (
|
|
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
|
|
181
|
-
const e = pick(g
|
|
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
|
|
227
|
+
const ephemeralId = await emit(g.trigger, e);
|
|
196
228
|
if (typeof ephemeralId !== 'string') return skip(ephemeralId);
|
|
197
|
-
const durableId = await emit(d
|
|
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
|
|
217
|
-
const e = pick(g
|
|
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
|
|
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
|
|
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));
|
|
@@ -116,7 +116,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
|
|
|
116
116
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
117
117
|
const facet = await familyAdvertised('mcp');
|
|
118
118
|
if (!facet) return softSkip('inapplicable', 'mcp facet not advertised — no floor to enforce');
|
|
119
|
-
if (!seamsProfileAdvertised(doc)) return softSkip('
|
|
119
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the below-floor exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
120
120
|
const floor = String(facet['minimumRevision']);
|
|
121
121
|
if (!(MCP_LOWEST < floor)) {
|
|
122
122
|
return softSkip('blocked', `the advertised mcp.minimumRevision (${floor}) is at or below the lowest revision the suite server can offer (${MCP_LOWEST}) — nothing below the floor can be offered`);
|
|
@@ -119,7 +119,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
119
119
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
120
120
|
const facet = await familyAdvertised('mcp');
|
|
121
121
|
if (!facet) return softSkip('inapplicable', 'mcp facet not advertised — no negotiation to authenticate');
|
|
122
|
-
if (!seamsProfileAdvertised(doc)) return softSkip('
|
|
122
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the unauthenticated exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
123
123
|
const preferred = String(facet['preferredVersion']);
|
|
124
124
|
if (!(MCP_LOWER < preferred)) return softSkip('blocked', `mcp.preferredVersion is ${preferred}, at or below the lowest revision the suite server can offer (${MCP_LOWER}) — no lower revision can be offered`);
|
|
125
125
|
const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
|
|
@@ -33,7 +33,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
33
33
|
import { driver } from '../lib/driver.js';
|
|
34
34
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
35
35
|
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
36
|
-
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
36
|
+
import { softSkip, seamAbsent, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
37
37
|
import { req } from '../lib/requirement-ids.js';
|
|
38
38
|
|
|
39
39
|
const ISOLATION_MODELS = ['wasm', 'process', 'container', 'vm'];
|
|
@@ -55,12 +55,19 @@ async function gated(): Promise<Record<string, unknown> | null> {
|
|
|
55
55
|
return sandbox;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* The kind `invoke` recorded when it returned null. A leg returns this kind, not
|
|
60
|
+
* a fixed `blocked`: a seam-free host records `inapplicable` here, and a second
|
|
61
|
+
* `blocked` on top of it outranked that and denied the bundle certification.
|
|
62
|
+
*/
|
|
63
|
+
let invokeSkip: SoftSkipKind = 'blocked';
|
|
64
|
+
|
|
65
|
+
/** Drive one misbehaving typeId through the sandbox seam; null (reason recorded, kind in `invokeSkip`) when the seam is absent. */
|
|
59
66
|
async function invoke(typeId: string, extra: Record<string, unknown> = {}): Promise<InvokeResult | null> {
|
|
60
67
|
const doc = await discovery();
|
|
61
|
-
if (!doc || !seamsProfileAdvertised(doc)) { softSkip('inapplicable', `the ${typeId} leg is seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised`); return null; }
|
|
68
|
+
if (!doc || !seamsProfileAdvertised(doc)) { invokeSkip = 'inapplicable'; softSkip('inapplicable', `the ${typeId} leg is seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised`); return null; }
|
|
62
69
|
const res = await driver.post(INVOKE, { typeId, ...extra });
|
|
63
|
-
if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
|
|
70
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) { invokeSkip = 'blocked'; seamAbsent(`host advertises sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
|
|
64
71
|
// The same id is ALSO asserted by its own `it` above, and that is the copy the
|
|
65
72
|
// registry can see: `generate-requirement-registry.mjs` harvests `req(…)` only
|
|
66
73
|
// within an `it`, so this helper-level call alone left the id out of
|
|
@@ -108,58 +115,58 @@ describe('RFC 0173 §B — pack-isolation (gated on sandbox)', () => {
|
|
|
108
115
|
it('node-pack-sandbox-fs-gated: a host filesystem read is refused', async () => {
|
|
109
116
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
110
117
|
const r = await invoke('misbehave.fs-escape-read');
|
|
111
|
-
if (!r) return softSkip(
|
|
118
|
+
if (!r) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
112
119
|
expectEscape('openwop.requirement.0173.pack-isolation.fs-read', r, 'host-fs-escape', 'node-pack-sandbox-fs-gated');
|
|
113
120
|
});
|
|
114
121
|
|
|
115
122
|
it('node-pack-sandbox-fs-gated: a host filesystem write is refused', async () => {
|
|
116
123
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
117
124
|
const r = await invoke('misbehave.fs-escape-write');
|
|
118
|
-
if (!r) return softSkip(
|
|
125
|
+
if (!r) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
119
126
|
expectEscape('openwop.requirement.0173.pack-isolation.fs-write', r, 'host-fs-escape', 'node-pack-sandbox-fs-gated');
|
|
120
127
|
});
|
|
121
128
|
|
|
122
129
|
it('node-pack-sandbox-no-env: the host environment does not leak', async () => {
|
|
123
130
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
124
131
|
const r = await invoke('misbehave.env-leak');
|
|
125
|
-
if (!r) return softSkip(
|
|
132
|
+
if (!r) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
126
133
|
expectEscape('openwop.requirement.0173.pack-isolation.no-env', r, 'host-env-leak', 'node-pack-sandbox-no-env');
|
|
127
134
|
});
|
|
128
135
|
|
|
129
136
|
it('node-pack-sandbox-network-gated: ungated network egress is refused', async () => {
|
|
130
137
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
131
138
|
const r = await invoke('misbehave.network-escape');
|
|
132
|
-
if (!r) return softSkip(
|
|
139
|
+
if (!r) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
133
140
|
expectEscape('openwop.requirement.0173.pack-isolation.network', r, 'network-escape', 'node-pack-sandbox-network-gated');
|
|
134
141
|
});
|
|
135
142
|
|
|
136
143
|
it('node-pack-sandbox-no-process: host process access is refused', async () => {
|
|
137
144
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
138
145
|
const r = await invoke('misbehave.process-escape');
|
|
139
|
-
if (!r) return softSkip(
|
|
146
|
+
if (!r) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
140
147
|
expectEscape('openwop.requirement.0173.pack-isolation.no-process', r, 'host-process-escape', 'node-pack-sandbox-no-process');
|
|
141
148
|
});
|
|
142
149
|
|
|
143
150
|
it('node-pack-sandbox-timeout: exceeding wallClockLimitMs fails with sandbox_timeout', async () => {
|
|
144
151
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
145
152
|
const r = await invoke('misbehave.timeout');
|
|
146
|
-
if (!r) return softSkip(
|
|
153
|
+
if (!r) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
147
154
|
expect(r.error?.code, req('openwop.requirement.0173.pack-isolation.timeout', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-timeout: a wall-clock overrun MUST fail with sandbox_timeout')).toBe('sandbox_timeout');
|
|
148
155
|
});
|
|
149
156
|
|
|
150
157
|
it('node-pack-sandbox-memory-cap: exceeding memoryLimitBytes fails with sandbox_memory_exceeded', async () => {
|
|
151
158
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
152
159
|
const r = await invoke('misbehave.memory-bomb');
|
|
153
|
-
if (!r) return softSkip(
|
|
160
|
+
if (!r) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
154
161
|
expect(r.error?.code, req('openwop.requirement.0173.pack-isolation.memory-cap', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-memory-cap: a memory overrun MUST fail with sandbox_memory_exceeded')).toBe('sandbox_memory_exceeded');
|
|
155
162
|
});
|
|
156
163
|
|
|
157
164
|
it('node-pack-sandbox-isolated-context: a pack cannot mutate state another invocation sees', async () => {
|
|
158
165
|
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
159
166
|
const first = await invoke('misbehave.cross-pack-mutate');
|
|
160
|
-
if (!first) return softSkip(
|
|
167
|
+
if (!first) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
161
168
|
const second = await invoke('misbehave.cross-pack-mutate');
|
|
162
|
-
if (!second) return softSkip(
|
|
169
|
+
if (!second) return softSkip(invokeSkip, 'seam leg not driven (reason recorded above)');
|
|
163
170
|
for (const r of [first, second]) {
|
|
164
171
|
expect(r.error, req('openwop.requirement.0173.pack-isolation.isolated-context', 'host-sample-test-seams.md §8', 'misbehave.cross-pack-mutate is not a failure mode — it MUST return a result')).toBeUndefined();
|
|
165
172
|
expect(
|