@openwop/openwop-conformance 2.45.28 → 2.45.30

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,19 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.30] — 2026-10-09 — the seams profile floor is seam-driven
4
+
5
+ - **`openwop-conformance-seams-v2` no longer certifies on a host that mounts no seams.** Its predicate is a `conformance` block at the discovery root, deliberately necessary and not sufficient: on a host that does not advertise `conformance.seamsProfile`, every floor leg records `inapplicable`, so the profile is claimed with `witnessCount 0` and not certified. But a floor row is recorded per scenario file, and two floor files had a leg that runs without a seam. openwop-app's production bundle `4683d9f89` (suite 2.45.28) carries a `conformance` block holding only `certificationBundleUrl`, and certified the profile on the facet leg alone.
6
+ - **The facet leg** of `v2-v1-signed-webhook-accepted` (`0176.v1-signed-webhook-accepted.facet`) moves to `v2-webhooks-v1-scheme-listed`.
7
+ - **The ledger-read leg** of `v2-effect-identity-business-key` (`0173.effect-identity-business-key`) moves to `v2-effect-ledger-keying`.
8
+ - Requirement ids are unchanged, and so are the profile's floor files. The floor files keep only their seam-driven legs.
9
+ - **New coherence check** `seams-floor-legs-gated`: every leg of a seams-profile floor file reaches `seamsProfileAdvertised(` or `era2Gate(` before its first assertion. Proven against both files as they were: each fails, naming its seam-free leg.
10
+ - **What a host's next bundle shows:** a host that doesn't advertise the seams profile but ran these legs sees `openwop.floor.v2-effect-identity-business-key` and `openwop.floor.v2-v1-signed-webhook-accepted` go `executed-pass` to `inapplicable`, while both requirement ids keep passing under the new files. On MyndHyve that costs no certification, since it doesn't claim the profile. On openwop-app the profile is claimed and not certified, which is the correct outcome. A seams host is unaffected. Bundles already published verify as before.
11
+
12
+ ## [2.45.29] — 2026-10-08 — purpose labels witnessed without a seam; RFC 0241 Accepted
13
+
14
+ - **`v2-purpose-propagation-onward` witnesses purpose labels through normative operations.** New fixture `conformance-purpose-relay`: routed as the only skill of one agent's A2A card, it relays an inbound A2A message's text to the suite's fake peer through `core.conformance.a2a-invoke`. The suite labels the inbound message with `metadata.openwop.permittedPurposes`, finds the agent through `GET /agents` and `GetExtendedAgentCard`, and reads the onward label at its peer; a nonce matches each onward message to its inbound one. The §22 seam stays as the fallback, and each note names the path. A host with the fixture advertised but no card routing it, or with the suite's peer not running, records `blocked`. Self-test double: a widened label, a dropped label and a call made under `[]` each fail their leg (`lib/purpose-relay-witness.test.ts`). Proven on a seams-off v2 reference host (openwop-examples #166): both rows `executed-pass`, and each defect `executed-fail`. This replaces the planned RFC 0240: the label already arrives on a real carrier.
15
+ - **No scenario change.** RFC 0241 goes `Accepted` on the v2 reference host's certified seams-off cut on 2.45.28; its gaps G1 and G2 close, which changes the packed `spec/v1/gaps.json`.
16
+
3
17
  ## [2.45.28] — 2026-10-08 — two more seam-driven legs, and the backoff ratio, on a public seam-free cut
4
18
 
5
19
  - **Found by the v2 reference host's first public seams-off cut** (2.45.27, RFC 0241's evidence): all seven target rows `executed-pass`, but four other rows kept the bundle from certifying. Neither shows on a loopback cut.
package/README.md CHANGED
@@ -137,7 +137,7 @@ Each scenario file records its disposition and assertion count in `<report-dir>/
137
137
 
138
138
  ## What's Covered
139
139
 
140
- The current suite has 650 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 210 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
140
+ The current suite has 652 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 212 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
141
141
 
142
142
  The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
143
143
 
@@ -147,7 +147,7 @@ The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
147
147
  | Runs | Create, read, list, cancel, bulk cancel, pause/resume, run options and limits, outputs on completion, snapshots | `v2-created-run-readable`, `v2-run-cancel`, `v2-run-pause-resume`, `v2-run-options-limits`, `v2-run-completed-outputs` |
148
148
  | Events and streaming | Closed event types, SSE projection, `Last-Event-ID` resumption, poll cursors, terminal-event-once | `v2-stream-sse-projection`, `v2-sse-last-event-id-cursor`, `v2-poll-cursor-v2`, `v2-terminal-event-once` |
149
149
  | Errors and headers | The error registry and envelope, malformed bodies, CORS preflight, header scheme | `v2-error-registry`, `v2-malformed-body-envelope`, `v2-cors-preflight`, `v2-header-scheme` |
150
- | Idempotency | Key grammar, in-flight concurrency, effect identity | `v2-idempotency-key-grammar`, `v2-idempotency-in-flight`, `v2-effect-identity-business-key` |
150
+ | Idempotency | Key grammar, in-flight concurrency, effect identity | `v2-idempotency-key-grammar`, `v2-idempotency-in-flight`, `v2-effect-identity-business-key`, `v2-effect-ledger-keying` |
151
151
  | Interrupts and approvals | Resolve after terminal, token scheme, approver enforcement, reject disposition, credential interrupts | `v2-interrupt-resolve-terminal`, `v2-approver-enforced`, `v2-credential-interrupt` |
152
152
  | Replay and fork | Fork prefix and ancestry, refusals, replay side-effect suppression, forking a v1 run | `v2-run-fork-prefix`, `v2-run-fork-refusals`, `v2-replay-suppression-ordinal` |
153
153
  | Identity, auth and OAuth | Auth challenges, bound ids, workspace scope from identity, OIDC audience, protected-resource metadata, PKCE | `v2-auth-challenge`, `v2-bound-id-kinds`, `v2-oidc-id-token-audience`, `v2-oauth-client-pkce-state-iss` |
@@ -166,7 +166,7 @@ Three kinds of scenario share the tree:
166
166
  - **Host** checks drive a live host over HTTP.
167
167
  - **Gated** checks run only when the host advertises the family, profile or fixture they need, and otherwise record `inapplicable` or `blocked` with a reason. `--require-behavior` turns an advertised-but-unwitnessable behaviour into a failure.
168
168
 
169
- Current source tree: 650 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
169
+ Current source tree: 652 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
170
170
 
171
171
  ## How the 2.x suite is built
172
172
 
package/coverage.md CHANGED
@@ -39,11 +39,11 @@ Each scenario that targets major 2 is listed once, under the [`spec/v2/core/`](.
39
39
  | [`portability.md`](../spec/v2/core/portability.md) | `v2-portability-advertisement.test.ts` |
40
40
  | [`replay.md`](../spec/v2/core/replay.md) | `context-summarization-replay.test.ts`, `memory-attribution-replay-stable.test.ts`, `v2-effect-seam-manifest.test.ts`, `v2-effect-seam-no-refire.test.ts`, `v2-fork-a-v1-run.test.ts`, `v2-nondeterminism-policy-advertisement.test.ts`, `v2-nondeterminism-sources.test.ts`, `v2-replay-suppression-ordinal.test.ts`, `v2-run-fork-prefix.test.ts` |
41
41
  | [`runs.md`](../spec/v2/core/runs.md) | `v2-artifact-a2a-shape.test.ts`, `v2-budget-enforcement.test.ts`, `v2-budget-exhaustion-facet.test.ts`, `v2-configurable-closed.test.ts`, `v2-conversation-turn-parts.test.ts`, `v2-created-run-readable.test.ts`, `v2-data-residency-admission.test.ts`, `v2-data-residency-advertisement.test.ts`, `v2-dead-letter-advertisement.test.ts`, `v2-eval-mode-unadvertised-refused.test.ts`, `v2-limits-envelope-caps-advertisement.test.ts`, `v2-run-annotation-not-event.test.ts`, `v2-run-bulk-cancel.test.ts`, `v2-run-cancel.test.ts`, `v2-run-diff-identical.test.ts`, `v2-run-execution-bounds.test.ts`, `v2-run-fork-ancestry.test.ts`, `v2-run-fork-refusals.test.ts`, `v2-run-list.test.ts`, `v2-run-options-limits.test.ts`, `v2-run-pause-resume.test.ts`, `v2-run-snapshot-etag.test.ts` |
42
- | [`security-defaults.md`](../spec/v2/core/security-defaults.md) | `audit-anomaly-shape.test.ts`, `audit-checkpoint-signature.test.ts`, `audit-checkpoint-vectors.test.ts`, `audit-log-integrity.test.ts`, `inbound-credential-no-passthrough.test.ts`, `v2-compensation-read-projection.test.ts`, `v2-effect-identity-business-key.test.ts`, `v2-pack-isolation.test.ts`, `v2-purpose-propagation-advertisement.test.ts`, `v2-purpose-propagation-onward.test.ts`, `v2-relaxation-recorded.test.ts` |
42
+ | [`security-defaults.md`](../spec/v2/core/security-defaults.md) | `audit-anomaly-shape.test.ts`, `audit-checkpoint-signature.test.ts`, `audit-checkpoint-vectors.test.ts`, `audit-log-integrity.test.ts`, `inbound-credential-no-passthrough.test.ts`, `v2-compensation-read-projection.test.ts`, `v2-effect-identity-business-key.test.ts`, `v2-effect-ledger-keying.test.ts`, `v2-pack-isolation.test.ts`, `v2-purpose-propagation-advertisement.test.ts`, `v2-purpose-propagation-onward.test.ts`, `v2-relaxation-recorded.test.ts` |
43
43
  | [`storage.md`](../spec/v2/core/storage.md) | `v2-fs-sandbox-escape-refused.test.ts`, `v2-storage-cross-tenant-isolation.test.ts`, `v2-table-schema-enforcement.test.ts` |
44
44
  | [`tool-catalog.md`](../spec/v2/core/tool-catalog.md) | `tool-catalog-compact-projection.test.ts`, `tool-catalog-projection.test.ts`, `tool-descriptor-shape.test.ts`, `v2-tool-catalog-annotations.test.ts` |
45
45
  | [`versioning.md`](../spec/v2/core/versioning.md) | `v2-advertised-path-space-served.test.ts`, `v2-client-version-header.test.ts`, `v2-dual-stack-negotiation.test.ts`, `v2-malformed-body-envelope.test.ts`, `v2-min-client-version.test.ts`, `v2-preferred-version-default.test.ts`, `v2-version-header-honored.test.ts`, `v2-well-known-one-resource.test.ts` |
46
- | [`webhooks.md`](../spec/v2/core/webhooks.md) | `v2-trigger-bridge-delivery.test.ts`, `v2-trigger-dead-letter-read.test.ts`, `v2-v1-signed-webhook-accepted.test.ts`, `v2-webhook-delivery-isolation.test.ts`, `v2-webhook-delivery-shape.test.ts`, `v2-webhook-durable-delivery.test.ts`, `v2-webhook-egress-refusal.test.ts`, `v2-webhook-endpoint-verification.test.ts`, `v2-webhook-generated-secret.test.ts`, `v2-webhook-message-id-stable.test.ts`, `v2-webhook-secret-rotation.test.ts`, `v2-webhook-standard-webhooks-delivery.test.ts`, `v2-webhook-unregister-stops-delivery.test.ts` |
46
+ | [`webhooks.md`](../spec/v2/core/webhooks.md) | `v2-trigger-bridge-delivery.test.ts`, `v2-trigger-dead-letter-read.test.ts`, `v2-v1-signed-webhook-accepted.test.ts`, `v2-webhooks-v1-scheme-listed.test.ts`, `v2-webhook-delivery-isolation.test.ts`, `v2-webhook-delivery-shape.test.ts`, `v2-webhook-durable-delivery.test.ts`, `v2-webhook-egress-refusal.test.ts`, `v2-webhook-endpoint-verification.test.ts`, `v2-webhook-generated-secret.test.ts`, `v2-webhook-message-id-stable.test.ts`, `v2-webhook-secret-rotation.test.ts`, `v2-webhook-standard-webhooks-delivery.test.ts`, `v2-webhook-unregister-stops-delivery.test.ts` |
47
47
  | [`workflow-chain-packs.md`](../spec/v2/core/workflow-chain-packs.md) | `v2-chain-pin-exact.test.ts` |
48
48
 
49
49
  `overview.md`, the introduction to the other documents, has no scenario filed under it.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.45.28",
4
- "stampSha256": "0e47a879900333edf1f01645a54edb7935cf263125d4ab3dc90405f81189d8a3"
3
+ "version": "2.45.30",
4
+ "stampSha256": "6288136786dfe99c80a73565b1e77f9b2f5e881fa5c1f140c4d826d3aba15f9c"
5
5
  }
@@ -0,0 +1,26 @@
1
+ {
2
+ "id": "conformance-purpose-relay",
3
+ "name": "Conformance: Purpose Relay",
4
+ "version": "1.0",
5
+ "description": "security-defaults.md §Onward hops. Routed as the only skill of one agent's A2A card: an inbound A2A message starts it, and one core.conformance.a2a-invoke node sends that message's text parts on to the A2A peer the operator configured. The suite labels the inbound message with metadata.openwop.permittedPurposes and reads the onward message at its peer. See conformance/fixtures.md.",
6
+ "nodes": [
7
+ {
8
+ "id": "relay",
9
+ "typeId": "core.conformance.a2a-invoke",
10
+ "name": "Relay the inbound message",
11
+ "position": { "x": 0, "y": 0 },
12
+ "config": {
13
+ "skill": "echo",
14
+ "forward": "inbound-message"
15
+ },
16
+ "inputs": {}
17
+ }
18
+ ],
19
+ "edges": [],
20
+ "triggers": [
21
+ { "id": "manual", "type": "manual", "enabled": true }
22
+ ],
23
+ "variables": [],
24
+ "metadata": { "tags": ["conformance", "a2a", "purpose-propagation"] },
25
+ "settings": { "timeout": 30000 }
26
+ }
package/fixtures.md CHANGED
@@ -44,6 +44,7 @@ All fixtures MUST advertise:
44
44
  | ----------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------- |
45
45
  | Noop | `conformance-noop` | Cheapest possible run-lifecycle test | `completed` | ≤ 5s |
46
46
  | Nondeterminism Sources | `conformance-nondeterminism` | RFC 0237 §D — one `core.conformance.nondeterminism` node draws one value per source the host lists in `nondeterminismPolicy.sources` into the run outputs; a `replay` fork from `fromSeq: 0` MUST reproduce each | `completed`; replay outputs byte-equal per listed source | ≤ 10s |
47
+ | Purpose Relay | `conformance-purpose-relay` | `security-defaults.md` §Onward hops — the only skill of one agent's A2A card; one `core.conformance.a2a-invoke` node sends the inbound message's text on to the configured peer, re-emitting its `permittedPurposes` label | `completed`, or `failed` when the label is `[]` | ≤ 30s |
47
48
  | Identity | `conformance-identity` | Verifies input/output passthrough | `completed` | ≤ 5s |
48
49
  | Delay | `conformance-delay` | Verifies poll/SSE behavior over time | `completed` | ≤ 30s (input-controlled) |
49
50
  | Failure | `conformance-failure` | Verifies error-event surface | `failed` | ≤ 5s |
@@ -160,6 +161,14 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
160
161
  - **Expected behavior**: the run reaches `completed`, and `run.completed.outputs` carries one string per listed source. A `replay` fork with `fromSeq: 0` reaches `completed` with byte-equal outputs for every listed source.
161
162
  - **Advertise only if** the host lists `nondeterminismPolicy.sources` and records the drawn values. Consumed by `v2-nondeterminism-sources`.
162
163
 
164
+ ### `conformance-purpose-relay`
165
+
166
+ - **Purpose**: witness `security-defaults.md` §Onward hops through normative operations: the label arrives on a real carrier (an inbound A2A message's `metadata.openwop.permittedPurposes`) and leaves on a real onward hop. No seam.
167
+ - **Shape**: one node, `relay` (`core.conformance.a2a-invoke`, the reserved typeId `conformance-a2a-task-roundtrip` uses), with `config.forward: "inbound-message"`: it sends the text parts of the A2A message that started the run, unchanged, to the A2A peer the operator configured (the suite's fake peer, `OPENWOP_A2A_FAKE_PEER=true`).
168
+ - **Routing**: the host routes this workflow as the only skill (`skills[].id` = `conformance-purpose-relay`) of one agent in the caller's `GET /agents` inventory, reached through that agent's `a2aTenant` (`interop.md` §Per-agent cards). It is never a skill of the host card, which keeps `conformance-approval` as its one skill.
169
+ - **Expected behavior**: an inbound `SendMessage` to that agent starts one run. With no label, or a non-empty one, the peer receives one `SendMessage` whose text parts equal the inbound ones and, when labelled, whose `metadata.openwop.permittedPurposes` is a subset of the received label. With `[]`, the peer receives nothing; the run's terminal status is the host's.
170
+ - **Advertise only if** the host advertises `purposePropagation` with an onward hop, the `a2a-1.0` profile and `a2a.agentCards`, and its A2A client re-emits labels. Consumed by `v2-purpose-propagation-onward`.
171
+
163
172
  ### `conformance-noop`
164
173
 
165
174
  - **Purpose**: cheapest run-lifecycle test. Used by `runs-lifecycle.test.ts` (v1) and `v2-created-run-readable.test.ts` (v2), and by most run-surface scenarios, to verify create, read and the terminal event end-to-end.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.45.28",
3
+ "version": "2.45.30",
4
4
  "description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.45.28"
59
+ "@openwop/spec-artifacts": "2.45.30"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 737,
6
- "tests": 2782,
7
- "withStableId": 2782,
5
+ "files": 740,
6
+ "tests": 2783,
7
+ "withStableId": 2783,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2644
9
+ "explicitIds": 2645
10
10
  },
11
11
  "records": [
12
12
  {
@@ -30331,85 +30331,85 @@
30331
30331
  ]
30332
30332
  },
30333
30333
  {
30334
- "id": "openwop.it.v2-effect-identity-business-key.get-runs-runid-effects-validates-and-every-row-is-keyed-on-business-identity-or",
30334
+ "id": "openwop.it.v2-effect-identity-business-key.the-same-provider-key-is-presented-across-two-transport-retries",
30335
30335
  "file": "v2-effect-identity-business-key.test.ts",
30336
- "line": 59,
30337
- "title": "GET /runs/{runId}/effects validates and every row is keyed on business identity or the activity recipe",
30338
- "explicitId": "openwop.requirement.0173.effect-identity-business-key",
30336
+ "line": 48,
30337
+ "title": "the same provider key is presented across two transport retries",
30338
+ "explicitId": "openwop.requirement.0173.effect-identity-business-key.retry",
30339
30339
  "citations": [
30340
30340
  {
30341
- "section": "runs.md §Create",
30342
- "requirement": "POST /runs MUST answer 201 for the noop fixture"
30341
+ "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30342
+ "requirement": null,
30343
+ "interpolated": true
30343
30344
  },
30344
30345
  {
30345
- "section": "security-defaults.md §Layer-2 effect identity",
30346
- "requirement": "a host advertising `idempotency` MUST serve GET /runs/{runId}/effects with 200 (RFC 0173 §B)"
30346
+ "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30347
+ "requirement": null,
30348
+ "interpolated": true
30347
30349
  },
30348
30350
  {
30349
- "section": "effect-ledger-projection.schema.json",
30351
+ "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30350
30352
  "requirement": null,
30351
30353
  "interpolated": true
30352
30354
  },
30353
30355
  {
30354
- "section": "effect-ledger-projection.schema.json runId",
30355
- "requirement": "runId MUST echo the run read"
30356
+ "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30357
+ "requirement": null,
30358
+ "interpolated": true
30356
30359
  },
30357
30360
  {
30358
- "section": "security-defaults.md §Layer-2 effect identity",
30361
+ "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30359
30362
  "requirement": null,
30360
30363
  "interpolated": true
30361
30364
  },
30362
30365
  {
30363
- "section": "RFC 0150 §B",
30366
+ "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30364
30367
  "requirement": null,
30365
30368
  "interpolated": true
30366
30369
  },
30367
30370
  {
30368
- "section": "effect-ledger-projection.schema.json providerKey",
30369
- "requirement": "providerKey is a redaction-safe identity, never credential material"
30371
+ "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30372
+ "requirement": "the key the ledger records MUST be the key the provider received — a ledger that disagrees with the wire is not a witness"
30370
30373
  }
30371
30374
  ]
30372
30375
  },
30373
30376
  {
30374
- "id": "openwop.it.v2-effect-identity-business-key.the-same-provider-key-is-presented-across-two-transport-retries",
30375
- "file": "v2-effect-identity-business-key.test.ts",
30376
- "line": 120,
30377
- "title": "the same provider key is presented across two transport retries",
30378
- "explicitId": "openwop.requirement.0173.effect-identity-business-key.retry",
30377
+ "id": "openwop.it.v2-effect-ledger-keying.get-runs-runid-effects-validates-and-every-row-is-keyed-on-business-identity-or",
30378
+ "file": "v2-effect-ledger-keying.test.ts",
30379
+ "line": 42,
30380
+ "title": "GET /runs/{runId}/effects validates and every row is keyed on business identity or the activity recipe",
30381
+ "explicitId": "openwop.requirement.0173.effect-identity-business-key",
30379
30382
  "citations": [
30380
30383
  {
30381
- "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30382
- "requirement": null,
30383
- "interpolated": true
30384
+ "section": "runs.md §Create",
30385
+ "requirement": "POST /runs MUST answer 201 for the noop fixture"
30384
30386
  },
30385
30387
  {
30386
- "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30387
- "requirement": null,
30388
- "interpolated": true
30388
+ "section": "security-defaults.md §Layer-2 effect identity",
30389
+ "requirement": "a host advertising `idempotency` MUST serve GET /runs/{runId}/effects with 200 (RFC 0173 §B)"
30389
30390
  },
30390
30391
  {
30391
- "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30392
+ "section": "effect-ledger-projection.schema.json",
30392
30393
  "requirement": null,
30393
30394
  "interpolated": true
30394
30395
  },
30395
30396
  {
30396
- "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30397
- "requirement": null,
30398
- "interpolated": true
30397
+ "section": "effect-ledger-projection.schema.json runId",
30398
+ "requirement": "runId MUST echo the run read"
30399
30399
  },
30400
30400
  {
30401
- "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30401
+ "section": "security-defaults.md §Layer-2 effect identity",
30402
30402
  "requirement": null,
30403
30403
  "interpolated": true
30404
30404
  },
30405
30405
  {
30406
- "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30406
+ "section": "RFC 0150 §B",
30407
30407
  "requirement": null,
30408
30408
  "interpolated": true
30409
30409
  },
30410
30410
  {
30411
- "section": "spec/v2/core/idempotency.md §Layer 2: effect identity",
30412
- "requirement": "the key the ledger records MUST be the key the provider received — a ledger that disagrees with the wire is not a witness"
30411
+ "section": "effect-ledger-projection.schema.json providerKey",
30412
+ "requirement": "providerKey is a redaction-safe identity, never credential material"
30413
30413
  }
30414
30414
  ]
30415
30415
  },
@@ -35437,15 +35437,10 @@
35437
35437
  {
35438
35438
  "id": "openwop.it.v2-purpose-propagation-onward.a-received-label-is-re-emitted-on-the-onward-message-never-widened",
35439
35439
  "file": "v2-purpose-propagation-onward.test.ts",
35440
- "line": 83,
35440
+ "line": 147,
35441
35441
  "title": "a received label is re-emitted on the onward message, never widened",
35442
35442
  "explicitId": "openwop.requirement.purpose-propagation.onward-reemit-no-widen",
35443
35443
  "citations": [
35444
- {
35445
- "section": "spec/v2/core/security-defaults.md §Onward hops",
35446
- "requirement": null,
35447
- "interpolated": true
35448
- },
35449
35444
  {
35450
35445
  "section": "spec/v2/core/security-defaults.md §Onward hops",
35451
35446
  "requirement": null,
@@ -35456,7 +35451,7 @@
35456
35451
  {
35457
35452
  "id": "openwop.it.v2-purpose-propagation-onward.an-empty-label-means-no-onward-call-while-an-unlabelled-control-call-goes-throug",
35458
35453
  "file": "v2-purpose-propagation-onward.test.ts",
35459
- "line": 102,
35454
+ "line": 164,
35460
35455
  "title": "an empty label means no onward call, while an unlabelled control call goes through",
35461
35456
  "explicitId": "openwop.requirement.purpose-propagation.empty-no-onward",
35462
35457
  "citations": [
@@ -37854,27 +37849,10 @@
37854
37849
  }
37855
37850
  ]
37856
37851
  },
37857
- {
37858
- "id": "openwop.it.v2-v1-signed-webhook-accepted.the-webhooks-facet-lists-scheme-v1-the-scheme-a-v1-signed-delivery-is-verified-u",
37859
- "file": "v2-v1-signed-webhook-accepted.test.ts",
37860
- "line": 79,
37861
- "title": "the webhooks facet lists scheme \"v1\" — the scheme a v1-signed delivery is verified under",
37862
- "explicitId": "openwop.requirement.0176.v1-signed-webhook-accepted.facet",
37863
- "citations": [
37864
- {
37865
- "section": "spec/v2/core/webhooks.md §Surfaces",
37866
- "requirement": "the webhooks facet is { signatureAlgorithms[] }"
37867
- },
37868
- {
37869
- "section": "spec/v2/core/webhooks.md §Surfaces",
37870
- "requirement": "signatureAlgorithms MUST list \"v1\" — the cut adds no signature scheme (RFC 0176 §D.2)"
37871
- }
37872
- ]
37873
- },
37874
37852
  {
37875
37853
  "id": "openwop.it.v2-v1-signed-webhook-accepted.the-host-s-v2-receiver-path-accepts-an-x-openwop-only-scheme-v1-delivery-and-ref",
37876
37854
  "file": "v2-v1-signed-webhook-accepted.test.ts",
37877
- "line": 89,
37855
+ "line": 81,
37878
37856
  "title": "the host's v2 receiver path accepts an X-openwop-*-only scheme-v1 delivery and refuses a tampered one",
37879
37857
  "explicitId": "openwop.requirement.0176.v1-signed-webhook-accepted",
37880
37858
  "citations": [
@@ -38627,6 +38605,23 @@
38627
38605
  }
38628
38606
  ]
38629
38607
  },
38608
+ {
38609
+ "id": "openwop.it.v2-webhooks-v1-scheme-listed.the-webhooks-facet-lists-scheme-v1-the-scheme-a-v1-signed-delivery-is-verified-u",
38610
+ "file": "v2-webhooks-v1-scheme-listed.test.ts",
38611
+ "line": 24,
38612
+ "title": "the webhooks facet lists scheme \"v1\" — the scheme a v1-signed delivery is verified under",
38613
+ "explicitId": "openwop.requirement.0176.v1-signed-webhook-accepted.facet",
38614
+ "citations": [
38615
+ {
38616
+ "section": "spec/v2/core/webhooks.md §Surfaces",
38617
+ "requirement": "the webhooks facet is { signatureAlgorithms[] }"
38618
+ },
38619
+ {
38620
+ "section": "spec/v2/core/webhooks.md §Surfaces",
38621
+ "requirement": "signatureAlgorithms MUST list \"v1\" — the cut adds no signature scheme (RFC 0176 §D.2)"
38622
+ }
38623
+ ]
38624
+ },
38630
38625
  {
38631
38626
  "id": "openwop.it.v2-well-known-one-resource.a-header-less-get-and-an-openwop-version-2-0-get-are-two-representations-of-one",
38632
38627
  "file": "v2-well-known-one-resource.test.ts",
@@ -44704,6 +44699,30 @@
44704
44699
  }
44705
44700
  ]
44706
44701
  },
44702
+ {
44703
+ "id": "openwop.it.seams-floor-legs-gated.every-leg-of-a-seams-profile-floor-file-reaches-a-seams-gate-before-its-first-as",
44704
+ "file": "seams-floor-legs-gated.test.ts",
44705
+ "line": 84,
44706
+ "title": "every leg of a seams-profile floor file reaches a seams gate before its first assertion",
44707
+ "explicitId": "openwop.it.seams-floor-legs-gated.every-leg-of-a-seams-profile-floor-file-reaches-a-seams-gate-before-its-first-a",
44708
+ "citations": [
44709
+ {
44710
+ "section": "spec/v2/profiles.json",
44711
+ "requirement": null,
44712
+ "interpolated": true
44713
+ },
44714
+ {
44715
+ "section": "spec/v2/profiles.json",
44716
+ "requirement": null,
44717
+ "interpolated": true
44718
+ },
44719
+ {
44720
+ "section": "spec/v2/profiles.json",
44721
+ "requirement": null,
44722
+ "interpolated": true
44723
+ }
44724
+ ]
44725
+ },
44707
44726
  {
44708
44727
  "id": "openwop.it.semantic-digest-v2.the-digest-recipe-section-is-found-at-all",
44709
44728
  "file": "semantic-digest-v2.test.ts",
@@ -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": 650,
4
+ "files": 652,
5
5
  "v1": 455,
6
- "v2": 210
6
+ "v2": 212
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1431,6 +1431,9 @@
1431
1431
  "v2-effect-identity-business-key.test.ts": [
1432
1432
  2
1433
1433
  ],
1434
+ "v2-effect-ledger-keying.test.ts": [
1435
+ 2
1436
+ ],
1434
1437
  "v2-effect-seam-manifest.test.ts": [
1435
1438
  2
1436
1439
  ],
@@ -1854,6 +1857,9 @@
1854
1857
  "v2-webhook-unregister-stops-delivery.test.ts": [
1855
1858
  2
1856
1859
  ],
1860
+ "v2-webhooks-v1-scheme-listed.test.ts": [
1861
+ 2
1862
+ ],
1857
1863
  "v2-well-known-one-resource.test.ts": [
1858
1864
  2
1859
1865
  ],
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.45.28",
5
- "corpusTag": "v2.45.28",
4
+ "version": "2.45.30",
5
+ "corpusTag": "v2.45.30",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "1408a344c243a2a54de2f19b839efd2a9ad45ffa94ab72c2b3e352cbce2804f3",
13
- "api/v2/asyncapi.yaml": "84aa6ad5580f097da601bb257a859c8c4ef745051c77d39b5de222c59734a89b",
14
- "api/v2/openapi.yaml": "3a85fa3b6d637123554921da4e003a69aabe2c1dc3144b0dd090b68cf0301553",
12
+ "api/seams-v2.yaml": "87950b612a26253dfce8dd55703b32ca164e61c56ba6d038e40626e3e6d69b1e",
13
+ "api/v2/asyncapi.yaml": "99aef28ec33b361d146d2b176714122f95c444967de4993f416f54b17a37ac81",
14
+ "api/v2/openapi.yaml": "97ed2c29a064cfce269dc746503e4059c42093e64590ae828de9f392917698dc",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -118,7 +118,7 @@
118
118
  "schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
119
119
  "schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
120
120
  "schemas/v2/capabilities.schema.json": "d859a413ff463ec39acc32a9cb76f2e8c7419e45683d6b7f886861765a9c36c7",
121
- "schemas/v2/certification-bundle.schema.json": "93fcdfe54b1db168b702bfaf632b60c7a8a602be92c3fe48c81cf25408c2332b",
121
+ "schemas/v2/certification-bundle.schema.json": "5cfa7fb5247160333e33a58b60d42bcd4e5269d8391d6a112d301e14828892ed",
122
122
  "schemas/v2/channel-presence-payload.schema.json": "fc03df0c20cc12a25f7ee6a47503a4c5931d79fe3d5ff24815e8fd7c1bcb9a87",
123
123
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
124
124
  "schemas/v2/chat-card-pack-manifest.schema.json": "92a57db2fd4b2463f14ef155fef8e3f18afc78960b646459dcf32efc79ff6bf5",
@@ -208,14 +208,14 @@
208
208
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
209
209
  "spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
210
210
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
211
- "spec/v1/core-standard-manifest.json": "54ae669965b91b6526ce05a6dcc4ce7bbf419dba030e9f23edeb174b1806b3c2",
211
+ "spec/v1/core-standard-manifest.json": "1b343f616416e2b92ae3c085bc147f6fb23aa9d366a4488431207e3d0f86bd97",
212
212
  "spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
213
213
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
214
214
  "spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
215
215
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
216
216
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
217
217
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
218
- "spec/v1/gaps.json": "a0aec8f5ddf1002d46a461a9c7d930c8f0656a19b68bb1f52d183b97cc1962cf",
218
+ "spec/v1/gaps.json": "44a1f30d4dd851ac7609fef7dfbd3f7e55404f97130dbe094132f9cd4b71c468",
219
219
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
220
220
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
221
221
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -254,7 +254,7 @@
254
254
  "spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
255
255
  "spec/v2/corrections.json": "3a8d93e33865b692d26a3b6bfd3819766e206b2e0c2a69669baa4bf833d05a49",
256
256
  "spec/v2/corrections.schema.json": "46bbffb954d13bd9995aebc912ceeb2544f7064331abf25fd6ab85c81a5cec94",
257
- "spec/v2/declaration.json": "396ac924068f25d4a178062a54e244f870c556c3db9fe7a98fcc933fb318ed30",
257
+ "spec/v2/declaration.json": "22530d99c92b9e0d3ae0dce25507f4cc6350486d560ad8cc01acda054e9face3",
258
258
  "spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
259
259
  "spec/v2/eos-override.json": "4c72d2f6a658943b01c1de1643cf294642091933af7832f38ee5495df684cb2d",
260
260
  "spec/v2/errors.json": "1f0e29455edbd22e28f67f221766209c3c55ac4987beb77993b5702dd7cae131",
@@ -312,10 +312,10 @@
312
312
  "spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
313
313
  "spec/v2/path-manifest.json": "7b5679a4dad98c80d855361d88a98c8a4e045af15e310862a05b285b815dc9fc",
314
314
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
315
- "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
316
- "spec/v2/release.json": "1001b6d3376418eda319c7bbd6a1f77f45f39d3574df0b977ca474c1f4f3622c",
315
+ "spec/v2/profiles.json": "3d84d7712e93d1e823e362615d5f4aaba0789ea4e044b3774965876fbb8829a7",
316
+ "spec/v2/release.json": "b7dea408ef966d034c55aeb0987fe926d88c4009479bd92816c7c87acfb384cf",
317
317
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
318
- "spec/v2/surface-baseline.json": "9553399d274a2d244ba5ac4de7ccc97b6d7ade51ebb92303938fe684d4cb4d2f"
318
+ "spec/v2/surface-baseline.json": "5f58750fdbd55de45cf31c1f7dee09f68c7b1a40c86d20a046f8c2bc52c0c740"
319
319
  },
320
- "corpusCommit": "19b9fd8ea3ae511f8d1b461e99d55564db3857e0"
320
+ "corpusCommit": "0f13b5191bd0e29c8b4a8219a059361e27834ea2"
321
321
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Purpose labels on the onward hop, judged from what the suite's A2A peer received
3
+ * (`spec/v2/core/security-defaults.md` §Onward hops; fixture `conformance-purpose-relay`).
4
+ *
5
+ * The scenario labels an inbound A2A message, tags its text with a nonce, and reads
6
+ * the peer's `SendMessage` calls. These functions find the onward message for a nonce
7
+ * and judge it, so a self-test double can show each defect failing its leg.
8
+ */
9
+
10
+ /** One `SendMessage` the peer received: its JSON-RPC body. */
11
+ export interface PeerSend { readonly body: unknown }
12
+
13
+ type Msg = { parts?: unknown; metadata?: { openwop?: { permittedPurposes?: unknown } } };
14
+
15
+ function messageOf(body: unknown): Msg | undefined {
16
+ return (body as { params?: { message?: Msg } } | undefined)?.params?.message;
17
+ }
18
+
19
+ /** The text parts of a message body, joined. A2A 1.0 `{ text }` and 0.3 `{ kind: 'text', text }` both count. */
20
+ export function textOf(body: unknown): string {
21
+ const parts = messageOf(body)?.parts;
22
+ if (!Array.isArray(parts)) return '';
23
+ return parts.map((p) => (typeof (p as { text?: unknown })?.text === 'string' ? (p as { text: string }).text : '')).join('');
24
+ }
25
+
26
+ /** The onward sends carrying `nonce` in their text: the relay of one inbound message. */
27
+ export function relaysOf(sends: readonly PeerSend[], nonce: string): PeerSend[] {
28
+ return sends.filter((s) => textOf(s.body).includes(nonce));
29
+ }
30
+
31
+ /** The label an onward message carries, as sent. */
32
+ export function labelOf(body: unknown): unknown {
33
+ return messageOf(body)?.metadata?.openwop?.permittedPurposes;
34
+ }
35
+
36
+ export type Verdict = { ok: true } | { ok: false; reason: string };
37
+
38
+ /** A received label MUST be re-emitted on the onward message, narrowing and never widening. */
39
+ export function judgeReemit(received: readonly string[], onward: unknown): Verdict {
40
+ if (!Array.isArray(onward)) return { ok: false, reason: `the onward message carries no metadata.openwop.permittedPurposes (got ${JSON.stringify(onward)})` };
41
+ const widened = onward.map(String).filter((p) => !received.includes(p));
42
+ if (widened.length > 0) return { ok: false, reason: `the onward label widens the received one ${JSON.stringify(received)}: it adds ${JSON.stringify(widened)}` };
43
+ return { ok: true };
44
+ }
45
+
46
+ /**
47
+ * `[]` means no onward use. `controlRelays` is what an unlabelled control message
48
+ * produced: with none, a missing relay under `[]` proves nothing (`blocked`).
49
+ */
50
+ export function judgeEmpty(controlRelays: number, emptyRelays: number): Verdict | 'blocked' {
51
+ if (controlRelays === 0) return 'blocked';
52
+ if (emptyRelays > 0) return { ok: false, reason: `the peer received ${emptyRelays} message(s) relaying the []-labelled data` };
53
+ return { ok: true };
54
+ }
@@ -10,20 +10,19 @@
10
10
  * state, at }`, content-free of provider payloads (RFC 0150 §B; RFC 0173 §B row
11
11
  * C6.7; `spec/v2/core/security-defaults.md` §Layer-2 effect identity).
12
12
  *
13
- * Legs:
14
- * 1. the ledger read validates on a run of the noop fixture and every row's
15
- * `keying` is one of the two documented modes;
16
- * 2. the "same provider key across two transport retries" leg needs the
17
- * suite's fixture provider (RFC 0173 §D.2 G4 — a provider that rejects a
18
- * changed key) driven through the seams profile; no such seam is
19
- * catalogued, so that leg records `blocked` naming it.
13
+ * One leg: the "same provider key across two transport retries" leg, driven
14
+ * through the seams profile (`forceEffectTransportRetry`, RFC 0173 §D.2 G4).
15
+ * This file is a floor of `openwop-conformance-seams-v2`, so every leg in it
16
+ * MUST be seam-driven (`coherence/seams-floor-legs-gated.test.ts`). The
17
+ * seam-free ledger-read leg lives in `v2-effect-ledger-keying.test.ts` since
18
+ * 2.45.30: here it passed the floor on hosts that mount no seams.
20
19
  *
21
20
  * @see spec/v2/core/security-defaults.md §Layer-2 effect identity
22
21
  */
23
22
 
24
23
  import { describe, it, expect } from 'vitest';
25
24
  import { driver } from '../lib/driver.js';
26
- import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
25
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
27
26
  import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
28
27
  import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
29
28
  import { startEffectReceiver } from '../lib/effect-receiver.js';
@@ -38,85 +37,14 @@ import { req } from '../lib/requirement-ids.js';
38
37
  */
39
38
  export const REQUIRES_HOST_CALLBACK = 'the host makes the retried outbound effect call to the suite-owned effect receiver (OPENWOP_WEBHOOK_RECEIVER_PORT)';
40
39
 
41
- const FIXTURE = 'conformance-noop';
42
40
  const KEYING = ['business-identity', 'activity-recipe'];
43
- const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
44
41
 
45
42
  async function discovery(): Promise<Record<string, unknown> | null> {
46
43
  try { return await v2Discovery(); } catch { return null; }
47
44
  }
48
45
 
49
- async function waitTerminal(runId: string, timeoutMs: number): Promise<void> {
50
- const deadline = Date.now() + timeoutMs;
51
- while (Date.now() < deadline) {
52
- const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
53
- if (res.status === 200 && TERMINAL.has(String((res.json as { status?: unknown } | null)?.status))) return;
54
- await new Promise((r) => setTimeout(r, 250));
55
- }
56
- }
57
46
 
58
47
  describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)', () => {
59
- it('GET /runs/{runId}/effects validates and every row is keyed on business identity or the activity recipe', async () => {
60
- const doc = await discovery();
61
- if (!doc) return softSkip('blocked', 'discovery unreachable');
62
- if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — no Layer-2 obligation (gate recorded under openwop.family.idempotency)');
63
- const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
64
- if (!fixtures.includes(FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to read`);
65
-
66
- // Unfailable-leg audit (2026-09-26): the noop fixture issues no effects, so
67
- // the per-row loop asserted nothing on any host, and the trailing
68
- // `softSkip('inapplicable')` (called without `return`, after four passing
69
- // setup asserts) recorded `executed-pass` with a `partial-witness:` detail —
70
- // a host that mis-keyed every effect passed this row. Now no expect passes
71
- // before the empty-ledger check: a failing setup read still fails, and an
72
- // empty but well-formed ledger returns `inapplicable` with zero assertions.
73
- const create = await driver.post('/runs', { workflowId: FIXTURE });
74
- if (create.status !== 201) {
75
- expect(create.status, req('openwop.requirement.0173.effect-identity-business-key', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
76
- }
77
- const runId = (create.json as { runId: string }).runId;
78
- await waitTerminal(runId, 10_000);
79
-
80
- const res = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
81
- const check = v2Validator('effect-ledger-projection')(res.json);
82
- const body = res.json as { runId?: unknown; effects?: Array<{ effectId?: unknown; keying?: unknown; providerKey?: unknown }> } | null;
83
- const effects = body?.effects ?? [];
84
- if (res.status === 200 && check.ok && body?.runId === runId && effects.length === 0) {
85
- // partial-witness-ok: no assertion has passed at runtime. The only earlier
86
- // expect runs solely when the create is not 201, and then it throws, so this
87
- // skip is a zero-assertion `inapplicable` (the gate reads source order).
88
- return softSkip('inapplicable', 'the noop fixture issued no external effect — the ledger read is well-formed but the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
89
- }
90
- expect(
91
- res.status,
92
- req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', 'a host advertising `idempotency` MUST serve GET /runs/{runId}/effects with 200 (RFC 0173 §B)'),
93
- ).toBe(200);
94
- expect(
95
- check.ok,
96
- req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json', `the ledger projection MUST validate: ${check.errors}`),
97
- ).toBe(true);
98
- expect(body?.runId, req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
99
- const ids = new Set<string>();
100
- for (const e of effects) {
101
- expect(
102
- KEYING,
103
- req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', `keying MUST be business-identity or activity-recipe (the documented fallback) — effect ${String(e.effectId)} declares ${String(e.keying)}`),
104
- ).toContain(e.keying);
105
- // One logical effect id per effect: a duplicate row is a re-assignment.
106
- expect(
107
- ids.has(String(e.effectId)),
108
- req('openwop.requirement.0173.effect-identity-business-key', 'RFC 0150 §B', `effectId ${String(e.effectId)} MUST be assigned once per effect (duplicate ledger row)`),
109
- ).toBe(false);
110
- ids.add(String(e.effectId));
111
- if (typeof e.providerKey === 'string') {
112
- expect(
113
- /(secret|bearer |sk-[a-z0-9]{8,})/i.test(e.providerKey),
114
- req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json providerKey', 'providerKey is a redaction-safe identity, never credential material'),
115
- ).toBe(false);
116
- }
117
- }
118
- });
119
-
120
48
  it('the same provider key is presented across two transport retries', async () => {
121
49
  const doc = await discovery();
122
50
  if (!doc) return softSkip('blocked', 'discovery unreachable');
@@ -0,0 +1,102 @@
1
+ /**
2
+ * RFC 0173 §B — the effect ledger read and its keying (suite 2.45.30, target
3
+ * major 2; gated on `idempotency` and the `conformance-noop` fixture).
4
+ *
5
+ * A host that advertises `idempotency` serves `GET /runs/{runId}/effects`
6
+ * (`schemas/v2/effect-ledger-projection.schema.json`); every row's `keying` is
7
+ * `business-identity` or the documented fallback `activity-recipe`, each
8
+ * effect id is assigned once, and `providerKey` is never credential material
9
+ * (RFC 0150 §B; `spec/v2/core/security-defaults.md` §Layer-2 effect identity).
10
+ *
11
+ * This leg needs no seam. Until 2.45.30 it was the first leg of
12
+ * `v2-effect-identity-business-key`, a floor of `openwop-conformance-seams-v2`,
13
+ * and its pass certified that profile on hosts that mount no seams.
14
+ *
15
+ * @see spec/v2/core/security-defaults.md §Layer-2 effect identity
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver } from '../lib/driver.js';
20
+ import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+
24
+ const FIXTURE = 'conformance-noop';
25
+ const KEYING = ['business-identity', 'activity-recipe'];
26
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
27
+
28
+ async function discovery(): Promise<Record<string, unknown> | null> {
29
+ try { return await v2Discovery(); } catch { return null; }
30
+ }
31
+
32
+ async function waitTerminal(runId: string, timeoutMs: number): Promise<void> {
33
+ const deadline = Date.now() + timeoutMs;
34
+ while (Date.now() < deadline) {
35
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
36
+ if (res.status === 200 && TERMINAL.has(String((res.json as { status?: unknown } | null)?.status))) return;
37
+ await new Promise((r) => setTimeout(r, 250));
38
+ }
39
+ }
40
+
41
+ describe('RFC 0173 §B — effect ledger keying (gated on idempotency)', () => {
42
+ it('GET /runs/{runId}/effects validates and every row is keyed on business identity or the activity recipe', async () => {
43
+ const doc = await discovery();
44
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
45
+ if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — no Layer-2 obligation (gate recorded under openwop.family.idempotency)');
46
+ const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
47
+ if (!fixtures.includes(FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to read`);
48
+
49
+ // Unfailable-leg audit (2026-09-26): the noop fixture issues no effects, so
50
+ // the per-row loop asserted nothing on any host, and the trailing
51
+ // `softSkip('inapplicable')` (called without `return`, after four passing
52
+ // setup asserts) recorded `executed-pass` with a `partial-witness:` detail —
53
+ // a host that mis-keyed every effect passed this row. Now no expect passes
54
+ // before the empty-ledger check: a failing setup read still fails, and an
55
+ // empty but well-formed ledger returns `inapplicable` with zero assertions.
56
+ const create = await driver.post('/runs', { workflowId: FIXTURE });
57
+ if (create.status !== 201) {
58
+ expect(create.status, req('openwop.requirement.0173.effect-identity-business-key', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
59
+ }
60
+ const runId = (create.json as { runId: string }).runId;
61
+ await waitTerminal(runId, 10_000);
62
+
63
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
64
+ const check = v2Validator('effect-ledger-projection')(res.json);
65
+ const body = res.json as { runId?: unknown; effects?: Array<{ effectId?: unknown; keying?: unknown; providerKey?: unknown }> } | null;
66
+ const effects = body?.effects ?? [];
67
+ if (res.status === 200 && check.ok && body?.runId === runId && effects.length === 0) {
68
+ // partial-witness-ok: no assertion has passed at runtime. The only earlier
69
+ // expect runs solely when the create is not 201, and then it throws, so this
70
+ // skip is a zero-assertion `inapplicable` (the gate reads source order).
71
+ return softSkip('inapplicable', 'the noop fixture issued no external effect — the ledger read is well-formed but the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
72
+ }
73
+ expect(
74
+ res.status,
75
+ req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', 'a host advertising `idempotency` MUST serve GET /runs/{runId}/effects with 200 (RFC 0173 §B)'),
76
+ ).toBe(200);
77
+ expect(
78
+ check.ok,
79
+ req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json', `the ledger projection MUST validate: ${check.errors}`),
80
+ ).toBe(true);
81
+ expect(body?.runId, req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
82
+ const ids = new Set<string>();
83
+ for (const e of effects) {
84
+ expect(
85
+ KEYING,
86
+ req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', `keying MUST be business-identity or activity-recipe (the documented fallback) — effect ${String(e.effectId)} declares ${String(e.keying)}`),
87
+ ).toContain(e.keying);
88
+ // One logical effect id per effect: a duplicate row is a re-assignment.
89
+ expect(
90
+ ids.has(String(e.effectId)),
91
+ req('openwop.requirement.0173.effect-identity-business-key', 'RFC 0150 §B', `effectId ${String(e.effectId)} MUST be assigned once per effect (duplicate ledger row)`),
92
+ ).toBe(false);
93
+ ids.add(String(e.effectId));
94
+ if (typeof e.providerKey === 'string') {
95
+ expect(
96
+ /(secret|bearer |sk-[a-z0-9]{8,})/i.test(e.providerKey),
97
+ req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json providerKey', 'providerKey is a redaction-safe identity, never credential material'),
98
+ ).toBe(false);
99
+ }
100
+ }
101
+ });
102
+ });
@@ -1,41 +1,53 @@
1
1
  /**
2
2
  * v2 — purpose labels on the onward hop (`spec/v2/core/security-defaults.md`
3
- * §Onward hops; suite 2.45.23, target major 2; seam-gated).
3
+ * §Onward hops; suite 2.45.23, target major 2; relay path suite 2.45.29).
4
4
  *
5
5
  * A host advertising `purposePropagation` MUST re-emit a `permittedPurposes`
6
6
  * label it received on every onward hop of the same data, narrowing and never
7
- * widening, and MUST treat `[]` as no onward use. The v1 twin read a
8
- * self-reported forward seam; this file observes a real onward hop instead.
9
- * The suite plays both ends: it hands the host a label through the §22 `invoke`
10
- * seam (`permittedPurposes`), and its fake A2A peer receives what the host's
11
- * production A2A client actually sends.
7
+ * widening, and MUST treat `[]` as no onward use. The suite plays both ends.
12
8
  *
13
- * onward-reemit-no-widen the onward message carries
14
- * `metadata.openwop.permittedPurposes`, a subset of
15
- * the label sent;
16
- * empty-no-onward with `[]` the host makes no onward call, while an
17
- * unlabelled control call through the same seam
18
- * reaches the peer (non-arrival is evidence, not a
19
- * dead pipe).
9
+ * Two ways in, the first preferred:
20
10
  *
21
- * Not here: the derived-output rule (a merge of labelled inputs carries their
22
- * intersection) needs two inputs on one hop; the seam forwards one.
11
+ * relay normative operations only. The suite sends an A2A `SendMessage`,
12
+ * labelled with `metadata.openwop.permittedPurposes`, to the agent whose
13
+ * card routes the fixture `conformance-purpose-relay` (found through
14
+ * `GET /agents` and `GetExtendedAgentCard`, `interop.md` §Per-agent
15
+ * cards). The fixture relays the message's text to the suite's shared
16
+ * fake A2A peer, which reads the onward label. A nonce in the text
17
+ * matches each onward message to its inbound one.
18
+ * seam the §22 `invoke` seam takes `permittedPurposes` and a peer URL.
19
+ *
20
+ * onward-reemit-no-widen the onward message carries the label, a subset of
21
+ * the one received;
22
+ * empty-no-onward with `[]` nothing reaches the peer, while an
23
+ * unlabelled control message does (non-arrival is
24
+ * evidence, not a dead pipe).
25
+ *
26
+ * Not here: a merge of labelled inputs carrying their intersection is RFC 0128
27
+ * §3 text that v2 prose does not state.
23
28
  *
24
29
  * Dispositions: discovery unreadable ⇒ `blocked`; `purposePropagation` absent,
25
- * `propagatesOnward` false, `a2a` absent or no seams profile ⇒ `inapplicable`;
26
- * seam unwired ⇒ `seamAbsent`.
30
+ * `propagatesOnward` false or `a2a` absent ⇒ `inapplicable`; neither the relay
31
+ * (`a2a-1.0` + `agentCards` + the fixture advertised) nor the seams profile ⇒
32
+ * `inapplicable`; the fixture advertised but no card routing it, or the suite's
33
+ * fake peer not running ⇒ `blocked`; seam unwired ⇒ `seamAbsent`. Each note names
34
+ * the path used.
27
35
  *
28
36
  * @see spec/v2/core/security-defaults.md §Onward hops
37
+ * @see conformance/fixtures.md §conformance-purpose-relay
29
38
  * @see spec/v1/host-sample-test-seams.md §22
30
39
  */
31
40
 
32
41
  import { describe, it, expect } from 'vitest';
42
+ import { randomBytes } from 'node:crypto';
33
43
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
34
44
  import { v2Discovery, familyAdvertised } from '../lib/v2.js';
35
45
  import { SEAMS_PREFIX, seamsProfileAdvertised } from '../lib/seams.js';
46
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
36
47
  import { softSkip, seamAbsent } from '../lib/soft-skip.js';
37
48
  import { req } from '../lib/requirement-ids.js';
38
- import { A2AFakePeer, type A2APeerInvocation } from '../lib/a2a-fake-peer.js';
49
+ import { A2AFakePeer, getA2AFakePeer, type A2APeerInvocation } from '../lib/a2a-fake-peer.js';
50
+ import { relaysOf, labelOf, judgeReemit, judgeEmpty, type PeerSend } from '../lib/purpose-relay-witness.js';
39
51
 
40
52
  export const REQUIRES_HOST_CALLBACK = "the host's A2A client calls the suite's fake A2A peer";
41
53
 
@@ -43,6 +55,7 @@ const DOC = 'spec/v2/core/security-defaults.md §Onward hops';
43
55
  const ID_REEMIT = 'openwop.requirement.purpose-propagation.onward-reemit-no-widen';
44
56
  const ID_EMPTY = 'openwop.requirement.purpose-propagation.empty-no-onward';
45
57
  const SEAM = `${SEAMS_PREFIX}/sample/a2a/invoke`;
58
+ const RELAY = 'conformance-purpose-relay';
46
59
  const LABEL = ['openwop-conformance-analytics', 'openwop-conformance-support'];
47
60
 
48
61
  async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
@@ -52,68 +65,120 @@ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse
52
65
  type Skip = { kind: 'blocked' | 'inapplicable' | 'seam'; reason: string };
53
66
  const skip = (s: Skip): undefined => (s.kind === 'seam' ? seamAbsent(s.reason) : softSkip(s.kind, s.reason));
54
67
 
55
- async function gate(): Promise<Skip | null> {
68
+ /** The relay agent: the host's JSONRPC 1.0 interface and the agent's routing value. */
69
+ interface Relay { kind: 'relay'; rpcUrl: string; tenant: string; peer: A2AFakePeer }
70
+ type Path = Relay | { kind: 'seam' };
71
+
72
+ async function rpc(url: string, method: string, params: unknown): Promise<{ status: number; result?: Record<string, unknown>; error?: unknown } | null> {
73
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': '1.0' };
74
+ const key = process.env['OPENWOP_API_KEY'];
75
+ if (key) headers['authorization'] = `Bearer ${key}`;
76
+ try {
77
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params }) });
78
+ const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: unknown };
79
+ return { status: res.status, ...(body.result !== undefined ? { result: body.result } : {}), ...(body.error !== undefined ? { error: body.error } : {}) };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /** Find the agent whose card routes the relay fixture; a Skip when the relay cannot be driven. */
86
+ async function relayPath(a2a: Record<string, unknown>): Promise<Relay | Skip> {
87
+ const peer = getA2AFakePeer();
88
+ if (peer === null) return { kind: 'blocked', reason: `relay path: the host advertises ${RELAY}, whose onward peer is the suite's fake A2A peer, and OPENWOP_A2A_FAKE_PEER is not enabled` };
89
+ const cardUrl = a2a['agentCardUrl'];
90
+ if (typeof cardUrl !== 'string') return { kind: 'blocked', reason: 'relay path: a2a.agentCardUrl is not advertised' };
91
+ const card = await fetch(cardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } }).then((r) => r.json() as Promise<{ supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }> }>).catch(() => null);
92
+ const rpcUrl = card?.supportedInterfaces?.find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0')?.url;
93
+ if (typeof rpcUrl !== 'string') return { kind: 'blocked', reason: `relay path: the public card at ${cardUrl} lists no JSONRPC 1.0 interface` };
94
+ const inv = await http(() => driver.get('/agents'));
95
+ const agents = inv?.status === 200 ? ((inv.json as { agents?: Array<{ a2aTenant?: unknown }> } | undefined)?.agents ?? []) : [];
96
+ for (const a of agents) {
97
+ if (typeof a.a2aTenant !== 'string') continue;
98
+ const c = await rpc(rpcUrl, 'GetExtendedAgentCard', { tenant: a.a2aTenant });
99
+ const skills = (c?.result?.['skills'] as Array<{ id?: unknown }> | undefined) ?? [];
100
+ if (skills.length === 1 && skills[0]?.id === RELAY) return { kind: 'relay', rpcUrl, tenant: a.a2aTenant, peer };
101
+ }
102
+ return { kind: 'blocked', reason: `relay path: the host advertises ${RELAY}, but no agent in the caller's GET /agents has a card whose only skill is ${RELAY}` };
103
+ }
104
+
105
+ async function gate(): Promise<Path | Skip> {
56
106
  let doc: Record<string, unknown> | null;
57
107
  try { doc = await v2Discovery(); } catch { doc = null; }
58
108
  if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable' };
59
109
  const pp = await familyAdvertised('purposePropagation');
60
110
  if (pp === null) return { kind: 'inapplicable', reason: 'the host does not advertise purposePropagation' };
61
111
  if (pp['propagatesOnward'] === false) return { kind: 'inapplicable', reason: 'purposePropagation.propagatesOnward is false — the host has no onward hop' };
62
- if (!(await familyAdvertised('a2a'))) return { kind: 'inapplicable', reason: 'a2a not advertised — the onward hop observed here is the host A2A client' };
63
- if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'the onward hop is driven through the §22 invoke seam — the seams profile is not advertised' };
64
- return null;
112
+ const a2a = await familyAdvertised('a2a');
113
+ if (a2a === null) return { kind: 'inapplicable', reason: 'a2a not advertised — the onward hop observed here is the host A2A client' };
114
+ const profiles = Array.isArray(a2a['profiles']) ? (a2a['profiles'] as unknown[]) : [];
115
+ if (profiles.includes('a2a-1.0') && a2a['agentCards'] === true && isFixtureAdvertised(RELAY)) return relayPath(a2a);
116
+ if (seamsProfileAdvertised(doc)) return { kind: 'seam' };
117
+ return { kind: 'inapplicable', reason: `neither path is offered: the relay needs the a2a-1.0 profile, a2a.agentCards and the ${RELAY} fixture; the §22 invoke seam needs the seams profile` };
65
118
  }
66
119
 
67
120
  /** The message sends the peer received. */
68
121
  const sends = (peer: A2AFakePeer): A2APeerInvocation[] => peer.invocations().filter((i) => i.rpcMethod === 'SendMessage' || i.rpcMethod === 'message/send');
69
122
 
70
- function labelOf(inv: A2APeerInvocation): unknown {
71
- const body = inv.body as { params?: { message?: { metadata?: { openwop?: { permittedPurposes?: unknown } } } } } | undefined;
72
- return body?.params?.message?.metadata?.openwop?.permittedPurposes;
123
+ /**
124
+ * Cause one onward hop of `nonce`, labelled or not. Relay: an inbound SendMessage,
125
+ * which blocks until the run is terminal (interop-map a2a SendMessage). Seam: §22.
126
+ * Returns the onward sends carrying the nonce, or a Skip.
127
+ */
128
+ async function cause(path: Path, peer: A2AFakePeer, nonce: string, permittedPurposes?: string[]): Promise<PeerSend[] | Skip> {
129
+ if (path.kind === 'relay') {
130
+ const message = { messageId: `purpose-${nonce}`, role: 'ROLE_USER', parts: [{ text: `relay ${nonce}` }], ...(permittedPurposes ? { metadata: { openwop: { permittedPurposes } } } : {}) };
131
+ const r = await rpc(path.rpcUrl, 'SendMessage', { tenant: path.tenant, message });
132
+ if (r === null) return { kind: 'blocked', reason: `relay path: SendMessage to ${path.rpcUrl} failed to connect` };
133
+ if (r.status >= 400 && permittedPurposes?.length !== 0) return { kind: 'blocked', reason: `relay path: SendMessage answered HTTP ${r.status}` };
134
+ } else {
135
+ const res = await http(() => driver.post(SEAM, { peerUrl: peer.hostFacingEndpoint(), ...(permittedPurposes ? { permittedPurposes } : {}) }));
136
+ if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
137
+ if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §22` };
138
+ // The seam's onward message carries no nonce: every send after the call is its relay.
139
+ return sends(peer).map((i) => ({ body: i.body }));
140
+ }
141
+ return relaysOf(sends(peer).map((i) => ({ body: i.body })), nonce);
73
142
  }
74
143
 
75
- async function invoke(peer: A2AFakePeer, permittedPurposes?: string[]): Promise<OpenWOPResponse | Skip> {
76
- const res = await http(() => driver.post(SEAM, { peerUrl: peer.hostFacingEndpoint(), ...(permittedPurposes ? { permittedPurposes } : {}) }));
77
- if (res === null) return { kind: 'blocked', reason: `${SEAM} unreachable (fetch failed)` };
78
- if (res.status === 404 || res.status === 405) return { kind: 'seam', reason: `${SEAM} not mounted (${res.status}) — host-sample-test-seams.md §22` };
79
- return res;
80
- }
144
+ const nonce = (): string => `n-${randomBytes(6).toString('hex')}`;
81
145
 
82
- describe('v2 purpose labels on the onward hop (security-defaults.md §Onward hops — seam-gated)', () => {
146
+ describe('v2 purpose labels on the onward hop (security-defaults.md §Onward hops)', () => {
83
147
  it('a received label is re-emitted on the onward message, never widened', async () => {
84
148
  const g = await gate();
85
- if (g) return skip(g);
86
- const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
87
- await peer.start();
149
+ if ('reason' in g) return skip(g);
150
+ const peer = g.kind === 'relay' ? g.peer : new A2AFakePeer({ protocolVersions: ['1.0'] });
151
+ if (g.kind === 'seam') await peer.start();
88
152
  try {
89
- const res = await invoke(peer, LABEL);
90
- if (!('status' in res)) return skip(res);
91
- const sent = sends(peer)[0];
92
- if (sent === undefined) return softSkip('blocked', `the seam answered ${res.status} and the suite peer received no message — the host's A2A client never reached it`);
93
- const label = labelOf(sent);
94
- expect(Array.isArray(label), req(ID_REEMIT, DOC, `the onward message MUST carry the received label as metadata.openwop.permittedPurposes (got ${JSON.stringify(label)})`)).toBe(true);
95
- const widened = (Array.isArray(label) ? label : []).filter((p) => !LABEL.includes(String(p)));
96
- expect(widened, req(ID_REEMIT, DOC, `the onward label MUST NOT widen the received one (${JSON.stringify(LABEL)}); it added ${JSON.stringify(widened)}`)).toEqual([]);
153
+ const relayed = await cause(g, peer, nonce(), LABEL);
154
+ if (!Array.isArray(relayed)) return skip(relayed);
155
+ const sent = relayed[0];
156
+ if (sent === undefined) return softSkip('blocked', `${g.kind} path: the labelled data never reached the suite peer, so there is no onward message to read`);
157
+ const v = judgeReemit(LABEL, labelOf(sent.body));
158
+ expect(v.ok ? '' : v.reason, req(ID_REEMIT, DOC, `${g.kind} path: the onward message MUST carry the received label as metadata.openwop.permittedPurposes, never widened`)).toBe('');
97
159
  } finally {
98
- await peer.stop();
160
+ if (g.kind === 'seam') await peer.stop();
99
161
  }
100
162
  });
101
163
 
102
164
  it('an empty label means no onward call, while an unlabelled control call goes through', async () => {
103
165
  const g = await gate();
104
- if (g) return skip(g);
105
- const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
106
- await peer.start();
166
+ if ('reason' in g) return skip(g);
167
+ const peer = g.kind === 'relay' ? g.peer : new A2AFakePeer({ protocolVersions: ['1.0'] });
168
+ if (g.kind === 'seam') await peer.start();
107
169
  try {
108
- const control = await invoke(peer);
109
- if (!('status' in control)) return skip(control);
170
+ const control = await cause(g, peer, nonce());
171
+ if (!Array.isArray(control)) return skip(control);
110
172
  const before = sends(peer).length;
111
- if (before === 0) return softSkip('blocked', `the unlabelled control call answered ${control.status} but reached no peer, so a missing call under [] would prove nothing`);
112
- const res = await invoke(peer, []);
113
- if (!('status' in res)) return skip(res);
114
- expect(sends(peer).length - before, req(ID_EMPTY, DOC, `a host MUST treat [] as no onward use: the seam answered ${res.status}, and the peer received ${sends(peer).length - before} message(s) for the []-labelled data`)).toBe(0);
173
+ const empty = await cause(g, peer, nonce(), []);
174
+ if (!Array.isArray(empty)) return skip(empty);
175
+ // The seam's sends carry no nonce: count what arrived after the control.
176
+ const emptyRelays = g.kind === 'seam' ? sends(peer).length - before : empty.length;
177
+ const v = judgeEmpty(control.length, emptyRelays);
178
+ if (v === 'blocked') return softSkip('blocked', `${g.kind} path: the unlabelled control message reached no peer, so a missing call under [] would prove nothing`);
179
+ expect(v.ok ? '' : v.reason, req(ID_EMPTY, DOC, `${g.kind} path: a host MUST treat [] as no onward use`)).toBe('');
115
180
  } finally {
116
- await peer.stop();
181
+ if (g.kind === 'seam') await peer.stop();
117
182
  }
118
183
  });
119
184
  });
@@ -7,8 +7,10 @@
7
7
  * HMAC-SHA256 with the subscription secret); this adds no signature scheme and
8
8
  * per-subscription secrets are unchanged across the cut (`spec/v2/core/webhooks.md`
9
9
  * §Dual emission through the overlap; persistence.md §Everything else a v1 host
10
- * persisted; migration row C9.9). The facet's `signatureAlgorithms[]` MUST list
11
- * `"v1"` (webhooks.md §Surfaces).
10
+ * persisted; migration row C9.9). The facet check (`signatureAlgorithms[]` lists
11
+ * `"v1"`) needs no seam and lives in `v2-webhooks-v1-scheme-listed.test.ts`
12
+ * since 2.45.30: this file is a floor of `openwop-conformance-seams-v2`, so
13
+ * every leg in it MUST be seam-driven (`coherence/seams-floor-legs-gated.test.ts`).
12
14
  *
13
15
  * The receiver under test is the HOST's inbound receiver path — the v2 host as
14
16
  * a subscriber (its trigger bridge, its A2A/MCP peer callbacks) — which the
@@ -76,16 +78,6 @@ async function deliver(secret: string, delivery: { headers: Record<string, strin
76
78
  }
77
79
 
78
80
  describe('RFC 0176 §D.2 — v1-signed-webhook-accepted (gated on webhooks + seams)', () => {
79
- it('the webhooks facet lists scheme "v1" — the scheme a v1-signed delivery is verified under', async () => {
80
- const doc = await discovery();
81
- if (!doc) return softSkip('blocked', 'discovery unreachable');
82
- const webhooks = await gateFamily('webhooks');
83
- if (!webhooks) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
84
- const algorithms = webhooks['signatureAlgorithms'];
85
- expect(Array.isArray(algorithms), req('openwop.requirement.0176.v1-signed-webhook-accepted.facet', 'spec/v2/core/webhooks.md §Surfaces', 'the webhooks facet is { signatureAlgorithms[] }')).toBe(true);
86
- expect(algorithms, req('openwop.requirement.0176.v1-signed-webhook-accepted.facet', 'spec/v2/core/webhooks.md §Surfaces', 'signatureAlgorithms MUST list "v1" — the cut adds no signature scheme (RFC 0176 §D.2)')).toContain('v1');
87
- });
88
-
89
81
  it('the host\'s v2 receiver path accepts an X-openwop-*-only scheme-v1 delivery and refuses a tampered one', async () => {
90
82
  const doc = await discovery();
91
83
  if (!doc) return softSkip('blocked', 'discovery unreachable');
@@ -0,0 +1,33 @@
1
+ /**
2
+ * RFC 0176 §D.2 — the webhooks facet lists scheme `v1` (suite 2.45.30, target
3
+ * major 2; gated on `webhooks`).
4
+ *
5
+ * The facet's `signatureAlgorithms[]` MUST list `"v1"`: the cut adds no
6
+ * signature scheme, so a v1-signed delivery is verified under it
7
+ * (`spec/v2/core/webhooks.md` §Surfaces). Until 2.45.30 this was the first leg
8
+ * of `v2-v1-signed-webhook-accepted`, a floor of `openwop-conformance-seams-v2`,
9
+ * and its pass certified that profile on hosts that mount no seams.
10
+ *
11
+ * @see spec/v2/core/webhooks.md §Surfaces
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
16
+ import { softSkip } from '../lib/soft-skip.js';
17
+ import { req } from '../lib/requirement-ids.js';
18
+
19
+ async function discovery(): Promise<Record<string, unknown> | null> {
20
+ try { return await v2Discovery(); } catch { return null; }
21
+ }
22
+
23
+ describe('RFC 0176 §D.2 — the webhooks facet lists scheme v1 (gated on webhooks)', () => {
24
+ it('the webhooks facet lists scheme "v1" — the scheme a v1-signed delivery is verified under', async () => {
25
+ const doc = await discovery();
26
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
27
+ const webhooks = await gateFamily('webhooks');
28
+ if (!webhooks) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
29
+ const algorithms = webhooks['signatureAlgorithms'];
30
+ expect(Array.isArray(algorithms), req('openwop.requirement.0176.v1-signed-webhook-accepted.facet', 'spec/v2/core/webhooks.md §Surfaces', 'the webhooks facet is { signatureAlgorithms[] }')).toBe(true);
31
+ expect(algorithms, req('openwop.requirement.0176.v1-signed-webhook-accepted.facet', 'spec/v2/core/webhooks.md §Surfaces', 'signatureAlgorithms MUST list "v1" — the cut adds no signature scheme (RFC 0176 §D.2)')).toContain('v1');
32
+ });
33
+ });