@openwop/openwop-conformance 2.36.1 → 2.37.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 +22 -0
- package/README.md +4 -3
- package/coverage.md +1 -0
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +510 -73
- package/scenario-majors.json +14 -2
- package/schemas/CORPUS-STAMP.json +27 -27
- package/src/lib/a2a-error-info.ts +44 -0
- package/src/lib/a2a-fake-peer.ts +50 -7
- package/src/lib/effect-receiver.ts +135 -0
- package/src/lib/front-mux.ts +102 -0
- package/src/lib/mcp-fake-server.ts +15 -4
- package/src/lib/oidc-issuer.ts +15 -5
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +19 -7
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-a2a-client-error-details.test.ts +70 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +84 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-durability-recovery.test.ts +83 -35
- package/src/scenarios/v2-idempotency-in-flight.test.ts +132 -0
- package/src/scenarios/v2-interrupt-resolve-terminal.test.ts +107 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +41 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +13 -0
- package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +133 -0
- package/src/scenarios/v2-terminal-event-once.test.ts +15 -21
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.37.1] — 2026-09-24 — records that say why, and identities a host must remember minted fresh per run
|
|
4
|
+
|
|
5
|
+
- **`0213.in-flight-loser-outcome` no longer blocks a host that replays every loser.** `v2-idempotency-in-flight` ended with `softSkip('blocked', …)` when all five same-key creates succeeded. RFC 0213 §B explicitly permits that outcome: a loser MAY wait and receive the winner's final outcome, marked `OpenWOP-Idempotent-Replay: true`. The file's own header already promised `partial-witness`. Blocking denied certification (RFC 0168 §E.1) to every host fast enough, or serial enough, that no loser was ever in flight. It was measured on the v2-reference host with the published 2.37.0 (loopback rehearsal: all 5 answers were successes, 4 of them marked replays). The note is now `inapplicable`, which at major 2 records the row as a partial witness after the leg's assertions. Certification counts it, and the acceptance predicate still refuses it, because the 409 branch was never observed.
|
|
6
|
+
- **A failing assertion reaches the record with its message and its case name, and the per-`it` row of a multi-leg requirement survives.** MEASURED, `v2-run-bulk-cancel.test.ts` on a tier-2 host, both rows from ONE run: `openwop.floor.v2-run-bulk-cancel` `executed-fail`, 10 assertions, detail "one or more assertions in the file failed"; `openwop.requirement.0170.run-bulk-cancel` `executed-pass`, 3 assertions. No case name, no message, nothing else in the record for that file — the host operator could not diagnose it and hand-probed production. Two independent faults. (1) `fileDisposition`'s `executed-fail` detail was that FIXED STRING; it is now `failureDetail(...)` — the failing case titles (first three, then `+N more`) plus the first failure's message — and `resolveItRecord` names the leg in the per-`it` row's detail too. `setup.ts` collects `task.name` + the first error in `afterEach`. (2) The file has two `it`s that both hand `req()` one module-level `const ID`; leg 1 passed with 3 assertions and leg 2 failed on its 7th, and leg 2's row hit `recordRequirement`'s one-disposition-per-run throw, which `setup.ts`'s "never fail a test for bookkeeping" `catch` swallowed — so the verdict reached neither the in-memory ledger nor the JSONL sink. `recordRequirement` gains `fold`, used only by the per-`it` path: the surviving row is the LEAST certifiable leg (the rank `readLedgerFile` already applies across workers), carrying that leg's detail, with the legs' assertion counts summed; a scenario that classifies ITSELF twice still throws. 27 scenario files share one explicit id across several `it`s, so this was never one file's bug — `scripts/check-req-only.mjs` rule (d) now resolves a `const` handed to `req()` (it compared only call-site literals and was blind to the form nearly every scenario uses) and no longer flags the shared-id pattern the fold makes safe. **Why 7 of the 10 assertions looked unattributed, stated rather than gated:** they were not. All ten carry `req(ID, …)`, and attribution is per-`it`, not per-assertion — `req()` sets one `explicitId` that `setup.ts` takes once per test, and `assertionCount` counts `expect` calls whatever their message. The 3-vs-10 was the discarded leg, not a missing id. (The suite does hold 1,281 `expect`s with no message at all, against 4,824 carrying `req(...)`; they cost the reader a sentence, never the row its id, and the case name now carries alongside vitest's own diff. The reasoning is recorded in `check-req-only.mjs` rather than turned into a 1,281-site sweep.) Sabotage, host-free: one assertion flipped in `v2-payload-seats-0186.test.ts` (3 `it`s, one shared `const ID`, same shape as the bulk-cancel file) reproduces the reported pair exactly at `origin/main` — scenario row `executed-fail` / "one or more assertions in the file failed", requirement row `executed-pass`, failing leg absent — and after the fix the requirement row is `executed-fail` naming `"§A.3 ApprovalData.onTimeout is a closed enum and the def stays closed"` with the full `req()` message, and the file row names the case and quotes it.
|
|
7
|
+
- **Four webhook scenarios stop registering one byte-identical destination, and a zero that is provably unmeasured stops convicting the host.** `v2-bound-id-kinds`, `v2-webhook-delivery-shape`, `v2-webhook-durable-delivery` and `webhook-signed-delivery` each bound `OPENWOP_WEBHOOK_RECEIVER_PORT` and registered `resolveRegistrationUrl(...)`, which returns `OPENWOP_WEBHOOK_RECEIVER_URL` VERBATIM — so on a tunnelled cut all four subscriptions pointed at ONE URL. A webhook subscription is durable host-side state that keeps delivering, and RETRYING, after the file that made it has finished, so whichever receiver held the port read the leftovers as its own traffic — and `v2-webhook-durable-delivery` answers 500 BY DESIGN. Both symptoms were already recorded in the tree as workarounds: `v2-webhook-delivery-shape`'s receiver carried "the tunnel forwards to the PINNED port — held by the other receiver, which answers 500 by design — so this file's `deliveries` stays empty, its legs soft-skip, and the rows resolve `executed-pass`", and `v2-webhook-durable-delivery` filters by subscription because "a tier-2 host measured 6 attempts against a maxAttempts of 5". New `src/lib/scoped-receiver.ts` composes the two halves the tree already had rather than reimplementing either: ROUTING is openwop#1520's `front-mux` (each receiver registers its handler under its nonce and calls `routeFronted` first, so several can be alive behind one front), IDENTITY is the nonce made UNCONDITIONAL — `frontedEndpoint` omits it for the pinned-port owner, which is enough for a live listener and not for a subscription that outlives its exercise. A retry for a finished exercise now addresses a nonce nobody serves, is answered 404, and is counted `foreign()` instead of absorbed. `unservedDestination()` does the same for a leg that needs the mint and no delivery (`v2-bound-id-kinds` leg 1). Zeroes: where other traffic DID reach the listener, the path from host to process demonstrably works and the absence is of this exercise's IDENTITY, so the leg records `blocked` with `noDeliveryCause(...)` naming the address, the nonce and the foreign count — `blocked` denies certification exactly as a failure does (RFC 0168 §E.1), and a genuinely mis-wired front produces no traffic at all and still fails. `src/lib/scoped-receiver.test.ts` pins it host-free (8 cases: distinct destinations, the nonce surviving a front, a sibling's traffic routed to the sibling, a finished exercise's retry refused, a nonce-less stranger refused, own traffic delivered prefix-stripped, and both readings of a zero); removing the nonce from the destination reds 3 of them. The `receiverBinding` source guard in `webhook-receiver.test.ts` is rewritten to accept either shape and gains two cases: the shared helper must itself bind through `receiverBinding()`, and no listed scenario may mint its destination with `resolveRegistrationUrl`. Not witnessed against a host: no tunnelled cut was available, and no timeout or window was widened.
|
|
8
|
+
- **Two RFC 0213 requirement ids were cited from one `it()`, so the winner id was recorded on no host.** `v2-idempotency-in-flight.test.ts` cited `0213.in-flight-one-winner` and `0213.in-flight-loser-outcome` from a single `it`. The ledger keys on the id and `setup.ts` takes ONE `explicitId` per test, so only the last one cited got a row: the winner clause — "of concurrent same-key requests a host MUST process exactly one" — was measured on every host that ran the file and recorded on none of them, and nothing said so. Found by `check-req-only.mjs` rule (d) the moment it learned to resolve a `const` handed to `req()`, which is the same sharpening this cycle made for the attribution fix; the rule had compared only call-site literals, so the violation was invisible when the file was written. Split into two `it`s over ONE race: the record is in flight only while the winning create is being handled, so driving the race twice would measure two unrelated races and halve the chance that either overlaps, so it is driven once and memoised and both legs await the same result (the `v2-subject-link-record` pattern). No assertion, gate or timeout changed. The no-overlap soft-skip now lands on the loser id it is ABOUT, so the winner clause keeps its verdict instead of losing it to that return — a strictly better row on a host with no overlap, which is every host that answers create in milliseconds. That soft-skip's DISPOSITION is openwop#1525's, taken verbatim on merge: `inapplicable` (→ `executed-pass` + `partial-witness`), not `blocked`, because §B permits a loser to wait and receive a marked final outcome and `blocked` would deny certification to a host that did nothing wrong. The two changes compose — #1525 fixed what the row says, this one fixed which id says it.
|
|
9
|
+
- **A third fixed dedup key, and the source guard that would have caught it.** `trigger-stream-cdc-sources.test.ts` handed the bridge seam the literal `'events:3:99001'` and then asserted `deliveredCount === 1 || outcome === 'delivered'` — the identical defect as the bullet below, one file over, and it survived that sweep because NOTHING WAS CHECKING. §F.5 reuses §C-1's dedup window verbatim and that window is a >=24h FLOOR, so the second run of the file against the same host, any time that day, hands the bridge broker coordinates it has already delivered; a conformant host collapses the exercise into the first run's outcome and the assertion convicts it. Cold host passes, warm host fails. New `freshStreamDedupKey()` mints the OFFSET and keeps `(topic, partition)` — not `freshDedupKey`, because §F.5 says a stream event's key SHOULD derive from `(topic, partition, offset)` and the leg's own `req()` message asserts over exactly that keying; an opaque token would make the message describe something the call no longer does. The repetition §C-1 is about happens INSIDE the seam's `scenario: 'dedup'`, so the clause is untouched. New `src/lib/trigger-dedup-key.test.ts` pins it host-free and guards the CALL SITES, which is where a regression would reappear: no file that drives `driveDelivery` may spell a `dedupKey` as a literal. Sabotage, both spellings: reintroducing the literal as `dedupKey: 'events:3:99001'` (the original defect) and as `const dedupKey = 'events:3:99001'` each red the guard, naming the file and line. The first draft of that guard matched only the property form and the `const` sabotage PASSED it — the regex is one alternation over `[:=]` for that reason, recorded here because a guard that misses the spelling a reader would reach for is not a guard. Files that mention `dedupKey` without driving the seam are excluded deliberately: in `trigger-bridge-shape` it is a field in an offline AJV sample, durable nowhere.
|
|
10
|
+
- **The identity enumeration, re-run against the merged tree, and what it did NOT find.** Every other class was already per-exercise or is covered elsewhere, so no second mechanism is added for any of them: Layer-1 `Idempotency-Key`s are minted fresh at all four call sites (`idempotency`, `idempotencyRetry`, `v2-idempotency-key-grammar`, `pause-resume`); the whole host-surface family (`kv`, `blob`, `table`, `search`, `vector`, `sql`, `queueBus`) already mints its key / table / index / namespace / stream per exercise; `v2-subject-link-record` already mints its `externalId`; `replay-fanout-suppression` binds an EPHEMERAL port rather than the pinned one, so its destination is distinct by construction; and the scenario-owned A2A peers and MCP servers are `front-mux`'s (openwop#1520), which nonce-paths each fake behind the one public front. One row was rehearsed rather than fixed: `v2-webhook-message-id-stable` came back `executed-pass` on a loopback run against the PUBLISHED 2.37.0 with its `0201.message-id-stable` requirement row classified — no unclassified return. One run is not proof and the single scenario is being re-run before the public cut, but it is consistent with openwop#1513 and #1520 having already covered it, so NO mechanism is added for it here: not reproduced on 2.37.0, plausibly covered by `front-mux` / `effect-receiver`. **Owed, not fixed here:** `lib/oauth-as-double.ts` derives its ISSUER identity from `resolvePublicFront(frontEnv, local)`, and three files — `v2-oauth-mcp-reach-discovery`, `v2-oauth-client-pkce-state-iss`, `v2-credential-interrupt` — start a double on the same default `OPENWOP_OAUTH_AS_URL`, so on a tunnelled cut all three advertise ONE issuer and only the holder of the pinned port is reached. That is the front-mux shape for a fake front-mux does not cover, and it sits on the public-front routing another session is changing in the same cycle (openwop#1524, the per-instance `kid` in `lib/oidc-issuer.ts`), so it is stated rather than touched. Unreproduced: no tunnelled cut was available, so it is read off the code.
|
|
11
|
+
- **Two more fixed identities against state a host is REQUIRED to remember.** `trigger-bridge-delivery` handed the bridge the literal `'conformance-dedup-key'`, and `trigger-bridge.md` §C-1 makes the dedup window a >=24h FLOOR — so the second run of the file against the same host, any time that day, hands it a key already delivered; a conformant host collapses the exercise into the first run's outcome, emits zero `delivered` attempts under this run's id, and the `=== 1` convicts it. Cold host passes, warm host fails. `freshDedupKey()` mints one per exercise; the repetition §C-1 is about happens inside `driveDelivery`'s `scenario: 'dedup'`, so the clause is untouched. `auth-subject-link` provisioned the literal `externalId` `'idp-op-8f3a'` into the OPERATOR's SCIM directory and then DEACTIVATED it, so a second run asserts "authenticates before deactivation" against a subject the host is correct to refuse; minted per exercise now. The SCIM leg is opt-in on two env vars that no cut sets, so it is read off the code and unwitnessed — stated rather than claimed.
|
|
12
|
+
- **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.37.1` because `2.37.0` is published. Published on the `v2.37.1` corpus tag.
|
|
13
|
+
|
|
14
|
+
## [2.37.0] — 2026-09-23 — rows that measured nothing now measure: a suite fake reachable through the public front, one effect identity per leg, the MRTR key that can tell
|
|
15
|
+
|
|
16
|
+
- **Every synthetic OIDC issuer publishes its own `kid`, so two scenarios at one issuer URL no longer collide in a host's JWKS cache.** `lib/oidc-issuer.ts` generated a fresh key per `createSyntheticOIDCIssuer` instance but named it `openwop-conformance-key-${rotationCounter}` with a per-instance counter — so EVERY instance published `openwop-conformance-key-1`. `v2-oidc-id-token-audience` and `v2-lane-exp-only-bound` both publish at `OPENWOP_TEST_OIDC_ISSUER_URL`; a host that caches JWKS by `kid` (RFC 7517 §4.5: a kid identifies one key) verified the second scenario's tokens against the first scenario's cached key and refused its VALID control as `invalid_signature`. Reported and measured by openwop-app-ce on openwop-app, suite 2.36.1: `v2-lane-exp-only-bound` 4/4 red in a full run (control included), 4/4 green filtered — an order-dependent red that reads as a host failure on RFC 0210's rows. The kid is now `openwop-conformance-key-<per-instance hex>-<rotation>`. New self-test: a kid-caching verifier (re-fetch only on an unknown kid) must accept both instances' valid tokens at one URL; reverting to the shared kid reds it.
|
|
17
|
+
- **RFC 0211 — the A2A error-details legs, and an isolation comparison that could not fail.** `v2-a2a-operation-map`'s unknown-vs-foreign-tenant leg compared `Object.keys(error.data)`, which is `["0"]` for any one-element array once `data` follows A2A §9.5 — so it would have passed whatever the entry disclosed. It now compares the per-element normalised ErrorInfo (`@type`, `reason`, `domain`, sorted metadata keys, minus an echo of the requested id) behind a positive control that an ErrorInfo exists (`0211.a2a-unreadable-not-found-details`). Three new legs in the same file: `0211.a2a-error-data-shape` (unknown `GetTask` ⇒ `TASK_NOT_FOUND`, terminal `CancelTask` ⇒ `TASK_NOT_CANCELABLE`, each an `Any[]` with exactly one ErrorInfo in domain `a2a-protocol.org`), `0211.a2a-no-openwop-envelope` (non-JSON body, no credential, unknown method — none answered `{ error, message }`), `0211.a2a-version-not-supported-shape` (`-32009` not `-32600`, reason `VERSION_NOT_SUPPORTED`, `metadata.supportedVersions` when present a comma-joined subset of the card), and `0211.a2a-httpjson-status` (gated on an HTTP+JSON interface — `inapplicable` on every committed host, not claimed). New major-2 `src/scenarios/v2-a2a-client-error-details.test.ts` (`0211.a2a-client-reads-either-shape`, gated on `a2a` + the seams profile): the host's client projects a peer's `-32009` to `interop_version_unsupported` with `data` as `Any[]` and as the legacy object — weak by construction and said so (code identification succeeds either way). `A2AFakePeer.setLegacyErrorData()` serves the pre-2.37.0 object shape for that leg. New `src/lib/a2a-error-info.ts`.
|
|
18
|
+
- **A scenario's own A2A peer or MCP server is reachable through the operator's public front, and one RFC 0175 row that passed without measuring anything now cannot.** An operator fronts ONE listener per fixture — the tunnel behind `OPENWOP_A2A_FAKE_PEER_URL` / `OPENWOP_MCP_FAKE_SERVER_URL` forwards to the pinned `_PORT`, where `setup.ts` starts the shared fake. Six legs also construct a fake of their own (version-pinned: a `0.3`-only peer, a `2025-06-18`-only server, a fresh `1.0` peer), start it on an ephemeral port, and hand the host `hostFacingEndpoint()` — which returned the front verbatim. On every tunnelled cut the host's request therefore went to the SHARED fake while the leg read its own, untouched one. Measured on the v2 reference host: `0207.a2a-traceparent-carried` recorded `blocked` in all three public cuts of 2026-09-23 (\"the suite peer received no SendMessage\") — the host's A2A client HAD reached the suite, just not the listener counting (measured, not inferred: on a loopback run of the same host, where each fake's own address is handed over, the §22 seam's `SendMessage` reached the leg's peer carrying the trace in both `metadata.openwop.traceparent` and the header — the host has no carrier gap); the §23 MCP seam leg of the same file recorded the same (masked as `partial-witness`, because the row's other leg witnessed it). Loopback cuts never showed it, because each fake's local address is distinct. New `src/lib/front-mux.ts`, the shape openwop#1513 used for the effect receiver: every fake carries a nonce; a fake that does not own the pinned port is handed `${front}/fx/<nonce>`, and whichever fake does own it strips the prefix and dispatches to the registered instance (a request for a stopped fake is answered `404`, never counted). A2A cards built from `hostFacingEndpoint()` carry the nonce, so the host's RPC after the card returns to the same fake. `src/lib/front-mux.test.ts` simulates the tunnel against the pinned port; removing the routing reds 3 of its 4 cases.
|
|
19
|
+
**What this says about committed evidence, stated rather than fixed forward.** The only committed bundle that executed these legs is `evidence/v2-host-bundles/openwop-host-v2-reference.json` (suite 2.35.0, a PUBLIC cut, relaxations none), on which RFC 0175 is Accepted. On that cut: `0175.negotiation-authenticated.mcp` recorded `executed-pass` having **measured nothing** — that leg has no seam knob forcing the lower offer, so the only thing making the exchange \"unauthenticated and lower\" was the scenario's `2025-06-18`-only server, which the host never contacted; it negotiated with the shared server at `preferredVersion` and the assertion held trivially. It now records `blocked` when the seam reports success and the lower-revision server saw no request (`v2-negotiation-authenticated`; the A2A leg gets the same guard, though there `peerOffersOnly` did force the lower offer, so its decision half was measured). `0175.minimum-version-refused` and `.mcp` measured the host's refusal genuinely — `peerOffersOnly` / `requestVersion` force it on the host's own decision path — but their wire half (\"no below-floor call reached the peer\") iterated a peer the host never called, so it could not have failed. No guard is added there: that half is a MUST NOT, and a host that refuses without contacting the peer satisfies it; the routing is what makes a zero mean zero. `myndhyve.json` and `openwop-workflow-engine.json` record all four rows `inapplicable` (neither advertises `a2a` or `mcp`), so they are untouched. The reference host's next public cut on 2.37.0 is the re-measurement.
|
|
20
|
+
- **`0158.duplicate-delivery` is deterministic, and the two legs that shared one effect identity no longer do.** The row gave different verdicts on repeated runs of one host cut. `v2-durability-recovery.test.ts` and `v2-terminal-event-once.test.ts` both drive `mode=duplicate-delivery` on the RFC 0158 seam and both passed it `resolveRegistrationUrl(...)` — on a tunnelled cut the operator's `OPENWOP_WEBHOOK_RECEIVER_URL` verbatim, the same string for both. Same fixture, same node, same URL ⇒ same Layer-2 business identity (`idempotency.md` §"Layer 2 Keying"), so a conformant host resolved the second exercise to the first's recorded outcome and performed no invocation; whichever leg ran second read zero arrivals. New `src/lib/effect-receiver.ts` mints one destination per exercise with a nonce in its path and counts only the arrivals bearing it (`arrivals()` vs `foreign()`); both legs now take their receiver from it. The 0158 leg reads the host's effect ledger before classifying a zero and distinguishes *resolved against an already-recorded outcome* / *attempted and transport-failed (`released`)* / *never attempted*, recording `blocked` with the cause named in each case instead of `executed-fail` — a missed fire is not an exactly-once violation, and `blocked` still denies certification (RFC 0168 §E.1). The blind post-terminal sleep becomes a bounded wait for the one legitimate arrival plus the same quiet window for a second; no window was widened. Measured on the v2 reference host: 1/20 pass before, 20/20 after on the same warm host and pinned receiver port, and the row still records `executed-fail` (2 arrivals) when the host's duplicate-delivery guard is sabotaged. `src/lib/effect-receiver.test.ts` pins it host-free.
|
|
21
|
+
- **`v2-mcp-mount-map` gains `0208.mcp-mrtr-input-request-key`: the MRTR input-request key is the open interrupt's `interruptId`, never the node it suspended on.** `interop-map.json` `mcp.mrtr` InputRequiredResult (host as server) spells the key `<interruptId>`, and `mcp.tasks.status` repeats it per that same row; `v2-mcp-tasks` already pins it on `tasks/get` (keys equal to the run's `node.suspended` ids), but the `tools/call` path was measured only by `Object.keys(inputRequests)[0]` — so a host keying it by **node id** passed. The new leg starts TWO runs of `conformance-approval`, which suspend at the SAME node: the keys MUST differ, MUST each match the tenant-bound `interruptId` grammar from `schemas/v2/ids.schema.json` (an author-chosen `nodeId` has no tenant segment and can never match it), MUST each be an id that run's own `node.suspended` carries when `runList` is advertised, and MUST be the key the retry's `inputResponses` is read under. Same gate as the rest of the file (`mcp.serverMount` + profile `mcp-2026-07-28`), `blocked` naming `conformance-approval` when the fixture is not advertised. Major 2 only — `mcp-integration.md` §C.2 leaves the key to the host at major 1 and the v1 leg stays key-agnostic. Sabotage: against the v2 reference host before its fix the leg fails with `got ["gate","gate"]` while every other leg in the file passes.
|
|
22
|
+
|
|
23
|
+
- **Version moved ahead of publication, not with it.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.37.0` now, before any 2.37.0 content lands, because `2.36.1` is published and the published-identity gate refuses a tree whose shipped files differ from the tarball at the same version. The scenarios this minor adds are recorded here as they land. Published on the `v2.37.0` corpus tag.
|
|
24
|
+
|
|
3
25
|
## [2.36.1] — 2026-09-23 — a leg that convicted every host of a rule the spec does not have
|
|
4
26
|
|
|
5
27
|
- **`mcp-2026-07-28-discover` asserted the opposite of `mcp-integration.md` §D, and a fixture that no longer existed.** 2.36.0 repaged the suite's own fake MCP server — "`tools/list` paged 3 + 3 over six tools with `cacheScope: \"private\"` (was one unpaged `public` page)", as that release's own RFC 0204 entry records — and both halves of the `tools/list` leg were left describing the old fixture. The first asserted `cacheScope === 'public'`; §D says `cacheScope` MUST be `private` whenever a result depends on the caller's tenant, workspace, principal or authorization, "which for `tools/list` on a multi-tenant host is always", and permits `public` ONLY when the result is byte-identical for every caller. So the leg required the one value the spec forbids here, and **every** certify run went red on it — the fixture is suite-owned, so the failure is deterministic and host-independent: it was a false `executed-fail` on the record of any host that ran it. The second asserted a three-name catalogue (`echo`, `needs_input`, `needs_input_loop`) against a six-tool paged one whose first page is `echo`, `structured-echo`, `always-error` — the MRTR tool the leg is *named for* is on page two, so the leg no longer reached the thing it exists to check. It now asserts the deterministic first page, follows `nextCursor`, and asserts page two carries the read-only-claim tool plus both MRTR tools and advertises no further cursor. Sabotage: setting the fixture back to `cacheScope: 'public'` turns the row `executed-fail` again. Found by the Conformance Soak on the 2.36.0 tree, not by a host — the corpus gate had quarantined the leg locally, which is exactly how an assertion drifts from its fixture for a full release. No spec text, schema or host obligation changed; only the suite's reading of one it already had.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
|
|
12
12
|
# default resolver refuses. npm 10.9 fails outright with
|
|
13
13
|
# "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
|
|
14
|
-
npm install --legacy-peer-deps @openwop/openwop-conformance@2.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.37.1 @openwop/spec-artifacts@2.37.1
|
|
15
15
|
# or run without install:
|
|
16
16
|
npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
|
|
17
17
|
```
|
|
@@ -135,7 +135,8 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
135
135
|
|
|
136
136
|
## What's Covered
|
|
137
137
|
|
|
138
|
-
The current suite has
|
|
138
|
+
The current suite has 557 scenario files under `src/scenarios/`.
|
|
139
|
+
- 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
|
|
139
140
|
- 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.
|
|
140
141
|
- 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.
|
|
141
142
|
- 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.**
|
|
@@ -480,7 +481,7 @@ Server-required (added in 1.7.0):
|
|
|
480
481
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
481
482
|
| **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. |
|
|
482
483
|
|
|
483
|
-
Current source tree:
|
|
484
|
+
Current source tree: 557 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
484
485
|
|
|
485
486
|
## Remaining Gaps
|
|
486
487
|
|
package/coverage.md
CHANGED
|
@@ -258,6 +258,7 @@ Every OpenAPI operation should have:
|
|
|
258
258
|
| `resolveInterruptByToken` | `interrupt-token-matrix.test.ts` covers replay (already-resolved) + unknown token; `interrupt-external-event-correlation.test.ts` covers positive path | Replay path + unknown-token path covered with explicit assertions | Add wrong-action case once the host advertises a typed allowed-actions vocabulary in the interrupt manifest. |
|
|
259
259
|
| `getArtifact` | Indirect through approval payload fixtures | `route-coverage.test.ts` covers unknown artifact `404`/`403` envelope; `artifact-auth.test.ts` (CF-4 close-out 2026-05-15; SQLite host 401-before-404 stub landed 2026-05-19, closes the info-leak surface for every HTTP method) covers `401` unauthenticated path | Negative paths covered (401 + 405 non-GET + 404/403) | Add positive artifact-read scenario once a reference host implements `getArtifact` end-to-end. |
|
|
260
260
|
| `registerWebhook` | Webhook spec exists | `route-coverage.test.ts` covers invalid URL validation envelope | Add positive registration with a test receiver when harness support exists. |
|
|
261
|
+
| `rotateWebhookSecret` | None on v1 — no v1 host advertises `webhooks.secretRotation` yet; the v2 twin is covered behaviourally by `v2-webhook-secret-rotation.test.ts` (RFC 0201 §E.18) | `v1-webhook-rotation-contract.test.ts` (corpus coherence) — the v1 contract carries the route `spec/v1/webhooks.md` §Rotation mandates, with `webhooks:manage` on all three lanes, the 400/403/404 refusals the prose enumerates, and a 200 that returns no secret | A behavioural v1 leg needs a v1 host advertising `webhooks.secretRotation`; none does. |
|
|
261
262
|
| `unregisterWebhook` | Webhook spec exists | `route-coverage.test.ts` covers unknown subscription behavior | Add full register-then-unregister roundtrip with a test receiver. |
|
|
262
263
|
| `listPromptTemplates` | `prompt-template-shape.test.ts` + `prompt-list-and-fetch.test.ts` cover schema shape + advertisement contract + list/get contract for `capabilities.prompts.*` against the reference workflow-engine (RFC 0028 `Active` — endpoints live under `openwop-app:backend/typescript/src/routes/prompts.ts`) | Behavioral list + advertisement-shape covered | Add cross-host list-with-filter parity scenario when a second host advertises `endpointsSupported: true`. |
|
|
263
264
|
| `createPromptTemplate` | `prompt-mutable-lifecycle.test.ts` covers CRUD lifecycle against the reference workflow-engine (gated on `mutableLibrary: true`); user-source POST succeeds, pack + host-built-in templates return 403 | Positive create + readonly-source 403 path covered | Add explicit `409` duplicate-id scenario + auth/scope matrix scenarios. |
|
|
@@ -47,10 +47,26 @@ const journal = [];
|
|
|
47
47
|
* dispositions throws: RFC 0148 §A says **exactly one** disposition per
|
|
48
48
|
* requirement, and a silent last-write-wins would let a later soft-skip
|
|
49
49
|
* overwrite an earlier real failure — the failure mode in reverse.
|
|
50
|
+
*
|
|
51
|
+
* `extras.fold` is the one exception, for the per-`it` rows `setup.ts` records
|
|
52
|
+
* when several `it` legs witness ONE requirement id; see its docblock below.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* How certifiable each disposition is, least first. `readLedgerFile` has always
|
|
56
|
+
* resolved a cross-worker disagreement this way — "one worker said it failed"
|
|
57
|
+
* outranks "another said it passed", and an unresolvable disagreement must never
|
|
58
|
+
* round toward certification. `fold` below applies the SAME rule in-worker.
|
|
50
59
|
*/
|
|
60
|
+
const CERTIFIABILITY_RANK = {
|
|
61
|
+
'executed-fail': 0,
|
|
62
|
+
blocked: 1,
|
|
63
|
+
'executed-pass': 2,
|
|
64
|
+
skipped: 3,
|
|
65
|
+
inapplicable: 4,
|
|
66
|
+
};
|
|
51
67
|
export function recordRequirement(requirementId, disposition, detail, extras) {
|
|
52
68
|
const prior = ledger.get(requirementId);
|
|
53
|
-
if (prior !== undefined && prior.disposition !== disposition) {
|
|
69
|
+
if (prior !== undefined && prior.disposition !== disposition && extras?.fold !== true) {
|
|
54
70
|
throw new Error(`RFC 0148 §A: ${requirementId} already recorded as '${prior.disposition}', now '${disposition}'. ` +
|
|
55
71
|
'Exactly one disposition per requirement per run.');
|
|
56
72
|
}
|
|
@@ -58,7 +74,7 @@ export function recordRequirement(requirementId, disposition, detail, extras) {
|
|
|
58
74
|
throw new Error(`RFC 0148 §A: ${requirementId} recorded as '${disposition}' without a reason. ` +
|
|
59
75
|
'Anything other than executed-pass MUST say why, or the ledger records an outcome nobody can act on.');
|
|
60
76
|
}
|
|
61
|
-
|
|
77
|
+
let entry = {
|
|
62
78
|
requirementId,
|
|
63
79
|
disposition,
|
|
64
80
|
...(detail === undefined ? {} : { detail }),
|
|
@@ -66,6 +82,27 @@ export function recordRequirement(requirementId, disposition, detail, extras) {
|
|
|
66
82
|
...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
|
|
67
83
|
...(extras?.evidence === undefined || disposition !== 'executed-pass' ? {} : { evidence: extras.evidence }),
|
|
68
84
|
};
|
|
85
|
+
if (prior !== undefined && extras?.fold === true) {
|
|
86
|
+
// The least-certifiable leg wins the disposition and keeps its own detail;
|
|
87
|
+
// a tie keeps whichever side actually said something. Counts sum, because
|
|
88
|
+
// both legs really did assert against the target for this one requirement.
|
|
89
|
+
const keepPrior = CERTIFIABILITY_RANK[prior.disposition] <= CERTIFIABILITY_RANK[disposition];
|
|
90
|
+
const winner = keepPrior ? prior : entry;
|
|
91
|
+
const loser = keepPrior ? entry : prior;
|
|
92
|
+
const count = (prior.assertionCount ?? 0) + (extras?.assertionCount ?? 0);
|
|
93
|
+
const keptDetail = winner.detail ?? loser.detail;
|
|
94
|
+
const hasCount = prior.assertionCount !== undefined || extras?.assertionCount !== undefined;
|
|
95
|
+
entry = {
|
|
96
|
+
requirementId,
|
|
97
|
+
disposition: winner.disposition,
|
|
98
|
+
...(keptDetail === undefined ? {} : { detail: keptDetail }),
|
|
99
|
+
...(hasCount ? { assertionCount: count } : {}),
|
|
100
|
+
...(winner.scenarioFile === undefined ? {} : { scenarioFile: winner.scenarioFile }),
|
|
101
|
+
// `evidence` is only meaningful on a pass; a fold that lands anywhere
|
|
102
|
+
// else drops it, exactly as the constructor above does.
|
|
103
|
+
...(winner.disposition === 'executed-pass' && winner.evidence !== undefined ? { evidence: winner.evidence } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
69
106
|
ledger.set(requirementId, entry);
|
|
70
107
|
journal.push(entry);
|
|
71
108
|
// File sink (RFC 0148 acceptance item 2, S6). The in-memory map lives in a
|
|
@@ -114,7 +151,11 @@ export function readLedgerFile(path) {
|
|
|
114
151
|
if (typeof e.requirementId !== 'string' || !DISPOSITIONS.includes(e.disposition))
|
|
115
152
|
continue;
|
|
116
153
|
const prior = merged.get(e.requirementId);
|
|
117
|
-
|
|
154
|
+
// `<=`, not `<`: a per-`it` FOLD (2.37.0) appends the cumulative row after
|
|
155
|
+
// the leg rows it folded, so on an equal disposition the LAST line is the
|
|
156
|
+
// one carrying the summed `assertionCount`. For genuinely duplicate lines
|
|
157
|
+
// the two are identical and the choice is a no-op.
|
|
158
|
+
if (prior === undefined || rank[e.disposition] <= rank[prior.disposition])
|
|
118
159
|
merged.set(e.requirementId, e);
|
|
119
160
|
}
|
|
120
161
|
return [...merged.values()].sort((a, b) => a.requirementId.localeCompare(b.requirementId));
|
|
@@ -110,9 +110,13 @@ export function resolveItRecord(state, assertionCalls, gate, noted, firstError,
|
|
|
110
110
|
* closed), none an optional extra. Scoped to major 2 because the 146 v1-side
|
|
111
111
|
* sites were not measured and v1 bundles are read through its EOS.
|
|
112
112
|
*/
|
|
113
|
-
blockedStands = false
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
blockedStands = false,
|
|
114
|
+
/** The `it` title, so a failed row says WHICH leg of the requirement failed. */
|
|
115
|
+
testName) {
|
|
116
|
+
if (state === 'fail') {
|
|
117
|
+
const where = testName === undefined || testName.trim() === '' ? '' : ` in "${testName.slice(0, 120)}"`;
|
|
118
|
+
return { disposition: 'executed-fail', detail: `the test executed and failed${where}: ${(firstError ?? 'no message').slice(0, 300)}` };
|
|
119
|
+
}
|
|
116
120
|
if (state === 'pass' && assertionCalls > 0) {
|
|
117
121
|
// `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
|
|
118
122
|
// assertions are not the requirement, and the requirement went unobserved.
|
|
@@ -138,11 +142,34 @@ blockedStands = false) {
|
|
|
138
142
|
return { disposition: 'blocked', detail: 'unclassified return: the test passed with zero assertions and recorded no reason — RFC 0148 §A resolves it to blocked, never to a pass' };
|
|
139
143
|
return { disposition: 'skipped', detail: 'vitest skipped the test (ctx.skip / it.skip) without a recorded gate reason' };
|
|
140
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* The `executed-fail` detail for a file row: WHICH cases failed, and what the
|
|
147
|
+
* first one said.
|
|
148
|
+
*
|
|
149
|
+
* Until 2.37.0 this was the fixed string "one or more assertions in the file
|
|
150
|
+
* failed". A tier-2 host read exactly that for `v2-run-bulk-cancel` — 10
|
|
151
|
+
* assertions, no case name, no message, and nothing else in the record for that
|
|
152
|
+
* file — and had to hand-probe every assertion in the file against production to
|
|
153
|
+
* find out what had happened. A bundle row whose only detail is that sentence is
|
|
154
|
+
* undiagnosable by construction, and every future flicker in any scenario had
|
|
155
|
+
* the same problem.
|
|
156
|
+
*/
|
|
157
|
+
export function failureDetail(failures, failedCount) {
|
|
158
|
+
if (failures.length === 0) {
|
|
159
|
+
return `${failedCount} test(s) in the file failed; the runner captured no message`;
|
|
160
|
+
}
|
|
161
|
+
const head = failures[0];
|
|
162
|
+
const named = failures.slice(0, 3).map((f) => `"${f.name.slice(0, 120)}"`).join(', ');
|
|
163
|
+
const more = failures.length > 3 ? ` (+${failures.length - 3} more)` : '';
|
|
164
|
+
const msg = head.message === undefined || head.message.trim() === '' ? 'no message' : head.message.slice(0, 400);
|
|
165
|
+
return `${failures.length} test(s) failed — ${named}${more}; first failure: ${msg}`;
|
|
166
|
+
}
|
|
141
167
|
/** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
|
|
142
168
|
* the ONE disposition the file records. */
|
|
143
|
-
export function fileDisposition(states, gateReason, assertionCount) {
|
|
144
|
-
|
|
145
|
-
|
|
169
|
+
export function fileDisposition(states, gateReason, assertionCount, failures = []) {
|
|
170
|
+
const failed = states.filter((s) => s === 'fail').length;
|
|
171
|
+
if (failed > 0)
|
|
172
|
+
return { disposition: 'executed-fail', detail: failureDetail(failures, failed) };
|
|
146
173
|
if (states.some((s) => s === 'pass')) {
|
|
147
174
|
// A test that early-returned through `behaviorGate` is reported by vitest
|
|
148
175
|
// as a pass with zero assertions. When EVERY passing test in the file did
|
|
@@ -182,7 +209,9 @@ export function fileDisposition(states, gateReason, assertionCount) {
|
|
|
182
209
|
* - every test `ctx.skip()`ped ⇒ the file's noted reason if it wrote one
|
|
183
210
|
* BEFORE skipping (`ctx.skip()` throws), else `blocked` + the marker.
|
|
184
211
|
*/
|
|
185
|
-
export function resolveFileRecord(states, gateReason, assertionCount, noted, specCoherenceFile
|
|
212
|
+
export function resolveFileRecord(states, gateReason, assertionCount, noted, specCoherenceFile,
|
|
213
|
+
/** The failed cases, so an `executed-fail` row NAMES them (2.37.0). */
|
|
214
|
+
failures = []) {
|
|
186
215
|
// A scenario whose subject is the CORPUS, skipped because the published
|
|
187
216
|
// tarball does not bundle spec/v1/. RFC 0148 §A: `blocked` is defined over
|
|
188
217
|
// ADVERTISED BEHAVIOUR, and there is none here — nothing about the host was
|
|
@@ -196,7 +225,7 @@ export function resolveFileRecord(states, gateReason, assertionCount, noted, spe
|
|
|
196
225
|
&& assertionCount === 0) {
|
|
197
226
|
return { disposition: 'inapplicable', detail: SPEC_COHERENCE_DETAIL };
|
|
198
227
|
}
|
|
199
|
-
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
|
|
228
|
+
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount, failures);
|
|
200
229
|
if (disposition === 'executed-pass' && assertionCount === 0) {
|
|
201
230
|
if (noted !== null) {
|
|
202
231
|
disposition = noted.kind;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.37.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.37.1"
|
|
60
60
|
}
|
|
61
61
|
}
|