@openwop/openwop-conformance 2.3.4 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.4.1] — 2026-09-18 — the leg that reads the host, not the schema
4
+
5
+ **Why a patch.** A leg is added to an existing scenario file; no file is added or removed.
6
+
7
+ ### Fixed
8
+
9
+ - **`v2-chain-pin-exact`** gains a third leg: an EXTERNAL `subChainRef` carrying
10
+ `version: "^1.0.0"`. The existing legs are satisfied by schema validation
11
+ alone (sabotage-proved: delete the reference host's pin rule and both stay
12
+ green); `SubChainRef.ref` types the external `version` as a semver range, so
13
+ this leg is the only one that reads the host's rule.
14
+
15
+ ## [2.4.0] — 2026-09-18 — every bound kind, not just runId
16
+
17
+ **Why a minor.** One scenario file is added (`PROTOCOL-STATUS.md` §suite version).
18
+
19
+ ### Added
20
+
21
+ - **`v2-bound-id-kinds.test.ts`** — one leg per tenant-bound kind with a wire
22
+ surface (`subscriptionId` via `webhookId`, `interruptId`, `effectId`), each
23
+ gated on the family that mints it: the grammar, the `~`-projected accept side,
24
+ and the `403 id_tenant_mismatch` a foreign tenant segment MUST draw whether or
25
+ not the id exists. Until now every v2 scenario that read a bound id read
26
+ `runId`, so a host binding one kind of five was green — which is what a tier-1
27
+ host's own audit found on its wire. The `deliveryId` leg records
28
+ `inapplicable`: the corpus serves no dead-letter read, so that kind has
29
+ nowhere to appear (RFC 0187 §Unresolved).
30
+
31
+ ### Fixed
32
+
33
+ - **`v2-webhook-durable-delivery` counted another scenario's deliveries against
34
+ its retry budget.** Both legs filtered attempts by `runId` alone; a host that
35
+ cannot reach the suite's loopback registers every scenario against one
36
+ tunnelled receiver URL, so a concurrent scenario's subscription matched the
37
+ same run. A tier-2 host measured 6 attempts against a `maxAttempts` of 5 at
38
+ `--max-workers 2` while its own logs showed five. Both legs now also filter on
39
+ the delivery's own `webhookId` — exact, and holds at any worker count.
40
+ - **`McpFakeServer.needs_input_loop`** re-issues `input_required` for
41
+ `arguments.rounds` retries, so a host's MRTR loop can be driven past its
42
+ ceiling; `v2-mrtr-rounds-ceiling` recorded `blocked` without it.
43
+ - **`v2-negotiation-authenticated`** reads `details.runId` off the closed error
44
+ envelope like `v2-minimum-version-refused` does.
45
+ ## [2.3.5] — 2026-09-17 — spec prose editorial pass
46
+
47
+ **Why a patch.** No scenario changed. The v2 prose shipped in spec-artifacts was edited (openwop CHANGELOG [2.3.5]), so 2.3.4 no longer identifies its published contents and the pins move together.
48
+
3
49
  ## [2.3.4] — 2026-09-17 — registers regenerated
4
50
 
5
51
  **Why a patch.** No scenario changed; `spec/v1/gaps.json` (shipped in spec-artifacts) regenerated from the dispositioned registers (openwop CHANGELOG [2.3.4]).
package/README.md CHANGED
@@ -117,7 +117,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
117
117
 
118
118
  ## What's Covered
119
119
 
120
- The current suite has 521 scenario files under `src/scenarios/`.
120
+ The current suite has 522 scenario files under `src/scenarios/`.
121
121
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
122
122
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
123
123
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -462,7 +462,7 @@ Server-required (added in 1.7.0):
462
462
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
463
463
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
464
464
 
465
- Current source tree: 521 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
465
+ Current source tree: 522 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
466
466
 
467
467
  ## Remaining Gaps
468
468
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.3.4",
4
- "stampSha256": "0f49d0f5b139d300bb253f0dc82a23540a1828a7f2f559f55c4f95641211d8eb"
3
+ "version": "2.4.1",
4
+ "stampSha256": "576f2a6451072e6ae17a6398428f6fba4c8a505d80e8899431d4bcf69a2c0fec"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.3.4",
3
+ "version": "2.4.1",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.3.4"
59
+ "@openwop/spec-artifacts": "2.4.1"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 570,
6
- "tests": 2168,
7
- "withStableId": 2168,
5
+ "files": 571,
6
+ "tests": 2173,
7
+ "withStableId": 2173,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2123
9
+ "explicitIds": 2126
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26006,6 +26006,62 @@
26006
26006
  }
26007
26007
  ]
26008
26008
  },
26009
+ {
26010
+ "id": "openwop.it.v2-bound-id-kinds.subscriptionid-post-webhooks-mints-a-bound-webhookid-the-projected-segment-is-ac",
26011
+ "file": "v2-bound-id-kinds.test.ts",
26012
+ "line": 60,
26013
+ "title": "subscriptionId: POST /webhooks mints a bound webhookId, the projected segment is accepted, and a foreign tenant segment is refused 403",
26014
+ "explicitId": "openwop.requirement.0187.bound-id-kinds.webhook",
26015
+ "citations": [
26016
+ {
26017
+ "section": "webhooks.md §Surfaces",
26018
+ "requirement": "POST /webhooks MUST answer 201 { webhookId }"
26019
+ },
26020
+ {
26021
+ "section": "spec/v2/core/identity.md §5 (RFC 0187 §A)",
26022
+ "requirement": null,
26023
+ "interpolated": true
26024
+ },
26025
+ {
26026
+ "section": "spec/v2/core/identity.md §5 (RFC 0187 §A)",
26027
+ "requirement": "the refusal MUST name id_tenant_mismatch, not not_found — the tenant check is the point"
26028
+ },
26029
+ {
26030
+ "section": "spec/v2/core/identity.md §5 (RFC 0187 §A)",
26031
+ "requirement": null,
26032
+ "interpolated": true
26033
+ }
26034
+ ]
26035
+ },
26036
+ {
26037
+ "id": "openwop.it.v2-bound-id-kinds.interruptid-the-id-a-suspension-puts-on-the-wire-is-bound",
26038
+ "file": "v2-bound-id-kinds.test.ts",
26039
+ "line": 87,
26040
+ "title": "interruptId: the id a suspension puts on the wire is bound",
26041
+ "explicitId": null,
26042
+ "citations": []
26043
+ },
26044
+ {
26045
+ "id": "openwop.it.v2-bound-id-kinds.effectid-every-row-get-runs-runid-effects-serves-is-bound",
26046
+ "file": "v2-bound-id-kinds.test.ts",
26047
+ "line": 108,
26048
+ "title": "effectId: every row GET /runs/{runId}/effects serves is bound",
26049
+ "explicitId": "openwop.requirement.0187.bound-id-kinds.per-kind",
26050
+ "citations": [
26051
+ {
26052
+ "section": "security-defaults.md §Layer-2 effect identity",
26053
+ "requirement": "GET /runs/{runId}/effects MUST answer 200"
26054
+ }
26055
+ ]
26056
+ },
26057
+ {
26058
+ "id": "openwop.it.v2-bound-id-kinds.deliveryid-the-kind-has-no-wire-surface-to-be-bound-on-a-corpus-gap-recorded",
26059
+ "file": "v2-bound-id-kinds.test.ts",
26060
+ "line": 123,
26061
+ "title": "deliveryId: the kind has no wire surface to be bound on — a corpus gap, recorded",
26062
+ "explicitId": null,
26063
+ "citations": []
26064
+ },
26009
26065
  {
26010
26066
  "id": "openwop.it.v2-bound-id-path-projection.a-tenant-bound-id-is-readable-at-its-escaped-path-segment-links-carry-that-form",
26011
26067
  "file": "v2-bound-id-path-projection.test.ts",
@@ -26254,7 +26310,7 @@
26254
26310
  {
26255
26311
  "id": "openwop.it.v2-chain-pin-exact.a-chain-reference-carrying-a-range-core-ai-callprompt-1-is-refused-at-register",
26256
26312
  "file": "v2-chain-pin-exact.test.ts",
26257
- "line": 102,
26313
+ "line": 116,
26258
26314
  "title": "a chain reference carrying a range (core.ai.callPrompt@^1) is refused at register",
26259
26315
  "explicitId": "openwop.requirement.0177.chain-pin-exact.range-refused",
26260
26316
  "citations": [
@@ -26269,10 +26325,28 @@
26269
26325
  }
26270
26326
  ]
26271
26327
  },
26328
+ {
26329
+ "id": "openwop.it.v2-chain-pin-exact.an-external-sub-chain-reference-carrying-a-range-is-refused-the-one-pin-the-sche",
26330
+ "file": "v2-chain-pin-exact.test.ts",
26331
+ "line": 125,
26332
+ "title": "an EXTERNAL sub-chain reference carrying a range is refused — the one pin the schema cannot catch",
26333
+ "explicitId": "openwop.requirement.0177.chain-pin-exact.external-range-refused",
26334
+ "citations": [
26335
+ {
26336
+ "section": "workflow-chain-packs.md §\"Exact pins\" (RFC 0177 §E.1)",
26337
+ "requirement": null,
26338
+ "interpolated": true
26339
+ },
26340
+ {
26341
+ "section": "workflow-chain-packs.md §\"Exact pins\" (RFC 0177 §E.1)",
26342
+ "requirement": "the refusal MUST carry an error code in the canonical envelope"
26343
+ }
26344
+ ]
26345
+ },
26272
26346
  {
26273
26347
  "id": "openwop.it.v2-chain-pin-exact.a-chain-reference-pinning-an-exact-version-core-ai-callprompt-1-0-0-registers",
26274
26348
  "file": "v2-chain-pin-exact.test.ts",
26275
- "line": 111,
26349
+ "line": 140,
26276
26350
  "title": "a chain reference pinning an exact version (core.ai.callPrompt@1.0.0) registers",
26277
26351
  "explicitId": "openwop.requirement.0177.chain-pin-exact.exact-accepted",
26278
26352
  "citations": [
@@ -27505,7 +27579,7 @@
27505
27579
  {
27506
27580
  "id": "openwop.it.v2-negotiation-authenticated.an-unauthenticated-a2a-exchange-cannot-lower-the-version-below-preferredversion",
27507
27581
  "file": "v2-negotiation-authenticated.test.ts",
27508
- "line": 86,
27582
+ "line": 91,
27509
27583
  "title": "an unauthenticated A2A exchange cannot lower the version below preferredVersion",
27510
27584
  "explicitId": null,
27511
27585
  "citations": []
@@ -27513,7 +27587,7 @@
27513
27587
  {
27514
27588
  "id": "openwop.it.v2-negotiation-authenticated.an-unauthenticated-mcp-exchange-cannot-lower-the-revision-below-preferredversion",
27515
27589
  "file": "v2-negotiation-authenticated.test.ts",
27516
- "line": 106,
27590
+ "line": 111,
27517
27591
  "title": "an unauthenticated MCP exchange cannot lower the revision below preferredVersion",
27518
27592
  "explicitId": null,
27519
27593
  "citations": []
@@ -29331,7 +29405,7 @@
29331
29405
  {
29332
29406
  "id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
29333
29407
  "file": "v2-webhook-durable-delivery.test.ts",
29334
- "line": 314,
29408
+ "line": 321,
29335
29409
  "title": "an exhausted delivery is dead-lettered, never dropped",
29336
29410
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
29337
29411
  "citations": [
@@ -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": 521,
4
+ "files": 522,
5
5
  "v1": 445,
6
- "v2": 78
6
+ "v2": 79
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1244,6 +1244,9 @@
1244
1244
  "v2-assurance-downgrade-audited.test.ts": [
1245
1245
  2
1246
1246
  ],
1247
+ "v2-bound-id-kinds.test.ts": [
1248
+ 2
1249
+ ],
1247
1250
  "v2-bound-id-path-projection.test.ts": [
1248
1251
  2
1249
1252
  ],
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.3.4",
5
- "corpusTag": "v2.3.4",
4
+ "version": "2.4.1",
5
+ "corpusTag": "v2.4.1",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "d2afa447343920474ddcbcc4b329fd24d54427e124f0e9bad3f9c39cccdc5682",
13
- "api/v2/asyncapi.yaml": "33b035ed9e11825c71d163c2cb48e084e5dcb78810b37a08ca48d828a93c9232",
14
- "api/v2/openapi.yaml": "bd8cf3034acdfa39976edc2040450a535c3c183b85f7d93d6d846317336b76b7",
12
+ "api/seams-v2.yaml": "f6385deac1f8292cce87c896725bcf709308b65dfb8050f9f8113a99492596c2",
13
+ "api/v2/asyncapi.yaml": "edc8d91a418cc29fea8d4cbe20ad1a69af8a26c0addf9b55e29d2fbea50716b0",
14
+ "api/v2/openapi.yaml": "ff765953f64a79f6d7e28e3d1e4adff7d912feb03e5ee804af2bffacbd8da2b3",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -144,12 +144,12 @@
144
144
  "schemas/v2/error-envelope.schema.json": "9c8f747e5bea81943d70e0930bc7d26586a4c3a3d90be77f46359b19b7e56cbe",
145
145
  "schemas/v2/eval-summary.schema.json": "9d31025752d0f67ad3b8a32e82a5314972c530ef9f780dd82e484ae8c5131198",
146
146
  "schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
147
- "schemas/v2/form-content-pack-manifest.schema.json": "307884e57946703b6f86ec283034bf56beb68bea4a000743ebbc9eebeed31b07",
147
+ "schemas/v2/form-content-pack-manifest.schema.json": "461529e4287ec3b15a9fbcde57226a1c6f17ea3ae71b33b418e6f595ff0619fe",
148
148
  "schemas/v2/frontend-plugin-manifest.schema.json": "fed6b8ddb3056c53b1bfc91525410a4445a3c37a8bf9eb9f6f9964ffea26d993",
149
149
  "schemas/v2/goal.schema.json": "146c4182e36d3538778f947e668a92cdb69ec42ee0820104b00063b035e342d7",
150
150
  "schemas/v2/heartbeat-evaluated.schema.json": "4086b1ba1d0547c08b038dfb263ac89416a3e406430db1250156fb2f9b74f8d6",
151
151
  "schemas/v2/heartbeat-state-changed.schema.json": "eb68d0082aa996650e3a6b8ccf32457f959cebc7e6728cc7c331b9d171a4bef6",
152
- "schemas/v2/ids.schema.json": "6481cab16c9b89fab168627502dcbf2ee7489b5a636a4080ea8cfd2670d7b17e",
152
+ "schemas/v2/ids.schema.json": "65cfaa193be0ca9cf48af39c18c553979e3ec1f7fc04509aaa513b322d7b7e4a",
153
153
  "schemas/v2/localized-content-language-settings.schema.json": "92240f19a2b4b2f05ab41a797f703485a28813848035478e72f395edd04c1d35",
154
154
  "schemas/v2/localized-content-page-response.schema.json": "ef48329749758afb21e4d3cbc3f017abb911f75726819457f35b9f1390332c75",
155
155
  "schemas/v2/localized-content-page.schema.json": "e0f97660121955d6705d935f34e748adb739ac70f8536a009cd8d6f303735151",
@@ -170,7 +170,7 @@
170
170
  "schemas/v2/run-ancestry-response.schema.json": "334c17f2062dea62726ca1d07b8be3f169cdc422af843c1def5fb317039814c9",
171
171
  "schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
172
172
  "schemas/v2/run-event-payloads.schema.json": "29dec16f0d40c9022c169d4d413742ddd775a3d8c895d3a60ccc093e2a872439",
173
- "schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
173
+ "schemas/v2/run-event.schema.json": "2f0d7416bd376c56affe97b6b2b49981532e8e9ff45c0a162cf806c78b3e7fb7",
174
174
  "schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
175
175
  "schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
176
176
  "schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
@@ -185,10 +185,10 @@
185
185
  "schemas/v2/tool-descriptor.schema.json": "658b18ef173e0f12d732f92d4866a9b3daeb8f88b493db8b025a577e09dd681c",
186
186
  "schemas/v2/trigger-event.schema.json": "7227fb9a130c33374fa8eab35e4448c4e8f22c962b8f271781cf4dbb5e4288b7",
187
187
  "schemas/v2/trigger-subscription-registration.schema.json": "04bc314a07c7e8b565577cb8df5821d968ce2af915900dfdccd4a24e928d20db",
188
- "schemas/v2/trigger-subscription.schema.json": "074bec7f016aa9f4d60359c3a093296cc847045510f01e0a9dceaf4fa5084d12",
188
+ "schemas/v2/trigger-subscription.schema.json": "c887d4b9a12be0133f8c9dfd3a8bafcb518e27ecc2e800447ebc4d1fef111503",
189
189
  "schemas/v2/ui-plugin-message.schema.json": "879d65867c01656b3962d64283e66364dcb8f626ad26c5fcf2d2d55a79ae0ce9",
190
- "schemas/v2/webhook-delivery.schema.json": "66025cd7b7899b6897b61c61e99ec2c554e232a6c9742124441adcd81912cef0",
191
- "schemas/v2/workflow-chain-pack-manifest.schema.json": "22801f14845a3df30660e6f31a41e1a3fbff2425bf56c8a475eab96f1c95882a",
190
+ "schemas/v2/webhook-delivery.schema.json": "4e8ab3b722ad1731adf0548c1217f0de62da40de14e8abf77533aee4ace4c079",
191
+ "schemas/v2/workflow-chain-pack-manifest.schema.json": "b61cc945b4df1c7093a1058fa71ee59ef10bacb3433ae4d7ae7536136e616cc2",
192
192
  "schemas/v2/workflow-definition.schema.json": "9aafddec32b84eb6aec3185be024850f1b9444c6193568771b0b636595fef136",
193
193
  "schemas/v2/workload-identity.schema.json": "1f589debfac6d70e0908da511550ec69a27b4bcc8314551cd843b6d923605efc",
194
194
  "schemas/v2/workspace-file-create.schema.json": "b6b5260857318c3b67de0231ed15e3a746f525880a763e7b36a7854178dd10d0",
@@ -198,65 +198,65 @@
198
198
  "schemas/workload-identity.schema.json": "64ed93e12cc9f0657e56ff9b8f045e5d68d397df5276d103c07f42c7f34f0579",
199
199
  "schemas/workspace-file-create.schema.json": "70a23b92c2958c0165d7d29aea9020a045dc7816af82c80c5f50d0aac19c4856",
200
200
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
201
- "spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
201
+ "spec/v1/alias-detectors.json": "e90916db12d2297e2e560b2d54d2c4702d6cb3bef7179a575844687a7ec17262",
202
202
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
203
- "spec/v1/core-standard-manifest.json": "ba5006cf189501c807996402baeff8227fe63081a1b32ec5af32fe5a09cad2d8",
204
- "spec/v1/deprecations.json": "508071308e762c281807b487e8f3840a1a69b978071894593e18f2e2d3e4b5c0",
203
+ "spec/v1/core-standard-manifest.json": "195b0a6fa7944758b773e3814659ed7eb4791597ce08b3d5cf5e1588ab7c5702",
204
+ "spec/v1/deprecations.json": "c81c8f71460936c8d489d8270477c7661cf4a5d7b7bad86c98899750b5656027",
205
205
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
206
206
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
207
207
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
208
208
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
209
- "spec/v1/gaps.json": "cd4875bff43ee5afd210da3b50fbff6e3675b4df481c82f822146f994067230d",
209
+ "spec/v1/gaps.json": "6f595da01299deecf928d358449eb7f97ebf9bbfe82e40be82f629f0e256cdfd",
210
210
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
211
211
  "spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
212
212
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
213
213
  "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
214
- "spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
215
- "spec/v2/README.md": "0f8a5c36ca9af5c6aa80b2e24ecf2d6af5ede9964bf70e88839110d9beab5693",
214
+ "spec/v1/spec-gaps.json": "eb3bfcb9c7d05c9a6845d4a40ec9493cfaa5a671932daf1af561310136fdabe8",
215
+ "spec/v2/README.md": "5d49f56f41e06b44312c61ad8144b786c9a90ec42fd0660a40d89520ef206137",
216
216
  "spec/v2/core/capabilities.md": "c5107a4964e487c20bbb5254cfe1f706e0d9ba7e34da9070bc43b50b385e7948",
217
- "spec/v2/core/conformance.md": "622084e3c74c4549087f4d861b1bb98cec201a0c5aec23788d27f53a355fde3b",
217
+ "spec/v2/core/conformance.md": "b8d7dae142e3708a7597533cb77827363b6cfc91b89538081a9afe5e23c975f4",
218
218
  "spec/v2/core/connection-packs.md": "1d759681e2962f5103228c52c45f24607ed4bb9ae73e30605f690c1d5a59e9ee",
219
219
  "spec/v2/core/errors.md": "ad85fc830139e587f20deabf88297b91b1fb6ce31e80d5442624a650246ab16e",
220
- "spec/v2/core/events.md": "20112f459e7f1bcab0e93fbfc2e28719ecd1435228e87c5476b9eaa691558603",
220
+ "spec/v2/core/events.md": "f1bac16419740f3b722febb6f5caaabed8838e092cba3d080fe9184024049dcf",
221
221
  "spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
222
- "spec/v2/core/headers.md": "2dd67d71673539e16612f09f3f2513a384f6566052d7cd8a00885885689854c2",
222
+ "spec/v2/core/headers.md": "91167479e63afd8fcfa1f5ee8f5da965eef8fd1cde87135911792dd2effa1b05",
223
223
  "spec/v2/core/idempotency.md": "b86c352d964aebad532727f314e26ed2177d49de382076fa64d3162e2f56118d",
224
224
  "spec/v2/core/identity.md": "9d2b823eb899463807452fe5721dcf09881837c161be024175957db27870b243",
225
225
  "spec/v2/core/interop.md": "d4c404e469e1d9d9be88bc2c939d777e7d7c1d0419b1251c18770f039838a401",
226
- "spec/v2/core/interrupt.md": "f525479a99104655219fa2e51c4cad2a03d8e3be93ff4dac173a684675e57348",
226
+ "spec/v2/core/interrupt.md": "548c7a7bdfcab56db33dfad3d397cae2ed5f9828a63418021936eb392395e3e4",
227
227
  "spec/v2/core/overview.md": "69377c90bbb87b6bfe4b8be4f1b65810094b0759c8d4f93ca6df92cc530ea023",
228
228
  "spec/v2/core/packs.md": "8b3e1587270bb1aec814befbd287181a05b7f76aeb0802787ee7ebdc85901205",
229
- "spec/v2/core/persistence.md": "2373c2815b25b7b4fe29dfdf3783d3f2f3e637d3abce5085e69f019546302fb6",
229
+ "spec/v2/core/persistence.md": "898dc929d021a03d245691148fe4fdec4582b4faa9237aa32037bbd3a2993b7d",
230
230
  "spec/v2/core/replay.md": "bd191a2565aa7ad0df9b35a0716f3de53be34b8722f1807c5e5b02859404a9b9",
231
231
  "spec/v2/core/runs.md": "f7a95fc82fda5b7c0c613de6e5edd376bbd6d8ba4c665a500c94eacbe42b86f2",
232
- "spec/v2/core/security-defaults.md": "1d38aa03b9d2507f1d4a985333cc57ae7892cecc7356f6342fbd1672e34f638a",
233
- "spec/v2/core/versioning.md": "d731782eeabb953919c7c7057340b7f9c2916b6186790e6b04c7d8ea60767189",
234
- "spec/v2/core/webhooks.md": "158aff629f66cf1ea483e188eba3ef9695d9868d9b525353189db6d7a3c099d6",
232
+ "spec/v2/core/security-defaults.md": "d69a8dfc9057e189caa61f14810e3090b3b053e8fde419d09db84729a42b84c7",
233
+ "spec/v2/core/versioning.md": "fd6564879cf043b68338db20b20de93755a1e7147000afb4738c07770deeddaf",
234
+ "spec/v2/core/webhooks.md": "969c367650e5e93beb090242bcbec59f43b95bdf646fa6c512f6c7fa1f05912e",
235
235
  "spec/v2/core/workflow-chain-packs.md": "ef2b07d2d99334eaec0cc370314e2ad2f84ea58deb699e5d92e6e52d6056613f",
236
236
  "spec/v2/declaration.json": "a09bac61c1b825e4a77b5abc0f86f1f8c20c381e0650eb08c2f6d0ef0aaec1dc",
237
237
  "spec/v2/declaration.schema.json": "c86e3083fb9789c1958ecb4576cc6f71aa90e6f339521139238543ed08211dd4",
238
238
  "spec/v2/errors.json": "4ff032033f02d7e3fb4204581456408ba5e7642b438c1f349bbd1c264e63e419",
239
239
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
240
240
  "spec/v2/event-codemap.schema.json": "b173f2a9bcc0bf9b62a474ce3b91e1431d597e64fd1560fc58662cd8606eaf9c",
241
- "spec/v2/ext/README.md": "c7dea12c48772047f18b111e73be1c236460e72d9bf98026351d148a2ac4f608",
242
- "spec/v2/ext/a2uiSurface/README.md": "176cb0542e1c236ffcd6d682536ba0081242684444e2dec790abd480a44a571e",
243
- "spec/v2/ext/brand/README.md": "1a80102a9cd766dcb6098c999e552af88f5b83c88951acde18d17cc0b29aa09d",
244
- "spec/v2/ext/canvas/README.md": "a2d5f28523753e8e85cc9042db39faf9207a2eebce1a8553be22e341f7ea58d9",
245
- "spec/v2/ext/chat/README.md": "39fe2961502d2937fe487cf52acfffeeb4a396731f0b2bf737b109eac2752bca",
246
- "spec/v2/ext/coordination/README.md": "a193aa9a59a2d9ac8d2e9775e5be40c29b6f7476ffef35231dbab49377af9207",
247
- "spec/v2/ext/dataIntegration/README.md": "8361c626ed9f77dc48a3bcaa0c9f769cb6706632941014700f7683efe0918bd1",
248
- "spec/v2/ext/entities/README.md": "c3bf6c59ddf2255c22a522e689f6e7a11646fbcd309b17699b6cf69a4e2e0f91",
249
- "spec/v2/ext/grpc-transport/README.md": "9913c3f5433b719533e0721af477917bf7a993372388aa800be8bff9d4f8e203",
250
- "spec/v2/ext/kanban/README.md": "6792926f7f0cb8a4dcb0acdedb86a1988574fb0b5de164b37159dfe3ba35f908",
251
- "spec/v2/ext/knowledge/README.md": "d2615320c7de4aec279f276381376a8881e962dd4e86b1435462d742dd00a52f",
252
- "spec/v2/ext/launchStudio/README.md": "bfe417e774689773f3bbced9fdfa0c18809577aa6040a1312d914c821beed026",
253
- "spec/v2/ext/messaging/README.md": "8a4702935483ce7900c0ee14793f9dfbeeb09d5e43f8943c946f8f94de2efa6f",
254
- "spec/v2/ext/portability/README.md": "7525fdc595b05dfd0b2bd22e2f7b3a13302164ebf49e4327c2e29e9da80766f4",
255
- "spec/v2/ext/provider-idempotency/README.md": "5efa5b220e22d8f9a93918d20d3be0c698445d828693c8e487f37abc39bfca7c",
241
+ "spec/v2/ext/README.md": "68b4203c1ffcb1434af79b5e04ae3d23501977e7d48b2256c18a6c92559f6270",
242
+ "spec/v2/ext/a2uiSurface/README.md": "7dfdd0db93b8bd2d8cfb0ebcc7c474f21f2cb0d3108cb66becf311288d21a9ef",
243
+ "spec/v2/ext/brand/README.md": "f5d3c6232e0a63fe445e9707dd6b0e117f8ececcec7194049fafa5562b63aa10",
244
+ "spec/v2/ext/canvas/README.md": "601537975b78d6f762a4a42c7cae583c54c61dfb73817cef99ac03177e671c11",
245
+ "spec/v2/ext/chat/README.md": "f4023b16b3749fbe3786439e370ee73c459c02f8d1c439b5df0287e1f24e1e56",
246
+ "spec/v2/ext/coordination/README.md": "70b00ede75f22c5fbdf4d8e0840d82d2dfeac61bda1459f94d6ebc2adb6d2865",
247
+ "spec/v2/ext/dataIntegration/README.md": "0a040215adbfc1cc176c2fbe052598701c9c36461367f0cd6b9e8d7bb1ee20da",
248
+ "spec/v2/ext/entities/README.md": "24cbf19103cb4711f41088a79a49077eaf4750b0c746764a8302e33b74ea54c8",
249
+ "spec/v2/ext/grpc-transport/README.md": "0d01d939fc7a4eab050f141b42e4fe145f312bcdd89ef78359a47e33c4e94751",
250
+ "spec/v2/ext/kanban/README.md": "c6b3b3bbf6c9cecae674fc7874e145dc3e4f723a864409ad6e96b0173868f801",
251
+ "spec/v2/ext/knowledge/README.md": "067280b15e14b3c46550d7b0ee68d49e7d1455ce86218d05bbc44535eb668bf1",
252
+ "spec/v2/ext/launchStudio/README.md": "3c4258e781d069dced34ab45552ce5b2b4af6ec7e08f0e2e00584d87a7981b15",
253
+ "spec/v2/ext/messaging/README.md": "0ce7535498458ad63d9b7d5b143da618a2da2ce4033ca53ea3fc4cb0c65419db",
254
+ "spec/v2/ext/portability/README.md": "15b8a881df861ff289489ca13a3a4871cf6e14aed90ca018afadff2388a05476",
255
+ "spec/v2/ext/provider-idempotency/README.md": "e3f159b2d1b53c4c279b911de91d80b5410a1e6a05ba940a7f4d2c0d547cf000",
256
256
  "spec/v2/ext/provider-idempotency/registry.json": "5b5fecad604fb4ab38da39f5abe697a4e57b05406c398f8afeb377331b2bd965",
257
- "spec/v2/ext/restTransport/README.md": "b39f4d1ddbabd72dd945a39e90218d8413e46baea1269798b96dffb4e429cb4f",
258
- "spec/v2/ext/sandbox-runtime-notes/README.md": "bd0c4ca4d0fbcba2ae948a02ddc768f50b08a33d9f0622efa1c9e2b7beab2969",
259
- "spec/v2/ext/webResearch/README.md": "941dfe8323fd536791167f9fc7d119dd4e165bb7937e3382a19f15cda3d9f19a",
257
+ "spec/v2/ext/restTransport/README.md": "8cfc6471ca1ca6e43bb53b21d47b33e73c6a5821e631eb4f565b971c01e2e5de",
258
+ "spec/v2/ext/sandbox-runtime-notes/README.md": "94a25409596e5122c217c566cb02bd44dde33c8130f52507988d498def598f29",
259
+ "spec/v2/ext/webResearch/README.md": "76409f9d6a4aa512ba2b8bdffe706b2b509503266037c3ef890950f7b8c9bd2b",
260
260
  "spec/v2/facets/a2a.schema.json": "6b0bceddec6d18d5115aa7d04720a0cc1ad7a9a0a78f53c208b34becaba5d16e",
261
261
  "spec/v2/facets/aiProviders.schema.json": "0c76e48e5d487b5be3b3ab1f6512a4e206c840ee5087d1336c7d7e2853b58d03",
262
262
  "spec/v2/facets/auth.schema.json": "73440c944ad12c41aab0618aeb799a90a0b59e97fc685527f248afb7d015381a",
@@ -267,12 +267,12 @@
267
267
  "spec/v2/facets/runList.schema.json": "6223e1baaebbb9f4faa71bfe968f8d22b4aa88046d0e43e8d5f56409e7f86e90",
268
268
  "spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
269
269
  "spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
270
- "spec/v2/id-field-bindings.json": "eecadfd11581ba2e075ccb320d85fc033b816edfdd51edb5c5727a181e55dcb4",
270
+ "spec/v2/id-field-bindings.json": "e73c06f3055ad30173507ac817a458b96362c8f26209ba105c53653292cf63d3",
271
271
  "spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
272
272
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
273
273
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
274
- "spec/v2/release.json": "fe7080a6557b3ee52e7e512c8bf31f77352e7e998e1d1fbb4b388b08532eca6f",
274
+ "spec/v2/release.json": "4d9ec16c653702e80967db2a08da55dd09326b5dce60e301623826a801702f78",
275
275
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
276
276
  },
277
- "corpusCommit": "1f99dbd8df43666450a5d94902fea8dab987fa37"
277
+ "corpusCommit": "7e0c22fdb50aac7803ca4d9bf46c0406ae944b78"
278
278
  }
@@ -88,6 +88,13 @@ const NEEDS_INPUT_TOOL = {
88
88
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
89
89
  };
90
90
 
91
+ /** The MRTR ceiling fixture: keeps asking for `arguments.rounds` retries (RFC 0175 §E.1). */
92
+ const NEEDS_INPUT_LOOP_TOOL = {
93
+ name: 'needs_input_loop',
94
+ description: 'Re-issues input_required for `rounds` retries before completing — drives a host past its mcp.mrtr.maxRounds ceiling.',
95
+ inputSchema: { type: 'object', properties: { rounds: { type: 'integer', minimum: 1 } }, required: ['rounds'] },
96
+ };
97
+
91
98
  export class McpFakeServer {
92
99
  private _server: Server | null = null;
93
100
  private _boundPort = 0;
@@ -263,7 +270,7 @@ export class McpFakeServer {
263
270
  });
264
271
 
265
272
  case 'tools/list':
266
- return ok({ resultType: 'complete', tools: [ECHO_TOOL, NEEDS_INPUT_TOOL], ttlMs: 60_000, cacheScope: 'public' });
273
+ return ok({ resultType: 'complete', tools: [ECHO_TOOL, NEEDS_INPUT_TOOL, NEEDS_INPUT_LOOP_TOOL], ttlMs: 60_000, cacheScope: 'public' });
267
274
 
268
275
  case 'tools/call': {
269
276
  const name = params['name'];
@@ -278,6 +285,30 @@ export class McpFakeServer {
278
285
  this._nextResultAssertsAuthority = false;
279
286
  return ok({ resultType: 'complete', content: [{ type: 'text', text }], isError: false, ...extra });
280
287
  }
288
+ if (name === 'needs_input_loop') {
289
+ // The ceiling fixture (RFC 0175 §E.1): re-issues `input_required` for
290
+ // `arguments.rounds` retries before completing, so a host's MRTR loop
291
+ // can be driven PAST `mcp.mrtr.maxRounds`. `needs_input` completes on
292
+ // the first retry and therefore cannot reach any ceiling >= 1, which is
293
+ // why `v2-mrtr-rounds-ceiling` recorded `blocked` until this existed.
294
+ if (clientCaps['elicitation'] === undefined) {
295
+ return err(400, MCP_ERR.MISSING_REQUIRED_CLIENT_CAPABILITY, 'needs_input_loop requires the elicitation client capability', { requiredCapabilities: ['elicitation'] });
296
+ }
297
+ const wanted = Number(((params['arguments'] ?? {}) as { rounds?: unknown }).rounds ?? 1);
298
+ const state = params['requestState'];
299
+ let served = 0;
300
+ if (typeof state === 'string') {
301
+ const m = /^mrtr:needs_input_loop:(\d+)$/.exec(state);
302
+ if (m === null) return err(400, -32602, 'requestState missing or not the value this server issued (clients MUST echo it exactly)');
303
+ served = Number(m[1]);
304
+ }
305
+ if (served >= wanted) return ok({ resultType: 'complete', content: [{ type: 'text', text: `looped ${served}` }], isError: false });
306
+ return ok({
307
+ resultType: 'input_required',
308
+ inputRequests: { who: { method: 'elicitation/create', params: { mode: 'form', message: `round ${served + 1} of ${wanted}`, requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } } } },
309
+ requestState: `mrtr:needs_input_loop:${served + 1}`,
310
+ });
311
+ }
281
312
  if (name === 'needs_input') {
282
313
  const responses = params['inputResponses'] as Record<string, { action?: string; content?: { name?: string } }> | undefined;
283
314
  const state = params['requestState'];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `spec/v2/core/identity.md` §5 + RFC 0187 §A — every tenant-bound KIND that
3
+ * has a wire surface, not just `runId` (suite 2.4.0, target major 2).
4
+ *
5
+ * §5 names five tenant-bound kinds: `runId`, `interruptId`, `subscriptionId`,
6
+ * `deliveryId`, `effectId`. Until this file, every v2 scenario that read a
7
+ * bound id on the wire read `runId` — so a host that bound one kind of five was
8
+ * green. A tier-1 host's own audit found exactly that: its id derivation walked
9
+ * the `$ref`s but matched only `runId`, and `interruptId` / `subscriptionId` /
10
+ * `deliveryId` / `effectId` went out BARE on every major-2 channel, with the
11
+ * accept path unbound on two prefixes. The suite could not have caught it.
12
+ *
13
+ * `webhookId` is the `subscriptionId` kind as of RFC 0187 §A.1: the mint
14
+ * surface (`POST /webhooks → { webhookId }`) had inherited v1's bare
15
+ * `type: string`, so the kind had no HTTP surface and the HTTP surface had no
16
+ * kind — and `identity.md` §5's `403 id_tenant_mismatch` check had nothing to
17
+ * read. Both production hosts mint it bare today and say so; this file is why
18
+ * that is now visible rather than assumed.
19
+ *
20
+ * One leg per kind, each gated on the family that mints it, so a host without
21
+ * the family records `inapplicable` rather than a failure. The `deliveryId`
22
+ * leg records `inapplicable` for a different reason and it is a corpus finding,
23
+ * not a host one: `api/v2/openapi.yaml` serves no dead-letter read, so a bound
24
+ * `deliveryId` has nowhere to appear (RFC 0187 §Unresolved).
25
+ *
26
+ * @see spec/v2/core/identity.md §5
27
+ * @see RFCS/0187-host-found-bindings.md §A
28
+ */
29
+ import { describe, it, expect } from 'vitest';
30
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
31
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
32
+ import { readErrorCode } from '../lib/error-envelope.js';
33
+ import { softSkip } from '../lib/soft-skip.js';
34
+ import { req } from '../lib/requirement-ids.js';
35
+ import { projectBoundId, BOUND_ID } from '../lib/bound-id.js';
36
+
37
+ const DOC = 'spec/v2/core/identity.md §5 (RFC 0187 §A)';
38
+ const WEBHOOK_ID = 'openwop.requirement.0187.bound-id-kinds.webhook';
39
+ const PER_KIND = 'openwop.requirement.0187.bound-id-kinds.per-kind';
40
+
41
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
42
+ try { return await fn(); } catch { return null; }
43
+ }
44
+
45
+ /** A bound id whose tenant segment is NOT the caller's — the one input §5's 403 check exists for. */
46
+ function foreignTenant(id: string): string {
47
+ const opaque = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
48
+ return `not-the-callers-tenant/${opaque}`;
49
+ }
50
+
51
+ /** Assert one id is the tenant-bound grammar. */
52
+ function expectBound(id: unknown, kind: string, where: string, requirement: string): void {
53
+ expect(
54
+ typeof id === 'string' && BOUND_ID.test(id),
55
+ req(requirement, DOC, `${kind} on the wire MUST be tenant-bound \`<tenantId>/<opaque>\` (identity.md §5 kind table) — ${where} carried ${JSON.stringify(id)}`),
56
+ ).toBe(true);
57
+ }
58
+
59
+ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
60
+ it('subscriptionId: POST /webhooks mints a bound webhookId, the projected segment is accepted, and a foreign tenant segment is refused 403', async () => {
61
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
62
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised — no subscription to mint (gate recorded under openwop.family.webhooks)');
63
+ const reg = await http(() => driver.post('/webhooks', { url: 'https://subscriber.invalid/hook', events: ['run.completed'] }));
64
+ if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
65
+ if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') return softSkip('blocked', 'host SSRF guard rejected the fixture receiver URL — set OPENWOP_WEBHOOK_RECEIVER_URL or allow the reserved .invalid host to witness');
66
+ expect(reg.status, req(WEBHOOK_ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
67
+ const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
68
+ expectBound(webhookId, 'webhookId', 'POST /webhooks 201', WEBHOOK_ID);
69
+ if (typeof webhookId !== 'string' || !BOUND_ID.test(webhookId)) return softSkip('blocked', `the mint answered ${JSON.stringify(webhookId)}, which is not a tenant-bound id — the projection and 403 legs have no bound id to drive (the grammar failure is recorded by the assertion above)`);
70
+
71
+ // The 403 check §5 exists for: a bound id whose tenant is not the caller's.
72
+ const foreign = await http(() => driver.delete(`/webhooks/${projectBoundId(foreignTenant(webhookId))}`));
73
+ expect(
74
+ foreign?.status ?? null,
75
+ req(WEBHOOK_ID, DOC, `a tenant-bound webhookId whose tenant segment is not the caller's MUST be refused 403 id_tenant_mismatch — got ${foreign?.status ?? 'no response'} ${readErrorCode(foreign?.json) ?? ''}`.trim()),
76
+ ).toBe(403);
77
+ expect(readErrorCode(foreign?.json), req(WEBHOOK_ID, DOC, 'the refusal MUST name id_tenant_mismatch, not not_found — the tenant check is the point')).toBe('id_tenant_mismatch');
78
+
79
+ // The accept side: the RFC 0184 projection on a tenant-bound parameter that is not runId.
80
+ const del = await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
81
+ expect(
82
+ del?.status ?? null,
83
+ req(WEBHOOK_ID, DOC, `DELETE /webhooks/{webhookId} MUST accept the ~-projected spelling of the id it minted (RFC 0184 §A: every tenant-bound path parameter) — got ${del?.status ?? 'no response'} ${readErrorCode(del?.json) ?? ''}`.trim()),
84
+ ).toBe(204);
85
+ });
86
+
87
+ it('interruptId: the id a suspension puts on the wire is bound', async () => {
88
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
89
+ if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised — no suspension to read (gate recorded under openwop.family.interrupt)');
90
+ const create = await http(() => driver.post('/runs', { workflowId: 'conformance-approval' }));
91
+ if (create === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
92
+ if (create.status !== 201) return softSkip('blocked', `POST /runs {workflowId: conformance-approval} answered ${create.status} ${readErrorCode(create.json) ?? ''} — the suspending fixture is not seeded on this host`.trim());
93
+ const runId = (create.json as { runId?: unknown }).runId;
94
+ if (typeof runId !== 'string') return softSkip('blocked', 'the create answered no runId');
95
+ const until = Date.now() + 15_000;
96
+ let suspended: Record<string, unknown> | undefined;
97
+ while (Date.now() < until && !suspended) {
98
+ const poll = await http(() => driver.get(`/runs/${projectBoundId(runId)}/events/poll?timeout=1`));
99
+ const events = ((poll?.json as { events?: Array<Record<string, unknown>> } | null)?.events ?? []);
100
+ suspended = events.find((e) => e['type'] === 'node.suspended');
101
+ if (!suspended) await new Promise((r) => setTimeout(r, 300));
102
+ }
103
+ if (!suspended) return softSkip('blocked', 'the approval fixture did not reach node.suspended inside 15s — no interruptId on the wire to read');
104
+ const payload = (suspended['payload'] ?? {}) as Record<string, unknown>;
105
+ expectBound(payload['interruptId'], 'interruptId', 'node.suspended.payload', PER_KIND);
106
+ });
107
+
108
+ it('effectId: every row GET /runs/{runId}/effects serves is bound', async () => {
109
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
110
+ if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — the Layer-2 effect ledger is its obligation (gate recorded under openwop.family.idempotency)');
111
+ const create = await http(() => driver.post('/runs', { workflowId: 'conformance-noop' }));
112
+ if (create === null || create.status !== 201) return softSkip('blocked', `POST /runs answered ${create?.status ?? 'no response'} — no run to read effects on`);
113
+ const runId = (create.json as { runId: string }).runId;
114
+ const res = await http(() => driver.get(`/runs/${projectBoundId(runId)}/effects`));
115
+ if (res === null) return softSkip('blocked', 'GET /runs/{runId}/effects unreachable (fetch failed)');
116
+ if (res.status === 404 || res.status === 405) return softSkip('inapplicable', `the host advertises idempotency but serves no effect ledger at /runs/{runId}/effects (${res.status})`);
117
+ expect(res.status, req(PER_KIND, 'security-defaults.md §Layer-2 effect identity', 'GET /runs/{runId}/effects MUST answer 200')).toBe(200);
118
+ const effects = ((res.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? []);
119
+ if (effects.length === 0) return softSkip('inapplicable', 'the noop fixture records no effects — no effectId on the wire to read (a host with an effect-producing fixture witnesses this leg)');
120
+ for (const e of effects) expectBound(e['effectId'], 'effectId', 'GET /runs/{runId}/effects', PER_KIND);
121
+ });
122
+
123
+ it('deliveryId: the kind has no wire surface to be bound on — a corpus gap, recorded', async () => {
124
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
125
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
126
+ return softSkip('inapplicable', 'identity.md §5 binds deliveryId, but api/v2/openapi.yaml serves no dead-letter read (a GET /webhooks/{webhookId}/dead-letters projection is needed) — the kind has nowhere to appear on the wire, so no host can be held to it. RFC 0187 §Unresolved; v2-webhook-durable-delivery records the same absence for exhaustion.');
127
+ });
128
+ });
@@ -76,6 +76,20 @@ function chainPack(name: string, reference: string): Record<string, unknown> {
76
76
  }],
77
77
  };
78
78
  }
79
+ /**
80
+ * The same fixture with an EXTERNAL sub-chain reference. `SubChainRef.ref`
81
+ * oneOf[1] types `version` as a semver RANGE, so — unlike the node reference
82
+ * above — the schema cannot refuse a range here. A host either carries the
83
+ * §Exact pins rule or it does not, and this is the only shape that tells the
84
+ * two apart.
85
+ */
86
+ function externalRefPack(name: string, version: string): Record<string, unknown> {
87
+ const pack = chainPack(name, 'core.ai.callPrompt@1.0.0');
88
+ const chain = (pack['chains'] as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
89
+ chain['subChains'] = [{ ref: { packName: 'core.openwop.v2-pin-external', chainId: 'c.remote', version } }];
90
+ return pack;
91
+ }
92
+
79
93
  async function publish(manifest: Record<string, unknown>) {
80
94
  const name = manifest['name'] as string;
81
95
  const version = manifest['version'] as string;
@@ -108,6 +122,21 @@ describe('v2-chain-pin-exact (RFC 0177 §E.1)', () => {
108
122
  expect(typeof readErrorCode(res.json), req('openwop.requirement.0177.chain-pin-exact.range-refused', SECTION, 'the refusal MUST carry an error code in the canonical envelope')).toBe('string');
109
123
  });
110
124
 
125
+ it('an EXTERNAL sub-chain reference carrying a range is refused — the one pin the schema cannot catch', async () => {
126
+ const skip = await preflight();
127
+ if (skip) return softSkip(skip.kind, skip.reason);
128
+ // Measured 2026-09-18 on both production hosts: the external branch is
129
+ // typed, reachable and UNEXERCISED — every shipped pack uses the sibling
130
+ // string form — so nothing on either host would have noticed a range here.
131
+ // The two legs above are satisfied by schema validation alone (sabotage on
132
+ // the reference host: delete its pin rule and they stay green), which is
133
+ // why this leg exists: it is the only one that reads the HOST's rule.
134
+ const res = await publish(externalRefPack(freshName('chain-ext-range'), '^1.0.0'));
135
+ if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
136
+ expect(res.status >= 400 && res.status < 500, req('openwop.requirement.0177.chain-pin-exact.external-range-refused', SECTION, `an external reference carrying a range MUST be refused at register — the schema types this field as a semver range, so only the host's rule can refuse it (got ${res.status})`)).toBe(true);
137
+ expect(typeof readErrorCode(res.json), req('openwop.requirement.0177.chain-pin-exact.external-range-refused', SECTION, 'the refusal MUST carry an error code in the canonical envelope')).toBe('string');
138
+ });
139
+
111
140
  it('a chain reference pinning an exact version (core.ai.callPrompt@1.0.0) registers', async () => {
112
141
  const skip = await preflight();
113
142
  if (skip) return softSkip(skip.kind, skip.reason);
@@ -66,7 +66,12 @@ async function assertNotLowered(id: string, preferred: string, lower: string, re
66
66
  req(id, 'interop.md §Authentication', `the wire toward the peer MUST NOT carry the lower version (${lower}) under an unauthenticated exchange — captured ${v}`),
67
67
  ).not.toBe(lower);
68
68
  }
69
- const runId = (res.json as { runId?: unknown } | null)?.runId;
69
+ // A refusal is the closed error envelope (errors.md): the run that carries the
70
+ // decision is named in `details.runId`, exactly where v2-minimum-version-refused
71
+ // reads it. Reading only the top-level key recorded `blocked` on a host that had
72
+ // emitted the event correctly.
73
+ const body = (res.json ?? {}) as { runId?: unknown; details?: Record<string, unknown> };
74
+ const runId = body.runId ?? body.details?.['runId'];
70
75
  if (typeof runId !== 'string') {
71
76
  softSkip('blocked', 'the seam answered without a runId — the negotiation.decided event for the unauthenticated exchange is not addressable from the suite');
72
77
  return;
@@ -39,7 +39,7 @@ import { req } from '../lib/requirement-ids.js';
39
39
  const FIXTURE = 'conformance-noop';
40
40
  const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
41
41
 
42
- interface Attempt { readonly key: string; readonly runId: string | null; readonly status: number; readonly at: number }
42
+ interface Attempt { readonly key: string; readonly runId: string | null; readonly webhookId: string; readonly status: number; readonly at: number }
43
43
 
44
44
  /**
45
45
  * A receiver that fails the first `failFirst` attempts for each delivery key
@@ -67,7 +67,7 @@ async function startReceiver(failFirst: number): Promise<{ server: Server; url:
67
67
  const n = (seen.get(key) ?? 0) + 1;
68
68
  seen.set(key, n);
69
69
  const status = n <= failFirst ? 500 : 204;
70
- attempts.push({ key, runId, status, at: Date.now() });
70
+ attempts.push({ key, runId, webhookId, status, at: Date.now() });
71
71
  res.writeHead(status);
72
72
  res.end();
73
73
  });
@@ -246,7 +246,14 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
246
246
  const runId = (create.json as { runId: string }).runId;
247
247
  await waitTerminal(runId, 10_000);
248
248
 
249
- const ours = () => receiver.attempts.filter((a) => a.runId === runId);
249
+ // Filter by the delivery's own SUBSCRIPTION, not just its run. A host that
250
+ // cannot reach the suite's loopback registers every scenario against one
251
+ // tunnelled receiver URL (`resolveRegistrationUrl`), so a concurrently
252
+ // running scenario's subscription matches this run too and its attempts
253
+ // landed in this budget: a tier-2 host measured 6 attempts against a
254
+ // maxAttempts of 5 at `--max-workers 2`, with its own logs showing five.
255
+ // The webhook id is on every delivery (webhooks.md §Headers).
256
+ const ours = () => receiver.attempts.filter((a) => a.runId === runId && a.webhookId === sub.webhookId);
250
257
  const retried = await waitFor(() => ours().some((a) => a.status === 204), retryWaitMs(doc));
251
258
  const attempts = ours();
252
259
  expect(
@@ -326,7 +333,14 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
326
333
  expect(create.status, req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
327
334
  const runId = (create.json as { runId: string }).runId;
328
335
  await waitTerminal(runId, 10_000);
329
- const ours = () => receiver.attempts.filter((a) => a.runId === runId);
336
+ // Filter by the delivery's own SUBSCRIPTION, not just its run. A host that
337
+ // cannot reach the suite's loopback registers every scenario against one
338
+ // tunnelled receiver URL (`resolveRegistrationUrl`), so a concurrently
339
+ // running scenario's subscription matches this run too and its attempts
340
+ // landed in this budget: a tier-2 host measured 6 attempts against a
341
+ // maxAttempts of 5 at `--max-workers 2`, with its own logs showing five.
342
+ // The webhook id is on every delivery (webhooks.md §Headers).
343
+ const ours = () => receiver.attempts.filter((a) => a.runId === runId && a.webhookId === sub.webhookId);
330
344
  await waitFor(() => ours().length > 1, retryWaitMs(doc));
331
345
  const attempts = ours();
332
346
  expect(