@openwop/openwop-conformance 2.38.0 → 2.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.39.1] — 2026-09-25 — the RFC 0214 delivery legs survive a tunnel, and each one leaves its own row
4
+
5
+ - **`v2-a2a-push-delivery` — every requirement now records a row, and the budgets fit a public front.** The 2.39.0 public push-on cut of the v2 reference host lost three legs (§A after-Delete, §B 3xx redirect, §D replay fork) to vitest's per-test timeout, not to an assertion — deliveries were arriving through the front (`no-openwop-signature` passed). Budgets now scale when `OPENWOP_WEBHOOK_RECEIVER_URL` is wired (arrival 60 s, settle 45 s, leg 240 s, fork leg 300 s; loopback unchanged), the #1537 precedent. Separately, `0214.a2a-push-delivery-authenticated` vanished from that bundle because its `it()` also cited `0214.a2a-push-no-openwop-signature` and the ledger keeps the LAST id an `it()` cites: the delivery is now made once and read by two legs, one id each, and when the delivery cannot be made the signature leg records `blocked` with the reason instead of disappearing.
6
+ - **`v2-lane-issuer-advertised` no longer fails a host whose anonymous lane omits `revocation`, as the schema and `identity.md` §2.2 allow.** openwop#1540 (2.38.0) made `revocation` optional on the `anonymous` lane, whose §2.2 row reads "—", but this scenario's `.members` leg still asserted `revocation` on every lane. A host that followed the schema and the prose therefore failed the published suite; openwop-app-ce measured it bumping openwop-app's pin. A "—" lane may now omit `revocation`; if it advertises one, it must still be a known member, so no new failure mode is added. `scripts/check-lane-revocation-rules.mjs` gains a fifth assertion, that the `.members` leg exempts the "—" lanes, which fails against the 2.38.0/2.39.0 scenario.
7
+
8
+ ## [2.39.0] — 2026-09-24 — an A2A push is measured at a receiver the suite owns
9
+
10
+ - **RFC 0214 — the push legs land, and a host that advertises `a2a.pushNotifications` is now measured, not just the one that refuses.** `v2-a2a-operation-map` gains three legs gated on `pushNotifications: true`: `0214.a2a-push-register-ssrf` (a non-https, private, loopback or link-local destination is not registered), `0214.a2a-push-config-isolation` (a config read under another tenant's task, another of the caller's tasks, or an unknown id is answered byte-identically; a foreign delete deletes nothing; delete is idempotent) and `0214.a2a-push-secrets-not-returned` (the registered token and credentials are absent from Create/Get/List, `GetTask`, the run snapshot and the event log). New scenario `v2-a2a-push-delivery` measures delivery at a SUITE-OWNED scoped receiver behind the operator's public front (the RFC 0158 receiver): `0214.a2a-push-delivery-authenticated` (§A/§C — at least one POST, `Authorization: {scheme} {credentials}`, `application/a2a+json`, a `StreamResponse` naming the task), `0214.a2a-push-no-openwop-signature`, `0214.a2a-push-no-redirect` (§B — a 307 target receives nothing), `0214.a2a-push-discard-on-delete` (§A, with a live-config positive control) and `0214.a2a-push-fork-no-push` (§D). Every leg suspends its task at INPUT_REQUIRED BEFORE registering, so none can pass on a push nobody owed; without a public https front the legs record `blocked` (a conforming host refuses a loopback destination), never a pass and never a relaxed guard. Stated limit: the delivery-time DNS re-resolve arm is host-unit-tested; the suite does not control the host's resolver.
11
+ - **Suite `2.39.0`**: 559 scenario files. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
12
+
3
13
  ## [2.38.0] — 2026-09-24 — two RFCs add legs, and six rows that could not pass on a conforming host now can
4
14
 
5
15
  - **Two rows the 2026-09-24 public v2-reference cut could not explain now say why, and one leg gets the time a tunnel needs.** `v2-mrtr-rounds-ceiling`'s `.refused` leg recorded only `expected 400 to be 422` on that cut, but passes on every loopback run of the host. Its failure message now carries the host's error code, how many `tools/call` the suite's MCP fake served, and the response body. Those facts separate "the host refused a round the fake answered" from "a round the tunnel failed to carry". `v2-oauth-client-pkce-state-iss`'s state leg gets a 120 s per-test timeout instead of 30 s: it makes eight sequential round trips through the AS double's tunnel, and on that cut it alone timed out while its shorter sibling legs passed. No assertion, requirement or window changes.
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.38.0 @openwop/spec-artifacts@2.38.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.39.1 @openwop/spec-artifacts@2.39.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,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 558 scenario files under `src/scenarios/`.
138
+ The current suite has 559 scenario files under `src/scenarios/`.
139
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.
140
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.
141
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.
@@ -481,7 +481,7 @@ Server-required (added in 1.7.0):
481
481
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
482
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. |
483
483
 
484
- Current source tree: 558 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
484
+ Current source tree: 559 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
485
 
486
486
  ## Remaining Gaps
487
487
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.38.0",
4
- "stampSha256": "ad88f149547eb38bb873fa63e1d18e06e8810e6427d61a72fac94c32c5e28603"
3
+ "version": "2.39.1",
4
+ "stampSha256": "d6c870ac95f4efeb1c666248d53ec1c29b55604cf16f1009da4471d1414b8ff6"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.38.0",
3
+ "version": "2.39.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.38.0"
59
+ "@openwop/spec-artifacts": "2.39.1"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,9 +2,9 @@
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": 632,
6
- "tests": 2415,
7
- "withStableId": 2415,
5
+ "files": 633,
6
+ "tests": 2423,
7
+ "withStableId": 2423,
8
8
  "interpolatedTitles": 0,
9
9
  "explicitIds": 2304
10
10
  },
@@ -26602,7 +26602,7 @@
26602
26602
  {
26603
26603
  "id": "openwop.it.v2-a2a-operation-map.a-message-carrying-taskid-without-contextid-is-answered-with-the-task-s-contexti",
26604
26604
  "file": "v2-a2a-operation-map.test.ts",
26605
- "line": 135,
26605
+ "line": 136,
26606
26606
  "title": "a message carrying taskId without contextId is answered with the task's contextId",
26607
26607
  "explicitId": null,
26608
26608
  "citations": [
@@ -26633,7 +26633,7 @@
26633
26633
  {
26634
26634
  "id": "openwop.it.v2-a2a-operation-map.a-message-whose-contextid-is-not-its-task-s-is-refused-invalid-parameters-and-ch",
26635
26635
  "file": "v2-a2a-operation-map.test.ts",
26636
- "line": 149,
26636
+ "line": 150,
26637
26637
  "title": "a message whose contextId is not its task's is refused invalid-parameters and changes nothing",
26638
26638
  "explicitId": null,
26639
26639
  "citations": [
@@ -26660,7 +26660,7 @@
26660
26660
  {
26661
26661
  "id": "openwop.it.v2-a2a-operation-map.a-message-to-a-retained-terminal-task-is-refused-unsupportedoperationerror-and-a",
26662
26662
  "file": "v2-a2a-operation-map.test.ts",
26663
- "line": 164,
26663
+ "line": 165,
26664
26664
  "title": "a message to a retained terminal task is refused UnsupportedOperationError, and appends no event",
26665
26665
  "explicitId": null,
26666
26666
  "citations": [
@@ -26687,7 +26687,7 @@
26687
26687
  {
26688
26688
  "id": "openwop.it.v2-a2a-operation-map.an-unknown-task-and-another-tenant-s-task-are-answered-by-the-same-tasknotfounde",
26689
26689
  "file": "v2-a2a-operation-map.test.ts",
26690
- "line": 179,
26690
+ "line": 180,
26691
26691
  "title": "an unknown task and another tenant's task are answered by the same TaskNotFoundError",
26692
26692
  "explicitId": null,
26693
26693
  "citations": [
@@ -26720,7 +26720,7 @@
26720
26720
  {
26721
26721
  "id": "openwop.it.v2-a2a-operation-map.task-status-state-is-the-map-s-projection-of-the-run-status-at-every-observed-st",
26722
26722
  "file": "v2-a2a-operation-map.test.ts",
26723
- "line": 201,
26723
+ "line": 202,
26724
26724
  "title": "Task.status.state is the map's projection of the run status at every observed state",
26725
26725
  "explicitId": null,
26726
26726
  "citations": [
@@ -26743,7 +26743,7 @@
26743
26743
  {
26744
26744
  "id": "openwop.it.v2-a2a-operation-map.the-json-rpc-error-codes-the-suite-can-cause-are-the-a2a-1-0-1-codes",
26745
26745
  "file": "v2-a2a-operation-map.test.ts",
26746
- "line": 226,
26746
+ "line": 227,
26747
26747
  "title": "the JSON-RPC error codes the suite can cause are the A2A 1.0.1 codes",
26748
26748
  "explicitId": null,
26749
26749
  "citations": [
@@ -26766,7 +26766,7 @@
26766
26766
  {
26767
26767
  "id": "openwop.it.v2-a2a-operation-map.a-row-whose-requires-facet-is-not-advertised-is-refused-with-the-row-s-error",
26768
26768
  "file": "v2-a2a-operation-map.test.ts",
26769
- "line": 240,
26769
+ "line": 241,
26770
26770
  "title": "a row whose requires facet is not advertised is refused with the row's error",
26771
26771
  "explicitId": null,
26772
26772
  "citations": [
@@ -26784,7 +26784,7 @@
26784
26784
  {
26785
26785
  "id": "openwop.it.v2-a2a-operation-map.a-host-not-advertising-a2a-pushnotifications-refuses-all-four-push-config-operat",
26786
26786
  "file": "v2-a2a-operation-map.test.ts",
26787
- "line": 253,
26787
+ "line": 254,
26788
26788
  "title": "a host not advertising a2a.pushNotifications refuses all four push-config operations with PushNotificationNotSupportedError",
26789
26789
  "explicitId": null,
26790
26790
  "citations": [
@@ -26805,10 +26805,106 @@
26805
26805
  }
26806
26806
  ]
26807
26807
  },
26808
+ {
26809
+ "id": "openwop.it.v2-a2a-operation-map.rfc-0214-createtaskpushnotificationconfig-refuses-a-non-https-or-private-destina",
26810
+ "file": "v2-a2a-operation-map.test.ts",
26811
+ "line": 285,
26812
+ "title": "RFC 0214 — CreateTaskPushNotificationConfig refuses a non-https or private destination",
26813
+ "explicitId": null,
26814
+ "citations": [
26815
+ {
26816
+ "section": "spec/v2/core/interop.md §\"The operation mappings\" (RFC 0208)",
26817
+ "requirement": null,
26818
+ "interpolated": true
26819
+ },
26820
+ {
26821
+ "section": null,
26822
+ "requirement": null,
26823
+ "interpolated": true
26824
+ },
26825
+ {
26826
+ "section": null,
26827
+ "requirement": null,
26828
+ "interpolated": true
26829
+ }
26830
+ ]
26831
+ },
26832
+ {
26833
+ "id": "openwop.it.v2-a2a-operation-map.rfc-0214-e-a-push-config-of-another-tenant-s-task-of-another-task-or-unknown-is",
26834
+ "file": "v2-a2a-operation-map.test.ts",
26835
+ "line": 303,
26836
+ "title": "RFC 0214 §E — a push config of another tenant's task, of another task, or unknown, is answered identically",
26837
+ "explicitId": null,
26838
+ "citations": [
26839
+ {
26840
+ "section": "spec/v2/core/interop.md §\"The operation mappings\" (RFC 0208)",
26841
+ "requirement": "SendMessage MUST start two tasks with tenant-bound ids"
26842
+ },
26843
+ {
26844
+ "section": null,
26845
+ "requirement": null,
26846
+ "interpolated": true
26847
+ },
26848
+ {
26849
+ "section": null,
26850
+ "requirement": null,
26851
+ "interpolated": true
26852
+ },
26853
+ {
26854
+ "section": null,
26855
+ "requirement": null,
26856
+ "interpolated": true
26857
+ },
26858
+ {
26859
+ "section": null,
26860
+ "requirement": null,
26861
+ "interpolated": true
26862
+ },
26863
+ {
26864
+ "section": null,
26865
+ "requirement": null,
26866
+ "interpolated": true
26867
+ },
26868
+ {
26869
+ "section": null,
26870
+ "requirement": "a delete naming another tenant's task MUST NOT delete the caller's config",
26871
+ "interpolated": true
26872
+ },
26873
+ {
26874
+ "section": "A2A v1.0.1 §3.1.10 (\"MUST be idempotent\")",
26875
+ "requirement": null,
26876
+ "interpolated": true
26877
+ }
26878
+ ]
26879
+ },
26880
+ {
26881
+ "id": "openwop.it.v2-a2a-operation-map.rfc-0214-a-a-push-config-s-token-and-credentials-are-never-returned-and-never-re",
26882
+ "file": "v2-a2a-operation-map.test.ts",
26883
+ "line": 347,
26884
+ "title": "RFC 0214 §A — a push config's token and credentials are never returned, and never reach task, events or state",
26885
+ "explicitId": null,
26886
+ "citations": [
26887
+ {
26888
+ "section": "spec/v2/core/interop.md §\"The operation mappings\" (RFC 0208)",
26889
+ "requirement": null,
26890
+ "interpolated": true
26891
+ },
26892
+ {
26893
+ "section": null,
26894
+ "requirement": null,
26895
+ "interpolated": true
26896
+ },
26897
+ {
26898
+ "section": null,
26899
+ "requirement": null,
26900
+ "interpolated": true
26901
+ }
26902
+ ]
26903
+ },
26808
26904
  {
26809
26905
  "id": "openwop.it.v2-a2a-operation-map.listtasks-returns-only-what-listruns-returns-to-the-same-subject-tenant-and-cont",
26810
26906
  "file": "v2-a2a-operation-map.test.ts",
26811
- "line": 280,
26907
+ "line": 381,
26812
26908
  "title": "ListTasks returns only what listRuns returns to the same Subject; tenant and contextId never select",
26813
26909
  "explicitId": null,
26814
26910
  "citations": [
@@ -26845,7 +26941,7 @@
26845
26941
  {
26846
26942
  "id": "openwop.it.v2-a2a-operation-map.an-a2a-error-s-data-is-an-array-carrying-one-errorinfo-with-the-map-row-s-reason",
26847
26943
  "file": "v2-a2a-operation-map.test.ts",
26848
- "line": 313,
26944
+ "line": 414,
26849
26945
  "title": "an A2A error's data is an array carrying one ErrorInfo with the map row's reason and the A2A domain",
26850
26946
  "explicitId": null,
26851
26947
  "citations": [
@@ -26879,7 +26975,7 @@
26879
26975
  {
26880
26976
  "id": "openwop.it.v2-a2a-operation-map.no-response-on-the-card-s-json-rpc-interface-url-is-the-openwop-error-envelope",
26881
26977
  "file": "v2-a2a-operation-map.test.ts",
26882
- "line": 334,
26978
+ "line": 435,
26883
26979
  "title": "no response on the card's JSON-RPC interface URL is the OpenWOP error envelope",
26884
26980
  "explicitId": null,
26885
26981
  "citations": [
@@ -26897,7 +26993,7 @@
26897
26993
  {
26898
26994
  "id": "openwop.it.v2-a2a-operation-map.versionnotsupportederror-carries-errorinfo-version-not-supported-and-supportedve",
26899
26995
  "file": "v2-a2a-operation-map.test.ts",
26900
- "line": 354,
26996
+ "line": 455,
26901
26997
  "title": "VersionNotSupportedError carries ErrorInfo VERSION_NOT_SUPPORTED, and supportedVersions is a comma-joined subset of the card",
26902
26998
  "explicitId": null,
26903
26999
  "citations": [
@@ -26921,7 +27017,7 @@
26921
27017
  {
26922
27018
  "id": "openwop.it.v2-a2a-operation-map.an-http-json-interface-answers-an-a2a-error-with-google-rpc-status-carrying-the",
26923
27019
  "file": "v2-a2a-operation-map.test.ts",
26924
- "line": 372,
27020
+ "line": 473,
26925
27021
  "title": "an HTTP+JSON interface answers an A2A error with google.rpc.Status carrying the ErrorInfo",
26926
27022
  "explicitId": null,
26927
27023
  "citations": [
@@ -26940,7 +27036,7 @@
26940
27036
  {
26941
27037
  "id": "openwop.it.v2-a2a-operation-map.a-run-suspended-on-a-credential-interrupt-projects-to-task-state-auth-required-n",
26942
27038
  "file": "v2-a2a-operation-map.test.ts",
26943
- "line": 387,
27039
+ "line": 488,
26944
27040
  "title": "a run suspended on a credential interrupt projects to TASK_STATE_AUTH_REQUIRED, naming the provider and carrying connectUrl",
26945
27041
  "explicitId": null,
26946
27042
  "citations": [
@@ -26973,6 +27069,128 @@
26973
27069
  }
26974
27070
  ]
26975
27071
  },
27072
+ {
27073
+ "id": "openwop.it.v2-a2a-push-delivery.a-c-a-transition-after-registration-is-posted-to-the-registered-destination-with",
27074
+ "file": "v2-a2a-push-delivery.test.ts",
27075
+ "line": 189,
27076
+ "title": "§A/§C — a transition after registration is POSTed to the registered destination with the registered Authorization",
27077
+ "explicitId": null,
27078
+ "citations": [
27079
+ {
27080
+ "section": null,
27081
+ "requirement": null,
27082
+ "interpolated": true
27083
+ },
27084
+ {
27085
+ "section": "A2A v1.0.1 §4.3.3",
27086
+ "requirement": "a push is an HTTP POST"
27087
+ },
27088
+ {
27089
+ "section": null,
27090
+ "requirement": "the push MUST carry the registered credential as Authorization: {scheme} {credentials}",
27091
+ "interpolated": true
27092
+ },
27093
+ {
27094
+ "section": "A2A v1.0.1 §4.3.3",
27095
+ "requirement": "the push body is application/a2a+json"
27096
+ },
27097
+ {
27098
+ "section": "A2A v1.0.1 §4.3.3 (StreamResponse)",
27099
+ "requirement": null,
27100
+ "interpolated": true
27101
+ }
27102
+ ]
27103
+ },
27104
+ {
27105
+ "id": "openwop.it.v2-a2a-push-delivery.c-an-a2a-push-carries-no-openwop-signature",
27106
+ "file": "v2-a2a-push-delivery.test.ts",
27107
+ "line": 203,
27108
+ "title": "§C — an A2A push carries no OpenWOP signature",
27109
+ "explicitId": null,
27110
+ "citations": [
27111
+ {
27112
+ "section": "spec/v2/core/interop.md §\"A2A push delivery\" (RFC 0214)",
27113
+ "requirement": "positive control: at least one push arrived to read headers from"
27114
+ },
27115
+ {
27116
+ "section": null,
27117
+ "requirement": "an A2A push MUST NOT carry an OpenWOP-Signature — A2A defines no secret exchange to verify it",
27118
+ "interpolated": true
27119
+ }
27120
+ ]
27121
+ },
27122
+ {
27123
+ "id": "openwop.it.v2-a2a-push-delivery.b-a-3xx-from-the-destination-is-a-failed-delivery-the-redirect-target-receives-n",
27124
+ "file": "v2-a2a-push-delivery.test.ts",
27125
+ "line": 215,
27126
+ "title": "§B — a 3xx from the destination is a failed delivery: the redirect target receives nothing, credential or not",
27127
+ "explicitId": null,
27128
+ "citations": [
27129
+ {
27130
+ "section": "spec/v2/core/interop.md §\"A2A push delivery\" (RFC 0214)",
27131
+ "requirement": null,
27132
+ "interpolated": true
27133
+ },
27134
+ {
27135
+ "section": "spec/v2/core/interop.md §\"A2A push delivery\" (RFC 0214)",
27136
+ "requirement": null,
27137
+ "interpolated": true
27138
+ },
27139
+ {
27140
+ "section": null,
27141
+ "requirement": "the redirect target MUST receive nothing: a 3xx is a delivery failure and the credential MUST NOT be sent after a redirect",
27142
+ "interpolated": true
27143
+ }
27144
+ ]
27145
+ },
27146
+ {
27147
+ "id": "openwop.it.v2-a2a-push-delivery.a-after-delete-no-further-push-reaches-that-destination-while-a-live-config-on-t",
27148
+ "file": "v2-a2a-push-delivery.test.ts",
27149
+ "line": 237,
27150
+ "title": "§A — after Delete no further push reaches that destination, while a live config on the same task still receives",
27151
+ "explicitId": null,
27152
+ "citations": [
27153
+ {
27154
+ "section": "spec/v2/core/interop.md §\"A2A push delivery\" (RFC 0214)",
27155
+ "requirement": null,
27156
+ "interpolated": true
27157
+ },
27158
+ {
27159
+ "section": "spec/v2/core/interop.md §\"A2A push delivery\" (RFC 0214)",
27160
+ "requirement": null,
27161
+ "interpolated": true
27162
+ },
27163
+ {
27164
+ "section": "spec/v2/core/interop.md §\"A2A push delivery\" (RFC 0214)",
27165
+ "requirement": null,
27166
+ "interpolated": true
27167
+ },
27168
+ {
27169
+ "section": null,
27170
+ "requirement": "a deleted config MUST receive nothing further — its destination credential is discarded with it",
27171
+ "interpolated": true
27172
+ }
27173
+ ]
27174
+ },
27175
+ {
27176
+ "id": "openwop.it.v2-a2a-push-delivery.d-a-replay-fork-of-a-pushed-run-delivers-nothing-to-the-source-run-s-destination",
27177
+ "file": "v2-a2a-push-delivery.test.ts",
27178
+ "line": 263,
27179
+ "title": "§D — a replay fork of a pushed run delivers nothing to the source run's destination",
27180
+ "explicitId": null,
27181
+ "citations": [
27182
+ {
27183
+ "section": "spec/v2/core/interop.md §\"A2A push delivery\" (RFC 0214)",
27184
+ "requirement": null,
27185
+ "interpolated": true
27186
+ },
27187
+ {
27188
+ "section": null,
27189
+ "requirement": null,
27190
+ "interpolated": true
27191
+ }
27192
+ ]
27193
+ },
26976
27194
  {
26977
27195
  "id": "openwop.it.v2-a2ui-v09-surface.a-version-2-envelope-carrying-a-version-1-body-is-refused-the-positive-is-admitt",
26978
27196
  "file": "v2-a2ui-v09-surface.test.ts",
@@ -29438,7 +29656,7 @@
29438
29656
  {
29439
29657
  "id": "openwop.it.v2-lane-issuer-advertised.a-windowed-revocation-rule-advertises-revocationwindowseconds",
29440
29658
  "file": "v2-lane-issuer-advertised.test.ts",
29441
- "line": 85,
29659
+ "line": 93,
29442
29660
  "title": "a windowed revocation rule advertises revocationWindowSeconds",
29443
29661
  "explicitId": "openwop.requirement.0170.lane-issuer-advertised.window",
29444
29662
  "citations": [
@@ -29452,7 +29670,7 @@
29452
29670
  {
29453
29671
  "id": "openwop.it.v2-lane-issuer-advertised.a-lane-advertises-only-a-revocation-rule-its-identity-md-2-2-row-lists",
29454
29672
  "file": "v2-lane-issuer-advertised.test.ts",
29455
- "line": 96,
29673
+ "line": 104,
29456
29674
  "title": "a lane advertises only a revocation rule its identity.md §2.2 row lists",
29457
29675
  "explicitId": "openwop.requirement.0170.lane-issuer-advertised.lane-rule",
29458
29676
  "citations": [
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
3
3
  "counts": {
4
- "files": 558,
4
+ "files": 559,
5
5
  "v1": 449,
6
- "v2": 118
6
+ "v2": 119
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1260,6 +1260,9 @@
1260
1260
  "v2-a2a-operation-map.test.ts": [
1261
1261
  2
1262
1262
  ],
1263
+ "v2-a2a-push-delivery.test.ts": [
1264
+ 2
1265
+ ],
1263
1266
  "v2-a2ui-v09-surface.test.ts": [
1264
1267
  2
1265
1268
  ],
@@ -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.38.0",
5
- "corpusTag": "v2.38.0",
4
+ "version": "2.39.1",
5
+ "corpusTag": "v2.39.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": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "4d7a6a93b84ad4d8602021fb1417c1436b168002d9e9811079cdd8f42af1f68e",
13
- "api/v2/asyncapi.yaml": "c8bc549b600c858a97ec7f9638ab89bdae08d4e5adf9dd78f8ce412921b2a7e0",
14
- "api/v2/openapi.yaml": "e36c19ccb3d107b3afb8b45c5107a5d9ac56e6e6c23202d83d3bed14518825b8",
12
+ "api/seams-v2.yaml": "edff974e28c055f4d3f605078a32315475053cf4aeb4565f461ac6fe65462bdb",
13
+ "api/v2/asyncapi.yaml": "8dc0060855a0edf49a7f235016657f86ae3ba2d04bed99d0d9579d4f01f65c11",
14
+ "api/v2/openapi.yaml": "5c673efed4d9c350882b85d88f6c8e934793a2238b2f316ba66278ba7a75d121",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,7 +204,7 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "30feee3e0480c4102801c55e49830974e021be2284f01171e1327c8ac3e56e87",
207
+ "spec/v1/core-standard-manifest.json": "ffb07755718c26e734e30cd674469d232bdd9fca4e8743886090ab4a81df90a5",
208
208
  "spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -292,9 +292,9 @@
292
292
  "spec/v2/path-manifest.json": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
293
293
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
294
294
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
295
- "spec/v2/release.json": "602da823b9231be131b63eb8a2570958a7a90ab0d64a472e5927b47ec6ed2a35",
295
+ "spec/v2/release.json": "c1c2d051b65444c959464ce385bf9851f3438a72a17a03405fc492421d454114",
296
296
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
297
- "spec/v2/surface-baseline.json": "3f375c183236e2b5cf50029a0d998101c4a3c3766db50f940f50f7039a81ef3f"
297
+ "spec/v2/surface-baseline.json": "648c5e22fea9eaae6c900d7de1656209af0b5a8f5fa76b64885c0e6ab7cc3089"
298
298
  },
299
- "corpusCommit": "d9ac404fd5724f9baf2adc568ee3b7f7e03a0347"
299
+ "corpusCommit": "64073df2c4d11a5b8a165ead4281d2557495e25b"
300
300
  }
@@ -39,9 +39,10 @@ import { softSkip } from '../lib/soft-skip.js';
39
39
  import { SCHEMAS_DIR } from '../lib/paths.js';
40
40
  import { req } from '../lib/requirement-ids.js';
41
41
  import { SEAMS_PREFIX } from '../lib/seams.js';
42
+ import { unservedDestination } from '../lib/scoped-receiver.js';
42
43
  import { errorInfos, normaliseErrorData, isOpenwopEnvelope, A2A_ERROR_DOMAIN } from '../lib/a2a-error-info.js';
43
44
 
44
- export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own card lists; nothing harness-hosted is handed to the host';
45
+ export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own card lists; the RFC 0214 push-config legs register an UNSERVED destination (lib/scoped-receiver unservedDestination) whose deliveries are neither required nor read — delivery is v2-a2a-push-delivery\'s claim';
45
46
 
46
47
  const DOC = 'spec/v2/core/interop.md §"The operation mappings" (RFC 0208)';
47
48
  const PROFILE = 'a2a-1.0';
@@ -277,6 +278,106 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
277
278
  await rpc(t.url, 'CancelTask', { id });
278
279
  });
279
280
 
281
+ // ── RFC 0214 — push-config legs on a host that ADVERTISES a2a.pushNotifications ──
282
+ // Delivery itself (credential at the destination, no redirect, discard, fork) needs
283
+ // a public receiver and lives in v2-a2a-push-delivery.test.ts; these three need none.
284
+
285
+ it('RFC 0214 — CreateTaskPushNotificationConfig refuses a non-https or private destination', async () => {
286
+ const rid = 'openwop.requirement.0214.a2a-push-register-ssrf';
287
+ const doc = 'interop-map.json a2a.operations CreateTaskPushNotificationConfig ("the url MUST pass the webhooks.md egress guard"); webhooks.md §Egress; RFC 0214 §B';
288
+ const t = await target(true);
289
+ if (!t.ok) return skip(t, rid);
290
+ const a2a = (await familyAdvertised('a2a'))!;
291
+ if (a2a['pushNotifications'] !== true) return softSkip('inapplicable', 'a2a.pushNotifications is not advertised — the refusal row (a2a-push-unadvertised-refused) applies instead');
292
+ const first = await startApprovalTask(t.url);
293
+ const id = first.task?.id;
294
+ expect(typeof id, req(rid, DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
295
+ for (const bad of ['http://push.example.com/openwop-conformance', 'https://10.0.0.1/openwop-conformance', 'https://127.0.0.1/openwop-conformance', 'https://169.254.169.254/latest/meta-data']) {
296
+ const r = await rpc(t.url, 'CreateTaskPushNotificationConfig', { taskId: id, url: bad });
297
+ expect(r.result, req(rid, doc, `a push destination the egress guard refuses (${bad}) MUST NOT be registered (got result ${JSON.stringify(r.result)})`)).toBeUndefined();
298
+ expect(typeof r.error?.code, req(rid, doc, `the refusal of ${bad} MUST be a JSON-RPC error (got ${JSON.stringify(r)})`)).toBe('number');
299
+ }
300
+ await rpc(t.url, 'CancelTask', { id });
301
+ });
302
+
303
+ it('RFC 0214 §E — a push config of another tenant\'s task, of another task, or unknown, is answered identically', async () => {
304
+ const rid = 'openwop.requirement.0214.a2a-push-config-isolation';
305
+ const doc = 'interop.md §"A2A push delivery" (RFC 0214 §E); interop.md §"The operation mappings" Isolation';
306
+ const t = await target(true);
307
+ if (!t.ok) return skip(t, rid);
308
+ const a2a = (await familyAdvertised('a2a'))!;
309
+ if (a2a['pushNotifications'] !== true) return softSkip('inapplicable', 'a2a.pushNotifications is not advertised — no push config can exist to isolate');
310
+ const one = (await startApprovalTask(t.url)).task?.id;
311
+ const two = (await startApprovalTask(t.url)).task?.id;
312
+ expect(typeof one === 'string' && typeof two === 'string' && one.includes('/'), req(rid, DOC, 'SendMessage MUST start two tasks with tenant-bound ids')).toBe(true);
313
+ const dest = unservedDestination('https://example.com/openwop-conformance-push');
314
+ const created = await rpc(t.url, 'CreateTaskPushNotificationConfig', { taskId: one, url: dest.url });
315
+ const cfg = (created.result ?? {}) as { id?: string };
316
+ if (created.error !== undefined && !dest.tunnelled) {
317
+ await rpc(t.url, 'CancelTask', { id: one }); await rpc(t.url, 'CancelTask', { id: two });
318
+ return softSkip('blocked', `Create was refused for ${dest.url} and no public front (OPENWOP_WEBHOOK_RECEIVER_URL) is wired — the isolation leg needs one config to exist (${JSON.stringify(created.error)})`);
319
+ }
320
+ expect(typeof cfg.id, req(rid, doc, `positive control: Create on the caller's own task with a public destination MUST return a config id (got ${JSON.stringify(created.error ?? created.result)})`)).toBe('string');
321
+ const own = await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: one, id: cfg.id });
322
+ expect(own.error, req(rid, doc, `positive control: the caller MUST read its own config back (got ${JSON.stringify(own.error)})`)).toBeUndefined();
323
+ const opaque = one!.slice(one!.indexOf('/') + 1);
324
+ const foreignTask = `zz-conformance-foreign/${opaque}`;
325
+ const unknownId = `pnc-${randomBytes(12).toString('hex')}`;
326
+ const answers: Array<[string, Rpc, string]> = [
327
+ ['unknown config id', await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: one, id: unknownId }), unknownId],
328
+ ['config id under another of the caller\'s tasks', await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: two, id: cfg.id }), cfg.id!],
329
+ ['config id under another tenant\'s task segment', await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: foreignTask, id: cfg.id }), cfg.id!],
330
+ ];
331
+ const shape = (r: Rpc, echo: string): string => JSON.stringify({ status: r.status, code: r.error?.code, message: (r.error?.message ?? '').split(echo).join('<id>'), data: normaliseErrorData(r.error?.data, echo), result: r.result });
332
+ const base = shape(answers[0]![1], answers[0]![2]);
333
+ expect(answers[0]![1].error?.code, req(rid, doc, `an unknown config id MUST be refused (got ${JSON.stringify(answers[0]![1].result)})`)).toBeTypeOf('number');
334
+ for (const [what, r, echo] of answers.slice(1)) {
335
+ expect(shape(r, echo), req(rid, doc, `a read of a ${what} MUST be answered exactly as an unknown id — same status, code, message and error details (minus an echo of the requested id)`)).toBe(base);
336
+ }
337
+ const foreignDelete = await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: foreignTask, id: cfg.id });
338
+ expect(foreignDelete.result === undefined || JSON.stringify(foreignDelete.result) === '{}', req(rid, doc, `a delete naming another tenant's task MUST NOT disclose anything beyond the idempotent answer (got ${JSON.stringify(foreignDelete)})`)).toBe(true);
339
+ const still = await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: one, id: cfg.id });
340
+ expect(still.error, req(rid, doc, 'a delete naming another tenant\'s task MUST NOT delete the caller\'s config')).toBeUndefined();
341
+ await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: one, id: cfg.id });
342
+ const again = await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: one, id: cfg.id });
343
+ expect(again.error, req(rid, 'A2A v1.0.1 §3.1.10 ("MUST be idempotent")', `a second delete of the same config MUST succeed idempotently (got ${JSON.stringify(again.error)})`)).toBeUndefined();
344
+ await rpc(t.url, 'CancelTask', { id: one }); await rpc(t.url, 'CancelTask', { id: two });
345
+ });
346
+
347
+ it('RFC 0214 §A — a push config\'s token and credentials are never returned, and never reach task, events or state', async () => {
348
+ const rid = 'openwop.requirement.0214.a2a-push-secrets-not-returned';
349
+ const doc = 'interop-map.json a2a.operations push rows ("secrets are never returned"); security-defaults.md §"Onward hops" (RFC 0214 §A)';
350
+ const t = await target(true);
351
+ if (!t.ok) return skip(t, rid);
352
+ const a2a = (await familyAdvertised('a2a'))!;
353
+ if (a2a['pushNotifications'] !== true) return softSkip('inapplicable', 'a2a.pushNotifications is not advertised — no push credential can be registered');
354
+ const first = await startApprovalTask(t.url);
355
+ const id = first.task?.id;
356
+ expect(typeof id, req(rid, DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
357
+ const credSentinel = `owcred${randomBytes(12).toString('hex')}`;
358
+ const tokenSentinel = `owtok${randomBytes(12).toString('hex')}`;
359
+ const dest = unservedDestination('https://example.com/openwop-conformance-push');
360
+ const created = await rpc(t.url, 'CreateTaskPushNotificationConfig', { taskId: id, url: dest.url, token: tokenSentinel, authentication: { scheme: 'Bearer', credentials: credSentinel } });
361
+ if (created.error !== undefined && !dest.tunnelled) {
362
+ await rpc(t.url, 'CancelTask', { id });
363
+ return softSkip('blocked', `Create was refused for ${dest.url} and no public front (OPENWOP_WEBHOOK_RECEIVER_URL) is wired (${JSON.stringify(created.error)})`);
364
+ }
365
+ const cfgId = (created.result as { id?: string } | undefined)?.id;
366
+ expect(typeof cfgId, req(rid, doc, `positive control: Create with a credential MUST succeed (got ${JSON.stringify(created.error ?? created.result)})`)).toBe('string');
367
+ const leaks = (what: string, v: unknown): void => {
368
+ const text = JSON.stringify(v ?? null);
369
+ expect(text.includes(credSentinel) || text.includes(tokenSentinel), req(rid, doc, `${what} MUST NOT carry the registered token or credentials`)).toBe(false);
370
+ };
371
+ leaks('the Create response', created.result);
372
+ leaks('GetTaskPushNotificationConfig', (await rpc(t.url, 'GetTaskPushNotificationConfig', { taskId: id, id: cfgId })).result);
373
+ leaks('ListTaskPushNotificationConfigs', (await rpc(t.url, 'ListTaskPushNotificationConfigs', { taskId: id })).result);
374
+ leaks('GetTask', (await rpc(t.url, 'GetTask', { id })).result);
375
+ leaks('the run snapshot (getRun)', (await driver.get(`/runs/${encodeURIComponent(id!)}`)).json);
376
+ leaks('the run event log (pollRunEvents)', (await driver.get(`/runs/${encodeURIComponent(id!)}/events/poll?timeout=1`)).json);
377
+ await rpc(t.url, 'DeleteTaskPushNotificationConfig', { taskId: id, id: cfgId });
378
+ await rpc(t.url, 'CancelTask', { id });
379
+ });
380
+
280
381
  it('ListTasks returns only what listRuns returns to the same Subject; tenant and contextId never select', async () => {
281
382
  const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
282
383
  if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller leg cannot run');
@@ -0,0 +1,291 @@
1
+ /**
2
+ * RFC 0214 — A2A push delivery, measured at a SUITE-OWNED receiver.
3
+ * Target major 2.
4
+ *
5
+ * Gate: the v2 `a2a` record lists profile `a2a-1.0` AND advertises
6
+ * `pushNotifications: true`. Otherwise `inapplicable` — the unadvertised host's
7
+ * obligation is the refusal row (`v2-a2a-operation-map`
8
+ * `a2a-push-unadvertised-refused`).
9
+ *
10
+ * Receiver: `lib/scoped-receiver.ts`, one nonce per exercise behind the
11
+ * operator's public front (`OPENWOP_WEBHOOK_RECEIVER_URL`, the RFC 0158
12
+ * receiver). A conforming host REFUSES a loopback, private or plain-http push
13
+ * destination (RFC 0214 §B, webhooks.md §Egress), so without a public https
14
+ * front no leg here can be witnessed and each records `blocked` — never a pass,
15
+ * and never a relaxation of the guard under test.
16
+ *
17
+ * Non-vacuity: every leg drives its task to INPUT_REQUIRED FIRST, registers the
18
+ * config there, and only then resumes — a task that finished before the config
19
+ * existed would owe no push, and a leg reading "nothing arrived" off it would
20
+ * measure nothing. On a public front a missing delivery is a FAILURE, except
21
+ * when other traffic provably reached the listener (`absenceIsUnmeasured`),
22
+ * which records `blocked`.
23
+ *
24
+ * Limit, stated: the delivery-time DNS re-resolve arm of §B cannot be driven
25
+ * from outside the host (the suite does not control the host's resolver); it is
26
+ * witnessed by host unit tests, not here. The `token`-only carriage is a SHOULD
27
+ * (RFC 0214 §C, open question 1) and is not asserted.
28
+ *
29
+ * @see RFCS/0214-a2a-push-credential-is-a-destination-credential.md §A–§D
30
+ * @see spec/v2/core/interop.md §"A2A push delivery"
31
+ * @see spec/v2/core/security-defaults.md §"Onward hops"
32
+ */
33
+ import { afterAll, describe, it, expect } from 'vitest';
34
+ import { randomBytes } from 'node:crypto';
35
+ import type { ServerResponse } from 'node:http';
36
+ import { driver } from '../lib/driver.js';
37
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
38
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
39
+ import { softSkip } from '../lib/soft-skip.js';
40
+ import { req } from '../lib/requirement-ids.js';
41
+ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedHit, type ScopedReceiver } from '../lib/scoped-receiver.js';
42
+
43
+ export const REQUIRES_HOST_CALLBACK = 'the host POSTs A2A push notifications (StreamResponse statusUpdate) to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
44
+
45
+ const DOC = 'spec/v2/core/interop.md §"A2A push delivery" (RFC 0214)';
46
+ const R = (slug: string): string => `openwop.requirement.0214.${slug}`;
47
+ const PROFILE = 'a2a-1.0';
48
+ const SKILL = 'conformance-approval';
49
+ const ACCEPT = { action: 'accept' };
50
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
51
+
52
+ interface RpcError { code: number; message?: string; data?: unknown }
53
+ interface Rpc { status: number; result?: Record<string, unknown> | undefined; error?: RpcError | undefined }
54
+ interface Task { id?: string; contextId?: string; status?: { state?: string } }
55
+
56
+ async function rpc(url: string, method: string, params: unknown): Promise<Rpc> {
57
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': '1.0' };
58
+ const key = process.env.OPENWOP_API_KEY;
59
+ if (key) headers['authorization'] = `Bearer ${key}`;
60
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params }) });
61
+ const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: RpcError };
62
+ return { status: res.status, ...body };
63
+ }
64
+ function taskOf(r: Rpc): Task | undefined {
65
+ const res = r.result ?? {};
66
+ return (res['task'] ?? (res['id'] !== undefined ? res : undefined)) as Task | undefined;
67
+ }
68
+ const message = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({ messageId: `conf-0214-${randomBytes(8).toString('hex')}`, role: 'ROLE_USER', parts: [{ text: 'openwop conformance RFC 0214' }], ...extra });
69
+ const acceptMessage = (taskId: string): Record<string, unknown> => message({ taskId, parts: [{ data: ACCEPT }], metadata: { openwop: { interrupt: ACCEPT } } });
70
+
71
+ type Gate = { ok: true; url: string } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
72
+ async function gate(): Promise<Gate> {
73
+ if (!(await v2Discovery())) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
74
+ const a2a = await familyAdvertised('a2a');
75
+ if (!a2a) return { ok: false, kind: 'inapplicable', reason: 'a2a not advertised at major 2' };
76
+ const profiles = Array.isArray(a2a['profiles']) ? (a2a['profiles'] as unknown[]) : [];
77
+ if (!profiles.includes(PROFILE)) return { ok: false, kind: 'inapplicable', reason: 'a2a advertised without profile a2a-1.0 — no A2A interface to register a push config on' };
78
+ if (a2a['pushNotifications'] !== true) return { ok: false, kind: 'inapplicable', reason: 'a2a.pushNotifications not advertised — the refusal row applies (v2-a2a-operation-map a2a-push-unadvertised-refused)' };
79
+ const cardUrl = a2a['agentCardUrl'];
80
+ if (typeof cardUrl !== 'string') return { ok: false, kind: 'blocked', reason: 'a2a.agentCardUrl is not advertised' };
81
+ const res = await fetch(cardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } });
82
+ const card = (await res.json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }>; skills?: Array<{ id?: string }> };
83
+ const iface = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0');
84
+ if (typeof iface?.url !== 'string') return { ok: false, kind: 'blocked', reason: 'the card lists no JSONRPC 1.0 interface' };
85
+ const skills = (card.skills ?? []).map((s) => s.id);
86
+ if (skills.length !== 1 || skills[0] !== SKILL || !isFixtureAdvertised(SKILL)) return { ok: false, kind: 'blocked', reason: `the card routes skills [${skills.join(', ')}]; the legs need exactly ${SKILL} (conformance/fixtures.md)` };
87
+ return { ok: true, url: iface.url };
88
+ }
89
+
90
+ /** A receiver that records its own hits and answers each with `answer` (default 204). */
91
+ async function receiver(answer?: (hit: ScopedHit, res: ServerResponse) => void): Promise<{ rx: ScopedReceiver; hits: ScopedHit[] }> {
92
+ const hits: ScopedHit[] = [];
93
+ const rx = await startScopedReceiver((hit, res) => {
94
+ hits.push(hit);
95
+ if (answer) return answer(hit, res);
96
+ res.writeHead(204); res.end();
97
+ });
98
+ return { rx, hits };
99
+ }
100
+ async function until(pred: () => boolean, ms: number): Promise<boolean> {
101
+ const end = Date.now() + ms;
102
+ while (!pred() && Date.now() < end) await new Promise((ok) => setTimeout(ok, 200));
103
+ return pred();
104
+ }
105
+ const wait = (ms: number): Promise<void> => new Promise((ok) => setTimeout(ok, ms));
106
+
107
+ /**
108
+ * Budgets. On a public cut every step crosses the operator's tunnel (suite → host
109
+ * front, host → receiver front), and the 2.39.0 public cut lost three legs to
110
+ * vitest's per-test timeout — not to an assertion — while deliveries were in
111
+ * fact arriving (#1537 precedent: the RFC 0199 state leg). Loopback keeps the
112
+ * tight budgets; a fronted run gets room for the round trips.
113
+ */
114
+ const FRONTED = Boolean(process.env.OPENWOP_WEBHOOK_RECEIVER_URL);
115
+ const ARRIVAL_MS = FRONTED ? 60_000 : 20_000;
116
+ const SETTLE_MS = FRONTED ? 45_000 : 10_000;
117
+ const LEG_MS = FRONTED ? 240_000 : 60_000;
118
+ const FORK_LEG_MS = FRONTED ? 300_000 : 90_000;
119
+ const header = (h: ScopedHit, name: string): string | undefined => { const v = h.headers[name.toLowerCase()]; return Array.isArray(v) ? v.join(',') : v; };
120
+
121
+ async function runStatus(taskId: string): Promise<string | undefined> {
122
+ const r = await driver.get(`/runs/${encodeURIComponent(taskId)}`);
123
+ return r.status === 200 ? ((r.json as { status?: string }).status) : undefined;
124
+ }
125
+ async function settle(taskId: string, want: (s: string | undefined) => boolean, ms = SETTLE_MS): Promise<string | undefined> {
126
+ const end = Date.now() + ms;
127
+ let s = await runStatus(taskId);
128
+ while (!want(s) && Date.now() < end) { await wait(150); s = await runStatus(taskId); }
129
+ return s;
130
+ }
131
+
132
+ /** Start an approval task and hold it at INPUT_REQUIRED — the precondition every leg needs. */
133
+ async function suspendedTask(url: string, id: string): Promise<string> {
134
+ const r = await rpc(url, 'SendMessage', { message: message() });
135
+ const task = taskOf(r);
136
+ expect(typeof task?.id, req(id, DOC, `SendMessage MUST start a task (got ${JSON.stringify(r.error ?? r.result)})`)).toBe('string');
137
+ expect(await settle(task!.id!, (s) => s === 'waiting-approval'), req(id, DOC, 'precondition: the task MUST be suspended (INPUT_REQUIRED) BEFORE the config is registered, or the leg would measure a push nobody owed')).toBe('waiting-approval');
138
+ return task!.id!;
139
+ }
140
+ async function create(url: string, taskId: string, dest: string, extra: Record<string, unknown> = {}): Promise<Rpc> {
141
+ return rpc(url, 'CreateTaskPushNotificationConfig', { taskId, url: dest, ...extra });
142
+ }
143
+
144
+ /** Why a leg cannot be measured on this cut, or null when it can. */
145
+ function unmeasurable(rx: ScopedReceiver, created: Rpc): string | null {
146
+ if (!rx.tunnelled) {
147
+ return `no public https front (OPENWOP_WEBHOOK_RECEIVER_URL) is wired — a conforming host refuses the loopback destination ${rx.url} (RFC 0214 §B), so push delivery cannot be witnessed here${created.error ? ` (Create answered ${JSON.stringify(created.error)})` : ''}`;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * One delivery, read by two legs. Each leg cites exactly one requirement id —
154
+ * the ledger keeps the LAST id an it() cites, so the 2.39.0 cut, whose single
155
+ * §A/§C it() cited both, recorded `no-openwop-signature` and silently dropped
156
+ * `delivery-authenticated`. The delivery runs once (lazily, in the first leg)
157
+ * and the second leg reads the same hits; when the delivery cannot be made the
158
+ * second leg records `blocked` with the reason instead of vanishing.
159
+ */
160
+ type Delivery =
161
+ | { kind: 'measured'; hits: ScopedHit[]; taskId: string; cred: string; rx: ScopedReceiver }
162
+ | { kind: 'skip'; skip: 'inapplicable' | 'blocked'; reason: string };
163
+ let delivery: Promise<Delivery> | undefined;
164
+ let deliveryRx: ScopedReceiver | undefined;
165
+ function deliverOnce(id: string = R('a2a-push-delivery-authenticated')): Promise<Delivery> {
166
+ delivery ??= (async (): Promise<Delivery> => {
167
+ const g = await gate();
168
+ if (!g.ok) return { kind: 'skip', skip: g.kind, reason: g.reason };
169
+ const { rx, hits } = await receiver();
170
+ deliveryRx = rx;
171
+ const taskId = await suspendedTask(g.url, id);
172
+ const cred = `owpush${randomBytes(12).toString('hex')}`;
173
+ const created = await create(g.url, taskId, rx.url, { authentication: { scheme: 'Bearer', credentials: cred } });
174
+ const why = unmeasurable(rx, created);
175
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return { kind: 'skip', skip: 'blocked', reason: why }; }
176
+ expect(created.error, req(id, DOC, `Create on a suspended task with the public receiver MUST succeed (got ${JSON.stringify(created.error)})`)).toBeUndefined();
177
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
178
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
179
+ const arrived = await until(() => hits.length > 0, ARRIVAL_MS);
180
+ if (!arrived && absenceIsUnmeasured(rx)) return { kind: 'skip', skip: 'blocked', reason: noDeliveryCause(rx, 'A2A push') };
181
+ return { kind: 'measured', hits, taskId, cred, rx };
182
+ })();
183
+ return delivery;
184
+ }
185
+
186
+ describe('RFC 0214 — v2-a2a-push-delivery (host POSTs A2A push to a suite-owned receiver)', () => {
187
+ afterAll(async () => { await deliveryRx?.close(); });
188
+
189
+ it('§A/§C — a transition after registration is POSTed to the registered destination with the registered Authorization', async () => {
190
+ const id = R('a2a-push-delivery-authenticated');
191
+ const d = await deliverOnce(id);
192
+ if (d.kind === 'skip') return softSkip(d.skip, d.reason);
193
+ const { hits, taskId, cred, rx } = d;
194
+ expect(hits.length, req(id, `${DOC} §C ("at least one attempt")`, `a transition after registration MUST be POSTed at least once to the registered destination — ${noDeliveryCause(rx, 'A2A push')}`)).toBeGreaterThan(0);
195
+ const h = hits[0]!;
196
+ expect(h.method, req(id, 'A2A v1.0.1 §4.3.3', 'a push is an HTTP POST')).toBe('POST');
197
+ expect(header(h, 'authorization'), req(id, `${DOC} §A (A2A v1.0.1 §4.3.3 "MUST include authentication credentials")`, 'the push MUST carry the registered credential as Authorization: {scheme} {credentials}')).toBe(`Bearer ${cred}`);
198
+ expect((header(h, 'content-type') ?? '').split(';')[0]!.trim(), req(id, 'A2A v1.0.1 §4.3.3', 'the push body is application/a2a+json')).toBe('application/a2a+json');
199
+ const body = JSON.parse(h.body || '{}') as { statusUpdate?: { taskId?: string; status?: { state?: string } } };
200
+ expect(body.statusUpdate?.taskId, req(id, 'A2A v1.0.1 §4.3.3 (StreamResponse)', `the push body MUST be a StreamResponse statusUpdate naming the task (got ${h.body.slice(0, 200)})`)).toBe(taskId);
201
+ }, LEG_MS);
202
+
203
+ it('§C — an A2A push carries no OpenWOP signature', async () => {
204
+ const id = R('a2a-push-no-openwop-signature');
205
+ let d: Delivery;
206
+ try { d = await deliverOnce(); }
207
+ catch (err) { return softSkip('blocked', `the delivery this leg reads was not made — the §A/§C leg failed first: ${err instanceof Error ? err.message.slice(0, 200) : String(err)}`); }
208
+ if (d.kind === 'skip') return softSkip(d.skip, d.reason);
209
+ expect(d.hits.length, req(id, DOC, 'positive control: at least one push arrived to read headers from')).toBeGreaterThan(0);
210
+ for (const x of d.hits) {
211
+ expect(header(x, 'openwop-signature'), req(id, `${DOC} §C ("no OpenWOP signature")`, 'an A2A push MUST NOT carry an OpenWOP-Signature — A2A defines no secret exchange to verify it')).toBeUndefined();
212
+ }
213
+ }, LEG_MS);
214
+
215
+ it('§B — a 3xx from the destination is a failed delivery: the redirect target receives nothing, credential or not', async () => {
216
+ const id = R('a2a-push-no-redirect');
217
+ const g = await gate();
218
+ if (!g.ok) return softSkip(g.kind, g.reason);
219
+ const target = await receiver();
220
+ const origin = await receiver((_hit, res) => { res.writeHead(307, { location: target.rx.url }); res.end(); });
221
+ try {
222
+ const taskId = await suspendedTask(g.url, id);
223
+ const created = await create(g.url, taskId, origin.rx.url, { authentication: { scheme: 'Bearer', credentials: `owredir${randomBytes(10).toString('hex')}` } });
224
+ const why = unmeasurable(origin.rx, created);
225
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return softSkip('blocked', why); }
226
+ expect(created.error, req(id, DOC, `Create MUST succeed (got ${JSON.stringify(created.error)})`)).toBeUndefined();
227
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
228
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
229
+ const arrived = await until(() => origin.hits.length > 0, ARRIVAL_MS);
230
+ if (!arrived && absenceIsUnmeasured(origin.rx)) return softSkip('blocked', noDeliveryCause(origin.rx, 'A2A push'));
231
+ expect(origin.hits.length, req(id, DOC, `positive control: the registered destination MUST receive the push it then redirects — ${noDeliveryCause(origin.rx, 'A2A push')}`)).toBeGreaterThan(0);
232
+ await wait(4000); // any retry or followed redirect lands in this window
233
+ expect(target.hits.length, req(id, `${DOC} §B; webhooks.md §Egress ("refuse to follow redirects")`, 'the redirect target MUST receive nothing: a 3xx is a delivery failure and the credential MUST NOT be sent after a redirect')).toBe(0);
234
+ } finally { await origin.rx.close(); await target.rx.close(); }
235
+ }, LEG_MS);
236
+
237
+ it('§A — after Delete no further push reaches that destination, while a live config on the same task still receives', async () => {
238
+ const id = R('a2a-push-discard-on-delete');
239
+ const g = await gate();
240
+ if (!g.ok) return softSkip(g.kind, g.reason);
241
+ const kept = await receiver();
242
+ const dropped = await receiver();
243
+ try {
244
+ const taskId = await suspendedTask(g.url, id);
245
+ const a = await create(g.url, taskId, kept.rx.url, { authentication: { scheme: 'Bearer', credentials: `owkeep${randomBytes(10).toString('hex')}` } });
246
+ const why = unmeasurable(kept.rx, a);
247
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return softSkip('blocked', why); }
248
+ const b = await create(g.url, taskId, dropped.rx.url, { authentication: { scheme: 'Bearer', credentials: `owdrop${randomBytes(10).toString('hex')}` } });
249
+ const dropId = (b.result as { id?: string } | undefined)?.id;
250
+ expect(typeof dropId, req(id, DOC, `Create of the second config MUST succeed (got ${JSON.stringify(b.error ?? b.result)})`)).toBe('string');
251
+ const del = await rpc(g.url, 'DeleteTaskPushNotificationConfig', { taskId, id: dropId });
252
+ expect(del.error, req(id, DOC, `Delete of the caller's own config MUST succeed (got ${JSON.stringify(del.error)})`)).toBeUndefined();
253
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
254
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
255
+ const arrived = await until(() => kept.hits.length > 0, ARRIVAL_MS);
256
+ if (!arrived && absenceIsUnmeasured(kept.rx)) return softSkip('blocked', noDeliveryCause(kept.rx, 'A2A push'));
257
+ expect(kept.hits.length, req(id, DOC, `positive control: the config that was NOT deleted MUST still receive the push — ${noDeliveryCause(kept.rx, 'A2A push')}`)).toBeGreaterThan(0);
258
+ await wait(3000);
259
+ expect(dropped.hits.length, req(id, `${DOC} §A ("dropped when the config is deleted")`, 'a deleted config MUST receive nothing further — its destination credential is discarded with it')).toBe(0);
260
+ } finally { await kept.rx.close(); await dropped.rx.close(); }
261
+ }, LEG_MS);
262
+
263
+ it('§D — a replay fork of a pushed run delivers nothing to the source run\'s destination', async () => {
264
+ const id = R('a2a-push-fork-no-push');
265
+ const g = await gate();
266
+ if (!g.ok) return softSkip(g.kind, g.reason);
267
+ const replay = await familyAdvertised('replay');
268
+ const modes = Array.isArray(replay?.['modes']) ? (replay!['modes'] as unknown[]) : [];
269
+ if (!modes.includes('replay')) return softSkip('inapplicable', 'replay (mode replay) is not advertised — no fork to measure');
270
+ const { rx, hits } = await receiver();
271
+ try {
272
+ const taskId = await suspendedTask(g.url, id);
273
+ const created = await create(g.url, taskId, rx.url, { authentication: { scheme: 'Bearer', credentials: `owfork${randomBytes(10).toString('hex')}` } });
274
+ const why = unmeasurable(rx, created);
275
+ if (why !== null) { await rpc(g.url, 'CancelTask', { id: taskId }); return softSkip('blocked', why); }
276
+ await rpc(g.url, 'SendMessage', { message: acceptMessage(taskId) });
277
+ await settle(taskId, (s) => s !== undefined && TERMINAL.has(s));
278
+ const arrived = await until(() => hits.length > 0, ARRIVAL_MS);
279
+ if (!arrived && absenceIsUnmeasured(rx)) return softSkip('blocked', noDeliveryCause(rx, 'A2A push'));
280
+ expect(hits.length, req(id, DOC, `positive control: the source run MUST have pushed before the fork — ${noDeliveryCause(rx, 'A2A push')}`)).toBeGreaterThan(0);
281
+ await wait(3000);
282
+ const before = hits.length;
283
+ const fork = await driver.post(`/runs/${encodeURIComponent(taskId)}:fork`, { mode: 'replay' });
284
+ if (fork.status < 200 || fork.status >= 300) return softSkip('blocked', `replay is advertised but forkRun answered ${fork.status} ${JSON.stringify(fork.json)} — the fork leg is unmeasured`);
285
+ const child = (fork.json as { runId?: string } | undefined)?.runId;
286
+ if (typeof child === 'string') await settle(child, (s) => s !== undefined && TERMINAL.has(s));
287
+ await wait(4000);
288
+ expect(hits.length, req(id, `${DOC} §D; replay.md §Suppression ("MUST NOT deliver events a replay re-emits")`, `a replay fork MUST NOT push to the source run's destination (deliveries ${before} before the fork, ${hits.length} after)`)).toBe(before);
289
+ } finally { await rx.close(); }
290
+ }, FORK_LEG_MS);
291
+ });
@@ -74,7 +74,15 @@ describe('v2 lane-issuer-advertised (RFC 0170 §B.2–§B.4)', () => {
74
74
  expect(LANES.has(name), req('openwop.requirement.0170.lane-issuer-advertised.members', DOC, `auth.lanes[].lane MUST be one of the ten lanes (got ${name})`)).toBe(true);
75
75
  const issuers = l['issuers'];
76
76
  expect(Array.isArray(issuers) && issuers.length > 0 && issuers.every((i) => typeof i === 'string' && i.length > 0), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.2', `lane ${name} MUST advertise its trust root in issuers[] (min 1, non-empty strings)`)).toBe(true);
77
- expect(REVOCATION.has(String(l['revocation'])), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.2', `lane ${name} MUST name its revocation rule`)).toBe(true);
77
+ // identity.md §2.2: a lane whose row reads "—" (`anonymous`) has no
78
+ // revocation rule, so it MAY omit `revocation` — the schema made it
79
+ // optional there in 2.38.0 (openwop#1540), and until 2.39.1 this leg
80
+ // still demanded it, failing a host that followed the schema and the
81
+ // prose (measured on openwop-app). If such a lane does advertise one,
82
+ // it must still be a known member. Every other lane MUST name its rule.
83
+ if (!(LANE_RULES[name] === null && l['revocation'] === undefined)) {
84
+ expect(REVOCATION.has(String(l['revocation'])), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.2', `lane ${name} MUST name its revocation rule`)).toBe(true);
85
+ }
78
86
  expect(ASSURANCE.has(String(l['minimumAssurance'])), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.3', `lane ${name} MUST advertise minimumAssurance: bearer | sender-constrained | key-bound`)).toBe(true);
79
87
  if (l['delegationProofs'] !== undefined) {
80
88
  expect(Array.isArray(l['delegationProofs']) && (l['delegationProofs'] as unknown[]).every((p) => PROOFS.has(String(p))), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.4', `lane ${name}: delegationProofs[] MUST be drawn from mtls-key-binding | dpop | svid-chain`)).toBe(true);