@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 +46 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +83 -9
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +46 -46
- package/src/lib/mcp-fake-server.ts +32 -1
- package/src/scenarios/v2-bound-id-kinds.test.ts +128 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +29 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +6 -1
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +18 -4
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
|
|
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:
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 571,
|
|
6
|
+
"tests": 2173,
|
|
7
|
+
"withStableId": 2173,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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": [
|
package/scenario-majors.json
CHANGED
|
@@ -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":
|
|
4
|
+
"files": 522,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
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.
|
|
5
|
-
"corpusTag": "v2.
|
|
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": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
191
|
-
"schemas/v2/workflow-chain-pack-manifest.schema.json": "
|
|
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": "
|
|
201
|
+
"spec/v1/alias-detectors.json": "e90916db12d2297e2e560b2d54d2c4702d6cb3bef7179a575844687a7ec17262",
|
|
202
202
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
203
|
-
"spec/v1/core-standard-manifest.json": "
|
|
204
|
-
"spec/v1/deprecations.json": "
|
|
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": "
|
|
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": "
|
|
215
|
-
"spec/v2/README.md": "
|
|
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": "
|
|
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": "
|
|
220
|
+
"spec/v2/core/events.md": "f1bac16419740f3b722febb6f5caaabed8838e092cba3d080fe9184024049dcf",
|
|
221
221
|
"spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
|
|
222
|
-
"spec/v2/core/headers.md": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
233
|
-
"spec/v2/core/versioning.md": "
|
|
234
|
-
"spec/v2/core/webhooks.md": "
|
|
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": "
|
|
242
|
-
"spec/v2/ext/a2uiSurface/README.md": "
|
|
243
|
-
"spec/v2/ext/brand/README.md": "
|
|
244
|
-
"spec/v2/ext/canvas/README.md": "
|
|
245
|
-
"spec/v2/ext/chat/README.md": "
|
|
246
|
-
"spec/v2/ext/coordination/README.md": "
|
|
247
|
-
"spec/v2/ext/dataIntegration/README.md": "
|
|
248
|
-
"spec/v2/ext/entities/README.md": "
|
|
249
|
-
"spec/v2/ext/grpc-transport/README.md": "
|
|
250
|
-
"spec/v2/ext/kanban/README.md": "
|
|
251
|
-
"spec/v2/ext/knowledge/README.md": "
|
|
252
|
-
"spec/v2/ext/launchStudio/README.md": "
|
|
253
|
-
"spec/v2/ext/messaging/README.md": "
|
|
254
|
-
"spec/v2/ext/portability/README.md": "
|
|
255
|
-
"spec/v2/ext/provider-idempotency/README.md": "
|
|
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": "
|
|
258
|
-
"spec/v2/ext/sandbox-runtime-notes/README.md": "
|
|
259
|
-
"spec/v2/ext/webResearch/README.md": "
|
|
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": "
|
|
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": "
|
|
274
|
+
"spec/v2/release.json": "4d9ec16c653702e80967db2a08da55dd09326b5dce60e301623826a801702f78",
|
|
275
275
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
276
276
|
},
|
|
277
|
-
"corpusCommit": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|