@openwop/openwop-conformance 2.32.0 → 2.33.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -4
  3. package/coverage.md +2 -1
  4. package/dist/cli.js +4 -2
  5. package/dist/lib/certification-bundle-v3.js +12 -2
  6. package/dist/lib/v2-profiles.js +66 -0
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirements.json +58 -14
  10. package/scenario-majors.json +5 -2
  11. package/schemas/CORPUS-STAMP.json +8 -8
  12. package/src/cli.ts +4 -2
  13. package/src/lib/a2a-fake-peer.ts +16 -3
  14. package/src/lib/certification-bundle-v3.ts +11 -2
  15. package/src/lib/durability-watch.ts +53 -0
  16. package/src/lib/mcp-fake-server.ts +13 -0
  17. package/src/lib/v2-profiles.ts +66 -0
  18. package/src/lib/webhook-receiver.ts +24 -5
  19. package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
  20. package/src/scenarios/a2a-peer-authority.test.ts +1 -1
  21. package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
  22. package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
  23. package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
  24. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
  25. package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
  26. package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
  27. package/src/scenarios/v2-durability-recovery.test.ts +13 -21
  28. package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
  29. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  30. package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
  31. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
  32. package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
  33. package/src/scenarios/v2-webhook-delivery-shape.test.ts +45 -8
  34. package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.33.1] — 2026-09-21 — two false fails: a race between two reads, and a subscription nobody unregistered
4
+
5
+ - **`v2-durability-recovery`: the "completed without re-execution" latch was decided from two non-atomic reads in the wrong order.** Each iteration read the LOG, then the STATUS, and latched when the status said `completed` and the — older — log showed no resumption. A host that re-dispatched between the two requests read as "completed un-re-executed" while being neither. `conformance-noop` re-executes in milliseconds, so the window is real: per run roughly (gap between the requests) / (500 ms poll), a few percent. **Measured on a tier-1 host:** after a genuine SIGKILL and a correct recovery by lease expiry (727 s inside a declared 750 s bound) the row failed with *"read status completed with 2 run.started"* — printing the re-execution it was denying, because the message used a later read than the latch. The same host and code had passed the two previous runs.
6
+ - **Status first, log second.** A run's log is append-only, so a log read taken *after* a `completed` status can only show more than the status implied: if it still shows no resumption the defect is real, if it shows resumption nothing was wrong, if it is unreadable nothing is concluded. The loop moves to `src/lib/durability-watch.ts` with injected readers, and `durability-watch.test.ts` pins the ordering without a host — the race reproduced, the genuine defect still latching, the latch staying sticky, and an unreadable log concluding nothing.
7
+ - **The defect only ever produced FALSE FAILS, never false passes** — the latch could be set wrongly, never cleared wrongly — so an `executed-pass` on this row under 2.32.0 or 2.33.0 stands. A host that saw this row fail on those versions should re-run on 2.33.1 before triaging it.
8
+ - Found by the openwop-app host session reading the installed scenario after an unexplained red. Introduced in 2.32.0, by the same change that made the row assert both clauses of §E item 11.
9
+ - **`v2-webhook-delivery-shape` never unregistered anything, and behind a public front that failed a correct host.** On loopback each leg's receiver binds its own ephemeral port, so leg 1's leftover MAJOR-2 subscription delivered to a dead address and nobody noticed. Behind a tunnel every leg shares ONE URL on ONE pinned port: the still-live v2 subscription delivered its v2 rendering into leg 2, and the major-1 leg read it — *"a major-1 run.started payload MUST validate against the V1 definition … engineVersion must be string"* — on a host that had rendered both contracts correctly. It was the ONE failing row of the v2 reference host's first relaxation-free cut (241 pass / 1 fail / 0 blocked). **Reproduced with no ingress at all** by pinning `OPENWOP_WEBHOOK_RECEIVER_PORT` on a loopback run: ephemeral → 3/3, pinned → the major-1 leg fails; after the fix both → 3/3 and the host is left with **0** subscriptions. Every leg now unregisters what it registered, and — belt and braces — a delivery whose `OpenWOP-Webhook-Id` / `X-openwop-Webhook-Id` names a different subscription is not read as this leg's. The file also left a live subscription on every host it ever ran against; MyndHyve's operator had been sweeping them by hand after each tunnelled cut. The other major-2 webhook files were audited and already unregister.
10
+ - Suite patch: corpus release stays `2.33.0`.
11
+
12
+ ## [2.33.0] — 2026-09-21 — a declared relaxation denied nothing, an undeclared one was invisible, and two fakes made both unavoidable
13
+
14
+ Step 2 of closing `docs/KNOWN-LIMITS.md` §"The reference host's certified bundles were cut under an undeclared relaxation". Three suite defects, all the corpus's.
15
+
16
+ - **A host advertising `a2a` or `mcp` could not be cut relaxation-free.** The A2A fake peer and MCP fake server advertised `http://127.0.0.1:<port>` and nothing else; only the webhook receiver had a public front. So such a host could be measured ONLY with its egress guard relaxed, and a relaxed guard cannot certify — the rule made a host implementing *more* of the protocol *less* able to certify. The two production hosts cut cleanly only because they advertise neither family. New `OPENWOP_A2A_FAKE_PEER_URL` / `OPENWOP_MCP_FAKE_SERVER_URL`, validated by the same rule as `OPENWOP_WEBHOOK_RECEIVER_URL` (now `resolvePublicFront(envName, …)`; `resolveRegistrationUrl` is a wrapper). `hostFacingEndpoint()` is what is handed to the host — every invoke seam's `peerUrl` / `serverUrl`, and the AgentCard's `url` fields — while the suite's own requests to its own fake keep using the loopback `endpoint()`. Twelve scenario files updated.
17
+ - **A DECLARED relaxation denied nothing on any real host.** `verifyBundleV3` matched a relaxed obligation to a profile by testing whether the profile **id** contained the family name; the emitter tested whether it contained the **whole obligation string** — two different rules under a comment saying they matched. No real v2 profile id contains a family name, so `webhooks.egress-guard` denied neither `openwop-core-standard` nor anything else. `v2-relaxation-recorded` stayed green because its fixture profile was called `openwop-webhooks`, the one id the rule could match. New `profilesRelaxedBy()` in `lib/v2-profiles.ts` reads ownership from where it was always stated — a profile's `predicate.families` in `spec/v2/profiles.json` — and is the ONE function both the emitter and the verifier call. The id-named arm is kept, so nothing denied today is un-denied. New leg `openwop.requirement.0173.relaxation-recorded.owned-by-registry` asserts against the real `openwop-core-standard`. **Sabotage-proven: restoring the old rule leaves the old leg green and reddens only the new one** — which is the blindness, reproduced.
18
+ - **NEW `v2-webhook-egress-refusal.test.ts` — the egress guard's refusal, at major 2, for the first time.** `webhooks.md` §SSRF is a plain MUST with a registered code, and no major-2 row asserted it: `http-client-ssrf` and `webhook-negative` never left the major-1 lane, so a v2 bundle could certify `openwop-core-standard` on a host whose guard refused nothing. Eight registration probes, one per class the sentence names (non-`https`, IPv4 and IPv6 loopback, `localhost`, two RFC 1918 ranges, link-local / cloud metadata, IPv6 ULA); each MUST answer `400 webhook_url_rejected`. Registration-time only — no probe is a destination the suite owns, nothing is delivered, an accepted registration is deleted at once. New id `openwop.requirement.0171.webhook-egress-refused`.
19
+ - **An UNDECLARED relaxation is now detectable, and denied.** This README said the suite "cannot detect an undeclared one". For the egress guard that was never true: a host that accepts `https://127.0.0.1/…` has shown on the wire that its guard is open. Three outcomes — all refused → `executed-pass`; accepted with a `webhooks.*` relaxation DECLARED → `inapplicable`, the declaration quoted, the owning profile denied by the declaration; accepted with NOTHING declared → `executed-fail`, which `profilesDeniedByObservedRelaxation()` turns into a denial of every profile built on `webhooks` in both the emitter (`certified: false`) and `--verify` (new rejection `undeclared-relaxation-observed`, scoped to the profile). Declaring nothing is no longer a way around the rule.
20
+ - **Measured on the v2 reference host in all three postures:** guarded → pass (8/8 refused, right code); `OPENWOP_WEBHOOK_ALLOW_PRIVATE=1` with nothing declared → **fail, "ACCEPTED 8 of 8"** — that flag opens the scheme check and the metadata address too, not only loopback; same flag with the relaxation declared → `inapplicable`. The first attempt at that measurement reported pass/pass/pass, because the harness had not killed the first host and all three runs hit the guarded one; it now refuses to measure while the port is held.
21
+ - `coverage.md`: new row; and the `v2-durability-recovery` row is corrected — it still said `duplicate-delivery` was "counted per effect identity" and that the log was read from `/events`, both wrong since 2.32.0.
22
+ - **Expect this release to un-certify a loopback cut.** That is its purpose. A host cut through public fronts (both production hosts) is unaffected.
23
+ - Suite pinned to corpus `2.33.0`.
24
+
3
25
  ## [2.32.0] — 2026-09-20 — every published v2 pack failed the corpus schema, and two kill rows that mandated a fast bound
4
26
 
5
27
  - **`v2-one-signing-scheme` now covers every bare `*-pack-manifest`, enumerated rather than listed** (openwop#1367). It pinned one file — `registry-version-manifest` — while six bare manifests kept the v1 `{ publicKeyRef, signatureRef, method }` block, closed, and `connection-pack-manifest` had no `signing` seat at all. Measured against the published v2 registry: **0 of 190** `pack.json` documents validated. New id `openwop.requirement.0177.one-signing-scheme.bare-manifests`. Sabotage-proven: fails on the 2.31.1 `node-pack-manifest`.
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.32.0 @openwop/spec-artifacts@2.32.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.33.1 @openwop/spec-artifacts@2.33.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
  ```
@@ -99,7 +99,7 @@ Run `npm run test` for normal CI cadence; `npm run test:strict` when claiming fu
99
99
 
100
100
  | Variable | Effect |
101
101
  | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
- | `OPENWOP_HOST_RELAXATIONS` | Sets `host.relaxations[]` in the bundle. **It relaxes nothing in the suite** — it is your declaration that the HOST runs relaxed, and the suite cannot detect an undeclared one. A profile carrying a relaxation **cannot certify** (`security-defaults.md` §Relaxations, RFC 0173 §A.2), and since 2.6.0 the emitter enforces that rather than writing `certified: true` and leaving the contradiction to `--verify`. JSON array of `{obligation, durability, reason}`; `durability` is `session \| deployment \| persisted` — **not** `permanent`. Unparseable JSON exits 2; a wrong enum value exits 2 later, at schema validation. |
102
+ | `OPENWOP_HOST_RELAXATIONS` | Sets `host.relaxations[]` in the bundle. **It relaxes nothing in the suite** — it is your declaration that the HOST runs relaxed, and the suite cannot detect an undeclared one **in general**. It can detect ONE: since 2.33.0 `v2-webhook-egress-refusal` registers eight destinations `webhooks.md` §SSRF says a host MUST refuse, and a host that accepts one with no `webhooks.*` relaxation declared here records `executed-fail` — which the emitter and `--verify` read as an observed undeclared relaxation, denying every profile built on `webhooks`. An obligation is `<family>.<name>`; it denies each profile whose `spec/v2/profiles.json` predicate lists that family (so `webhooks.egress-guard` denies `openwop-core-standard`). Until 2.33.0 the match was on the profile *id*, which contains no family name, so a declared relaxation denied nothing on any real host. A profile carrying a relaxation **cannot certify** (`security-defaults.md` §Relaxations, RFC 0173 §A.2), and since 2.6.0 the emitter enforces that rather than writing `certified: true` and leaving the contradiction to `--verify`. JSON array of `{obligation, durability, reason}`; `durability` is `session \| deployment \| persisted` — **not** `permanent`. Unparseable JSON exits 2; a wrong enum value exits 2 later, at schema validation. |
103
103
  | `OPENWOP_TARGET_MAJOR` | Selects the scenario lane. **Only the literal `"2"` selects major 2** — unlike `--target-major`, which validates and exits 2 on a bad value, the env var falls back to major 1 for `"1"`, `"3"`, `"two"` or empty. The runner and every vitest worker MUST see the same value: when they disagreed, the worker minted `openwop.floor.*` while the runner looked up `openwop.scenario.*`, producing a vacuous-pass emitter defect and **no bundle at all**. Prefer `--target-major`; set this only when driving `vitest` directly. |
104
104
  | `OPENWOP_REQUIRE_BEHAVIOR=true` | Capability-gated scenarios (audit-log integrity, rate-limit envelope, multi-region idempotency, `configurableSchema`, webhook sig versioning, etc.) FAIL instead of skipping when the host doesn't advertise the profile. Lets a host claim "full coverage" mechanically. See [`coverage.md`](./coverage.md) §"Capability-gated scenarios". |
105
105
  | `OPENWOP_TEST_PUBLIC_REGISTRY=true` | Runs `registry-public.test.ts` against the hosted registry at `packs.openwop.dev`. Skipped by default so the suite doesn't depend on outbound connectivity. |
@@ -113,6 +113,7 @@ Run `npm run test` for normal CI cadence; `npm run test:strict` when claiming fu
113
113
  | `OPENWOP_MCP_FAKE_SERVER=true` | Boots the synthetic MCP peer for `mcp-tool-roundtrip.test.ts`. |
114
114
  | `OPENWOP_MCP_REAL_SERVER_URL=<base-url>` | Points the MCP wire-shape probe at a real MCP server. The probe POSTs JSON-RPC and reads a single-JSON response — matches MCP's `streamable-http` transport in single-response mode. **Does NOT support** stdio transport (which is what most `modelcontextprotocol/servers` references default to) or SSE-streamed responses; an operator collecting interop evidence today runs a custom `StreamableHTTPServerTransport`-style server that returns a single JSON body per request. Adding SSE-frame parsing is tracked in `docs/PROTOCOL-GAP-CLOSURE-PLAN.md` Track 6. Assertions relax to shape-only. When both this and `OPENWOP_MCP_FAKE_SERVER` are set, the real URL wins. Phase 3 T3.4 interop-evidence path. |
115
115
  | `OPENWOP_A2A_FAKE_PEER=true` | Boots the synthetic A2A peer for `a2a-task-roundtrip.test.ts`. |
116
+ | `OPENWOP_A2A_FAKE_PEER_URL=<https-url>` / `OPENWOP_MCP_FAKE_SERVER_URL=<https-url>` | **The public front for each fake — the route to an A2A / MCP witness that relaxes nothing** (2.33.0). Same rule and the same loud validation as `OPENWOP_WEBHOOK_RECEIVER_URL`: `https:`, publicly resolvable. The fake still LISTENS on loopback; pin it with the matching `_PORT` so the tunnel has a fixed port to forward to. When set, the URL handed to the host under test — the `peerUrl` / `serverUrl` of every invoke seam, and the `url` fields of the A2A AgentCard — is the front; the suite's own requests to its own fake stay local. Until 2.33.0 neither fake had a front, so a host advertising `a2a` or `mcp` could be measured ONLY with its egress guard relaxed, and a relaxed guard cannot certify: the rule made a host implementing more of the protocol less able to certify. |
116
117
  | `OPENWOP_A2A_REAL_PEER_URL=<base-url>` | Points the A2A AgentCard + task-lifecycle probe at a real reference A2A peer. Drift-point subtests (AUTH_REQUIRED / REJECTED) stay fake-peer-only — real peers don't expose a state-forcing API. Phase 3 T3.4 interop-evidence path. |
117
118
  | `OPENWOP_FORCE_RATE_LIMIT=true` | Signals the host (test-only key) to fabricate a 429 so `rate-limit-envelope.test.ts` can exercise envelope shape deterministically. |
118
119
  | `OPENWOP_TEST_PROMPT_PACK_INSTALLED=true` | Promotes the `prompt-pack-install.test.ts` existence claim from soft-skip to hard assertion (RFC 0028 §B). Set when the target host is known to have at least one prompt pack installed at boot — RFC 0028 §B does NOT require any host with `endpointsSupported: true` to have packs installed, so the suite stays conformant against a fresh production host with no pack subscriptions when the flag is unset. The in-tree workflow-engine sample auto-installs `vendor.openwop.prompt-sample` via `promptPackLoader.ts`, so operators running against it should set the flag. |
@@ -132,7 +133,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
132
133
 
133
134
  ## What's Covered
134
135
 
135
- The current suite has 523 scenario files under `src/scenarios/`.
136
+ The current suite has 524 scenario files under `src/scenarios/`.
136
137
  - 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.
137
138
  - 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.
138
139
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -477,7 +478,7 @@ Server-required (added in 1.7.0):
477
478
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
478
479
  | **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. |
479
480
 
480
- Current source tree: 523 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
481
+ Current source tree: 524 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
481
482
 
482
483
  ## Remaining Gaps
483
484
 
package/coverage.md CHANGED
@@ -144,7 +144,8 @@ The scenario groups in the table below (one row per group; count the rows — th
144
144
  | `tool-session-lifecycle.test.ts` | `capabilities.toolCatalog.sessionLifecycle` (RFC 0078 §D, `tool-catalog.md`) | A (the §D bracket via `POST /v1/host/sample/tools/session-run` + the test event-log seam: `tool.session.opened` before the first RFC 0064 call event → `tool.session.closed` after the last, one shared `sessionId`, each carrying a `toolId`, `closed.outcome` ∈ {completed,failed,aborted,expired}, both content-free) | `host-pending` | `behaviorGate('openwop-tool-session-lifecycle', …)`. Seam-gated; soft-skips on 404. **Part of the RFC 0078 → Accepted bar.** First adopter: MyndHyve `toolCatalog`. |
145
145
  | `egress-audience-binding.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §C, `host-capabilities.md`) + `SECURITY/invariants.yaml` `egress-credential-audience-bound` | A (KEYSTONE — the §C confused-deputy MUST via `POST /v1/host/sample/egress/decide`: an out-of-audience egress is `denied`/`downgraded` with `reason:"out-of-audience"` and the credential is NOT attached (`credentialAttached !== true`); a provenance-unevaluable egress fails closed `denied`+`reason:"provenance-unevaluable"`; decision/reason ∈ the closed enums) | `host-pending` | `behaviorGate('openwop-egress-audience-binding', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0079 → Accepted bar** (the `egress-credential-audience-bound` invariant graduates reference-impl → protocol tier when this passes against a host). First adopter: MyndHyve `httpClient.egressPolicy`. |
146
146
  | `egress-decision-content-free.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §F / SR-1) | A (the secret non-leak — a `canary` credential's sentinel never surfaces in the decision (`canaryLeaked !== true`), the `egress.decided` payload carries no forbidden content key, and `reason` stays in the CLOSED vocabulary so no blocked destination spills into a free-form field) | `host-pending` | `behaviorGate('openwop-egress-decision-content-free', …)`. Seam-gated; soft-skips on 404. **Part of the RFC 0079 → Accepted bar.** First adopter: MyndHyve `httpClient.egressPolicy`. |
147
- | `v2-durability-recovery.test.ts` | the RFC 0158 host-extension durability seam `POST /host/durability/kill` (non-normative; §E mints no capability field, so seam PRESENCE is how a host says it claims a rung) | A (all five `durable-single-instance` rows: `kill-after-accept` as a HOLD-DISPATCH exercise, `kill-during-execution` asserting that work executing at a real process death is never observable as completed without re-execution, `duplicate-delivery` counted PER EFFECT IDENTITY rather than by end state, `bound-is-derived` as an explicitly-labelled paper check, and `poison-exhaustion` ported from the major-1 scenario — at major 2 it reads the canonical `GET /runs/{runId}/events`, so unlike its v1 twin it needs NO seam and can never record `blocked` for want of one a host did not wire) | `host-pending` | **Disposition rule, and it protects fleet certification:** no seam route at all ⇒ `inapplicable` (the host claims no rung); seam present but an operator precondition unmet — a restart supervisor, which a black-box suite cannot supply — ⇒ `blocked` with the precondition NAMED. Collapsing the two would deny certification to every host the day these rows entered the lane. `peer-resume` is deliberately NOT written: §E makes it bundle-witnessed, so a scenario could only ever record `blocked`. **This is the RFC 0158 → Accepted bar, and it is now complete at major 2** — before the port, a major-2 bundle could carry at most four of the five rows and the rung was unwitnessable no matter what a host did, and its witness is openwop-app, which the RFC records as having observed `kill-during-execution` across a real `SIGKILL`. |
147
+ | `v2-webhook-egress-refusal.test.ts` | the `webhooks` family (capability-gated) | A (`webhooks.md` §SSRF at MAJOR 2 — eight registration probes, one per class the sentence names: non-`https`, IPv4/IPv6 loopback, `localhost`, two RFC 1918 ranges, link-local / cloud metadata, IPv6 ULA; each MUST answer `400 webhook_url_rejected`. Until 2.33.0 no major-2 row asserted the guard refuses anything — the refusal scenarios were major-1 only. THREE OUTCOMES: all refused → `executed-pass`; a probe accepted with a `webhooks.*` relaxation DECLARED in `OPENWOP_HOST_RELAXATIONS` → `inapplicable`, and the declared relaxation denies the owning profile; a probe accepted with NOTHING declared → `executed-fail`, which the emitter and verifier read as an OBSERVED undeclared relaxation and deny every profile built on `webhooks` (`undeclared-relaxation-observed`). Registration-time only: no probe is a destination the suite owns, nothing is delivered, an accepted registration is deleted at once) |
148
+ | `v2-durability-recovery.test.ts` | the RFC 0158 host-extension durability seam `POST /host/durability/kill` (non-normative; §E mints no capability field, so seam PRESENCE is how a host says it claims a rung) | A (all five `durable-single-instance` rows: `kill-after-accept` as a HOLD-DISPATCH exercise, `kill-during-execution` asserting that work executing at a real process death is never observable as completed without re-execution, `duplicate-delivery` counted AT THE EFFECT'S DESTINATION — a receiver the suite owns, `effectUrl` — because a ledger keyed on effect identity admits one row per identity by construction and cannot show a double-fire (2.32.0), `bound-is-derived` as an explicitly-labelled paper check, and `poison-exhaustion` ported from the major-1 scenario — at major 2 it reads the canonical JSON log read `GET /runs/{runId}/events/poll` (NOT `/events`, which is SSE-only at major 2 — reading it as JSON made this row pass vacuously on a host serving exactly the OpenAPI; 2.32.0), so unlike its v1 twin it needs NO seam and can never record `blocked` for want of one a host did not wire) | `host-pending` | **Disposition rule, and it protects fleet certification:** no seam route at all ⇒ `inapplicable` (the host claims no rung); seam present but an operator precondition unmet — a restart supervisor, which a black-box suite cannot supply — ⇒ `blocked` with the precondition NAMED. Collapsing the two would deny certification to every host the day these rows entered the lane. `peer-resume` is deliberately NOT written: §E makes it bundle-witnessed, so a scenario could only ever record `blocked`. **This is the RFC 0158 → Accepted bar, and it is now complete at major 2** — before the port, a major-2 bundle could carry at most four of the five rows and the rung was unwitnessable no matter what a host did, and its witness is openwop-app, which the RFC records as having observed `kill-during-execution` across a real `SIGKILL`. |
148
149
  | `memory-degraded-projection.test.ts` | `capabilities.agents.manifestRuntime.supported` + `capabilities.memory.supported` (RFC 0080 §C, `agent-memory.md`) | A (the §C iff-contract on the NORMATIVE `GET /v1/agents`: a degraded entry MUST carry `memoryDegraded:true` + a non-empty, unique `degradedMemoryDimensions[]` drawn from the closed §A-name enum [read/write/search/long-term-durability/compaction/attribution/replay-snapshot/retention]; a non-degraded entry MUST NOT carry a non-empty list; the inventory is non-empty; the degraded branch runs non-vacuously when `OPENWOP_DEGRADED_AGENT_ID` names a known-degraded agent) | `host-pending` | `behaviorGate('openwop-memory-degraded', …)`. Black-box on the normative path (no POST seam); soft-skips on 404 / when the host computes no degradation. **This is the RFC 0080 → Accepted bar.** First adopter: MyndHyve `memory`. |
149
150
  | `budget-enforcement.test.ts` | `capabilities.budget.supported` (RFC 0084 §C/§D, `budget-policy.md`) + `SECURITY/invariants.yaml` `budget-no-pricing-leak` | A (the §C/§D enforcement via `POST /v1/host/sample/budget/run` + the test event-log seam: a `hard-cost-exhaust` run emits the strict-ordered `budget.reserved → budget.consumed → budget.threshold.crossed{percent} → budget.exhausted → cap.breached{kind:"budget-cost"} → run.failed{error:"budget_exhausted"}` chain; a `model-denied` run is refused `budget_model_denied` BEFORE the provider call (fail-closed); an `advisory` host emits the `budget.*` events without stopping; every `budget.*` payload content-free — no pricing/rate) | `host-pending` | `behaviorGate('openwop-budget-enforcement', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0084 → Accepted bar.** First adopter: MyndHyve `budget`. |
150
151
  | `agent-platform-aggregate-evidence.test.ts` | `openwop-agent-platform` claim — live discovery `profiles[]` includes it (RFC 0085 §C, `agent-platform-profile.md`) | A (the §C/§D honest-advertisement on live `/.well-known/openwop`: a host claiming `openwop-agent-platform` MUST satisfy the §B floor predicate (`isAgentPlatformPartial` → `partial`/`full`, never `none`), the claim backed by per-capability evidence not the profile string; `OPENWOP_AGENT_PLATFORM_TIER=full` forces the full-predicate bar — all governance terms + tenant installScope + all 16 §D terms) | `host-pending` | `behaviorGate('openwop-agent-platform', …)`. Black-box on the discovery doc (no POST seam); soft-skips until a host claims the profile. **This is the RFC 0085 → Accepted bar.** First adopter: MyndHyve (after the memory batch surfaces the floor's `memory.supported`). |
package/dist/cli.js CHANGED
@@ -46,7 +46,7 @@ import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/cer
46
46
  import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
47
47
  import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
48
48
  import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
49
- import { v2ProfileIds } from './lib/v2-profiles.js';
49
+ import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2ProfileIds } from './lib/v2-profiles.js';
50
50
  import { childEnv } from './lib/child-env.js';
51
51
  function parseArgs(argv) {
52
52
  let baseUrl;
@@ -665,7 +665,9 @@ async function runCertify(args, baseUrl, apiKey) {
665
665
  // belongs to. Matched the same way the verifier matches it, so the emitter
666
666
  // and `verifyBundleV3` cannot disagree about the same bundle.
667
667
  const relaxedObligations = new Set((relaxations ?? []).map((r) => r.obligation));
668
- const relaxedProfile = (p) => [...relaxedObligations].some((o) => p.includes(o));
668
+ const relaxedProfileIds = profilesRelaxedBy([...relaxedObligations], claimedProfiles);
669
+ const observedRelaxed = profilesDeniedByObservedRelaxation(rows3, claimedProfiles).profiles;
670
+ const relaxedProfile = (p) => relaxedProfileIds.has(p) || observedRelaxed.has(p);
669
671
  const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: !relaxedProfile(p) && (verdictFor(p)?.certifiable ?? false) && !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
670
672
  const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
671
673
  const lock = existsSync(lockPath) ? JSON.parse(readFileSync(lockPath, 'utf8')) : undefined;
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
21
21
  import { profileDerivable } from './profiles.js';
22
- import { v2RegistryAvailable } from './v2-profiles.js';
22
+ import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
23
23
  export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
24
24
  /** Deterministic JSON: keys sorted at every level, no whitespace. */
25
25
  export function canonicalJSON(value) {
@@ -118,9 +118,19 @@ export function verifyBundleV3(bundle, opts = {}) {
118
118
  }
119
119
  // Relaxations: a relaxed obligation's profile cannot certify (RFC 0173 §A.2).
120
120
  const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
121
+ // Ownership comes from the profile registry, not from the profile's name — see profilesRelaxedBy.
122
+ const relaxedProfiles = profilesRelaxedBy((bundle.host?.relaxations ?? []).map((r) => r.obligation), (bundle.claimedProfiles ?? []).map((p) => p.id));
123
+ // An OBSERVED relaxation nobody declared: the bundle's own results show the
124
+ // host accepting a destination its egress guard MUST refuse. Same denial as a
125
+ // declared one, so declaring nothing is not a way around the rule.
126
+ const observed = profilesDeniedByObservedRelaxation(bundle.results?.requirements ?? [], (bundle.claimedProfiles ?? []).map((p) => p.id));
127
+ for (const p of bundle.claimedProfiles ?? []) {
128
+ if (p.certified && observed.profiles.has(p.id) && !relaxedProfiles.has(p.id))
129
+ rejections.push({ kind: 'undeclared-relaxation-observed', profile: p.id, detail: `${p.id} is marked certified, but this bundle's own results show the host running relaxed on ${observed.obligations.join(', ')} with no host.relaxations[] entry recording it (security-defaults.md §Relaxations: every relaxation a host runs under MUST be recorded, and its profile MUST NOT certify)` });
130
+ }
121
131
  const certifiedProfiles = [];
122
132
  for (const p of bundle.claimedProfiles ?? []) {
123
- if (p.certified && relaxed.size > 0 && [...relaxed].some((o) => p.id.includes(o)))
133
+ if (p.certified && relaxedProfiles.has(p.id))
124
134
  rejections.push({ kind: 'relaxed-profile-certified', profile: p.id, detail: `${p.id} is marked certified while a relaxation on ${[...relaxed].join(', ')} is recorded (RFC 0173 §A.2)` });
125
135
  else if (p.certified)
126
136
  certifiedProfiles.push(p.id);
@@ -104,3 +104,69 @@ export function v2ProfileIds(doc) {
104
104
  export function v2ProfileDerivable(doc, profile) {
105
105
  return v2ProfileIds(doc)?.includes(profile) ?? false;
106
106
  }
107
+ /**
108
+ * Which of `profileIds` a relaxed obligation denies (RFC 0173 §A.2;
109
+ * `security-defaults.md` §Relaxations: "a bundle that records a relaxation MUST
110
+ * NOT certify the profile the relaxed obligation belongs to").
111
+ *
112
+ * An obligation is `<family>.<name>`. A profile OWNS a family when its registry
113
+ * predicate lists it — `openwop-core-standard` is built on `interrupt`, `replay`,
114
+ * `webhooks`, `idempotency`, `eventLog`, so a relaxation on any of those denies
115
+ * it. That ownership was always stated in `spec/v2/profiles.json`; nothing read
116
+ * it. Until 2.33.0 the verifier tested whether the profile ID contained the
117
+ * family name, and the emitter tested whether it contained the WHOLE obligation
118
+ * string — two different rules under a comment saying they matched — and since
119
+ * no real v2 profile id contains a family name, a declared relaxation denied
120
+ * nothing on any real host. The one scenario pinning the rule used a fixture
121
+ * profile called `openwop-webhooks`, which is why it stayed green.
122
+ *
123
+ * The id-substring rule is KEPT as a second arm, not replaced: it is how a
124
+ * non-registry profile id (a v1 alias, a fixture) names its family, and
125
+ * dropping it would un-deny something that is denied today. When the registry
126
+ * is unavailable only that arm can run; the caller already records that layout
127
+ * as `derivabilityChecked: false`.
128
+ *
129
+ * ONE function, called by the emitter and the verifier, so the file and the
130
+ * verdict on the file cannot disagree again.
131
+ */
132
+ export function profilesRelaxedBy(obligations, profileIds) {
133
+ const families = new Set(obligations.map((o) => o.split('.')[0] ?? '').filter((f) => f.length > 0));
134
+ const out = new Set();
135
+ if (families.size === 0)
136
+ return out;
137
+ const registry = readRegistry();
138
+ const owned = new Map();
139
+ for (const p of registry ?? []) {
140
+ if (typeof p.id === 'string')
141
+ owned.set(p.id, Array.isArray(p.predicate?.families) ? p.predicate.families.map(String) : []);
142
+ }
143
+ for (const id of profileIds) {
144
+ const ownsOne = (owned.get(id) ?? []).some((f) => families.has(f));
145
+ const namesOne = [...families].some((f) => id.includes(f));
146
+ if (ownsOne || namesOne)
147
+ out.add(id);
148
+ }
149
+ return out;
150
+ }
151
+ /**
152
+ * The requirement whose FAILURE is an observed, undeclared relaxation.
153
+ *
154
+ * `conformance/README.md` said of `OPENWOP_HOST_RELAXATIONS` that "the suite
155
+ * cannot detect an undeclared one". For the egress guard that was never true:
156
+ * a host that ACCEPTS `https://127.0.0.1/…` as a webhook destination has shown
157
+ * on the wire that its guard is open (`webhooks.md`: "a host MUST reject (400
158
+ * webhook_url_rejected) … RFC 1918 and loopback and link-local ranges").
159
+ * `v2-webhook-egress-refusal` records that as `executed-fail` on this id unless
160
+ * the operator DECLARED the relaxation — in which case {@link profilesRelaxedBy}
161
+ * already denies the profile. Either way a profile built on `webhooks` does not
162
+ * certify on a host whose guard is open; this closes the path where it did,
163
+ * silently, because nobody declared anything.
164
+ */
165
+ export const UNDECLARED_RELAXATION_WITNESSES = [
166
+ { requirementId: 'openwop.requirement.0171.webhook-egress-refused', obligation: 'webhooks.egress-guard' },
167
+ ];
168
+ /** Profiles that MUST NOT certify because a row above is `executed-fail` in this bundle's own results. */
169
+ export function profilesDeniedByObservedRelaxation(rows, profileIds) {
170
+ const obligations = UNDECLARED_RELAXATION_WITNESSES.filter((w) => rows.some((r) => r.id === w.requirementId && r.result === 'executed-fail')).map((w) => w.obligation);
171
+ return { profiles: profilesRelaxedBy(obligations, profileIds), obligations };
172
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.32.0",
4
- "stampSha256": "09ede2a5f7639c91137ef502d8484ae467f51d0b66f37663081423c8c61f593f"
3
+ "version": "2.33.1",
4
+ "stampSha256": "132270b1cd8c97bed21d57a5b08c9abe89d10a56ff1aadf9ff3119edafb4d121"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.32.0",
3
+ "version": "2.33.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.32.0"
59
+ "@openwop/spec-artifacts": "2.33.1"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 581,
6
- "tests": 2207,
7
- "withStableId": 2207,
5
+ "files": 582,
6
+ "tests": 2209,
7
+ "withStableId": 2209,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2161
9
+ "explicitIds": 2163
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26650,7 +26650,7 @@
26650
26650
  {
26651
26651
  "id": "openwop.it.v2-durability-recovery.accepted-work-survives-a-kill-before-dispatch-and-dispatches-on-resume",
26652
26652
  "file": "v2-durability-recovery.test.ts",
26653
- "line": 262,
26653
+ "line": 254,
26654
26654
  "title": "accepted work survives a kill before dispatch and dispatches on resume",
26655
26655
  "explicitId": "openwop.requirement.0158.kill-after-accept",
26656
26656
  "citations": [
@@ -26664,7 +26664,7 @@
26664
26664
  {
26665
26665
  "id": "openwop.it.v2-durability-recovery.work-executing-at-a-real-process-death-is-never-reported-complete-and-resumes",
26666
26666
  "file": "v2-durability-recovery.test.ts",
26667
- "line": 307,
26667
+ "line": 299,
26668
26668
  "title": "work executing at a real process death is never reported complete, and resumes",
26669
26669
  "explicitId": "openwop.requirement.0158.kill-during-execution",
26670
26670
  "citations": [
@@ -26683,7 +26683,7 @@
26683
26683
  {
26684
26684
  "id": "openwop.it.v2-durability-recovery.the-same-accepted-work-delivered-twice-fires-each-effect-exactly-once",
26685
26685
  "file": "v2-durability-recovery.test.ts",
26686
- "line": 354,
26686
+ "line": 346,
26687
26687
  "title": "the same accepted work delivered twice fires each effect exactly once",
26688
26688
  "explicitId": "openwop.requirement.0158.duplicate-delivery",
26689
26689
  "citations": [
@@ -26702,7 +26702,7 @@
26702
26702
  {
26703
26703
  "id": "openwop.it.v2-durability-recovery.the-declared-recovery-bound-is-derived-from-the-mechanism-that-enforces-it",
26704
26704
  "file": "v2-durability-recovery.test.ts",
26705
- "line": 428,
26705
+ "line": 420,
26706
26706
  "title": "the declared recovery bound is derived from the mechanism that enforces it",
26707
26707
  "explicitId": "openwop.requirement.0158.bound-is-derived",
26708
26708
  "citations": [
@@ -26716,7 +26716,7 @@
26716
26716
  {
26717
26717
  "id": "openwop.it.v2-durability-recovery.deterministically-failing-work-reaches-a-terminal-state-and-stops-being-retried",
26718
26718
  "file": "v2-durability-recovery.test.ts",
26719
- "line": 458,
26719
+ "line": 450,
26720
26720
  "title": "deterministically failing work reaches a terminal state and stops being retried",
26721
26721
  "explicitId": "openwop.requirement.0158.poison-exhaustion",
26722
26722
  "citations": [
@@ -28387,7 +28387,7 @@
28387
28387
  {
28388
28388
  "id": "openwop.it.v2-relaxation-recorded.a-bundle-recording-a-relaxation-cannot-certify-the-relaxed-profile",
28389
28389
  "file": "v2-relaxation-recorded.test.ts",
28390
- "line": 68,
28390
+ "line": 70,
28391
28391
  "title": "a bundle recording a relaxation cannot certify the relaxed profile",
28392
28392
  "explicitId": "openwop.requirement.0173.relaxation-recorded",
28393
28393
  "citations": [
@@ -28421,10 +28421,35 @@
28421
28421
  }
28422
28422
  ]
28423
28423
  },
28424
+ {
28425
+ "id": "openwop.it.v2-relaxation-recorded.a-relaxation-denies-the-real-profile-that-owns-its-family-not-only-one-named-aft",
28426
+ "file": "v2-relaxation-recorded.test.ts",
28427
+ "line": 118,
28428
+ "title": "a relaxation denies the REAL profile that owns its family, not only one named after it",
28429
+ "explicitId": "openwop.requirement.0173.relaxation-recorded.owned-by-registry",
28430
+ "citations": [
28431
+ {
28432
+ "section": "security-defaults.md §Relaxations",
28433
+ "requirement": "control: with no relaxation recorded, the real profile certifies — so the rejection below is the relaxation's"
28434
+ },
28435
+ {
28436
+ "section": "security-defaults.md §Relaxations",
28437
+ "requirement": "a relaxation on `webhooks.*` MUST deny `openwop-core-standard`, whose registry predicate is built on the `webhooks` family — ownership is read from spec/v2/profiles.json, never from the spelling of the profile id"
28438
+ },
28439
+ {
28440
+ "section": "security-defaults.md §Relaxations",
28441
+ "requirement": "the owning profile is not certified, and a profile that does not own the family still is"
28442
+ },
28443
+ {
28444
+ "section": "security-defaults.md §Relaxations",
28445
+ "requirement": "a relaxation on a family outside every claimed profile's predicate is scoped to nothing claimed — it does not poison an unrelated profile"
28446
+ }
28447
+ ]
28448
+ },
28424
28449
  {
28425
28450
  "id": "openwop.it.v2-relaxation-recorded.durability-is-a-closed-set-session-deployment-persisted",
28426
28451
  "file": "v2-relaxation-recorded.test.ts",
28427
- "line": 108,
28452
+ "line": 143,
28428
28453
  "title": "durability is a closed set: session | deployment | persisted",
28429
28454
  "explicitId": "openwop.requirement.0173.relaxation-recorded.durability-closed",
28430
28455
  "citations": [
@@ -29415,7 +29440,7 @@
29415
29440
  {
29416
29441
  "id": "openwop.it.v2-webhook-delivery-shape.a-major-2-subscriber-receives-the-v2-rendering-the-delivery-validates-and-run-st",
29417
29442
  "file": "v2-webhook-delivery-shape.test.ts",
29418
- "line": 186,
29443
+ "line": 220,
29419
29444
  "title": "a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal",
29420
29445
  "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29421
29446
  "citations": [
@@ -29446,7 +29471,7 @@
29446
29471
  {
29447
29472
  "id": "openwop.it.v2-webhook-delivery-shape.a-major-1-subscriber-still-receives-the-v1-rendering-the-v1-wire-does-not-move-m",
29448
29473
  "file": "v2-webhook-delivery-shape.test.ts",
29449
- "line": 205,
29474
+ "line": 240,
29450
29475
  "title": "a major-1 subscriber still receives the v1 rendering — the v1 wire does not move mid-overlap",
29451
29476
  "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29452
29477
  "citations": [
@@ -29464,7 +29489,7 @@
29464
29489
  {
29465
29490
  "id": "openwop.it.v2-webhook-delivery-shape.a-seeded-era-2-run-is-delivered-projected-too-the-fan-out-branch-a-fresh-run-can",
29466
29491
  "file": "v2-webhook-delivery-shape.test.ts",
29467
- "line": 228,
29492
+ "line": 264,
29468
29493
  "title": "a seeded era-2 run is delivered projected too — the fan-out branch a fresh run cannot reach (seam-gated)",
29469
29494
  "explicitId": "openwop.requirement.0171.webhook-delivery-shape",
29470
29495
  "citations": [
@@ -29563,6 +29588,25 @@
29563
29588
  }
29564
29589
  ]
29565
29590
  },
29591
+ {
29592
+ "id": "openwop.it.v2-webhook-egress-refusal.a-webhook-registration-naming-a-destination-the-guard-must-refuse-is-refused-400",
29593
+ "file": "v2-webhook-egress-refusal.test.ts",
29594
+ "line": 82,
29595
+ "title": "a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected",
29596
+ "explicitId": "openwop.requirement.0171.webhook-egress-refused",
29597
+ "citations": [
29598
+ {
29599
+ "section": "spec/v2/core/webhooks.md §SSRF",
29600
+ "requirement": null,
29601
+ "interpolated": true
29602
+ },
29603
+ {
29604
+ "section": "spec/v2/core/webhooks.md §SSRF",
29605
+ "requirement": null,
29606
+ "interpolated": true
29607
+ }
29608
+ ]
29609
+ },
29566
29610
  {
29567
29611
  "id": "openwop.it.v2-well-known-one-resource.a-header-less-get-and-an-openwop-version-2-0-get-are-two-representations-of-one",
29568
29612
  "file": "v2-well-known-one-resource.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": 523,
4
+ "files": 524,
5
5
  "v1": 445,
6
- "v2": 80
6
+ "v2": 81
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1463,6 +1463,9 @@
1463
1463
  "v2-webhook-durable-delivery.test.ts": [
1464
1464
  2
1465
1465
  ],
1466
+ "v2-webhook-egress-refusal.test.ts": [
1467
+ 2
1468
+ ],
1466
1469
  "v2-well-known-one-resource.test.ts": [
1467
1470
  2
1468
1471
  ],
@@ -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.32.0",
5
- "corpusTag": "v2.32.0",
4
+ "version": "2.33.1",
5
+ "corpusTag": "v2.33.0",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "a1db4d5558ec828482688ad34db7a749f6f52ef102158d24036819402720a3e9",
13
- "api/v2/asyncapi.yaml": "8bfff7d7d9c5ef82196d5b6da1566b36e7070d4996da731c4499fd90b8ee51a7",
14
- "api/v2/openapi.yaml": "02338e18f6e0ba029dde8b6dadee126904d2ca97b10a6bdd43c7c1a423ad88d7",
12
+ "api/seams-v2.yaml": "975b6cd87fa679857df9fcdc60b6a2bed98510b831e82a5eff5a114e3b263f6e",
13
+ "api/v2/asyncapi.yaml": "96ba13176119bbf54a94bd41d789ebaa919f67c81d529bdc3c66ffb3f419ee36",
14
+ "api/v2/openapi.yaml": "428ab881d0a8d08d388dd89d4b4457f3454cd0433b665674180d3e85821b978d",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -201,7 +201,7 @@
201
201
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
202
202
  "spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
203
203
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
204
- "spec/v1/core-standard-manifest.json": "01ed93af96a8ca6c562b8fa25658ff2e33a793970133c50838d50ac5e362819e",
204
+ "spec/v1/core-standard-manifest.json": "71c38c738496eaac1d144561a7f6e752600f8afd71f938eb483c0c76b8a5263a",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -279,8 +279,8 @@
279
279
  "spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
280
280
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
281
281
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
282
- "spec/v2/release.json": "df06e652e6f851338df9b4e0f230bb176511e62691e867f7228d05c4263b1bec",
282
+ "spec/v2/release.json": "94a3ea302324d9f7df87ce62a186b0b98936de6ce5ac5ec01a3fb3208c38efc3",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "7091dae28be398d49c446f779b4c91755a80a5ff"
285
+ "corpusCommit": "b9afb25c0bdfbbd2f11efad6f984df51e50344e5"
286
286
  }
package/src/cli.ts CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  PROFILE_FLOOR_SCENARIOS,
55
55
  } from './lib/profiles.js';
56
56
  import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
57
- import { v2ProfileIds } from './lib/v2-profiles.js';
57
+ import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2ProfileIds } from './lib/v2-profiles.js';
58
58
  import { childEnv } from './lib/child-env.js';
59
59
 
60
60
  interface ParsedArgs {
@@ -694,7 +694,9 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
694
694
  // belongs to. Matched the same way the verifier matches it, so the emitter
695
695
  // and `verifyBundleV3` cannot disagree about the same bundle.
696
696
  const relaxedObligations = new Set((relaxations ?? []).map((r) => r.obligation));
697
- const relaxedProfile = (p: string): boolean => [...relaxedObligations].some((o) => p.includes(o));
697
+ const relaxedProfileIds = profilesRelaxedBy([...relaxedObligations], claimedProfiles);
698
+ const observedRelaxed = profilesDeniedByObservedRelaxation(rows3, claimedProfiles).profiles;
699
+ const relaxedProfile = (p: string): boolean => relaxedProfileIds.has(p) || observedRelaxed.has(p);
698
700
  const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: !relaxedProfile(p) && (verdictFor(p)?.certifiable ?? false) && !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
699
701
  const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
700
702
  const lock = existsSync(lockPath) ? (JSON.parse(readFileSync(lockPath, 'utf8')) as { version: string; stampSha256: string }) : undefined;
@@ -38,6 +38,7 @@
38
38
  * @see https://a2a-protocol.org/v0.3.0/specification
39
39
  */
40
40
 
41
+ import { resolvePublicFront } from './webhook-receiver.js';
41
42
  import { createServer, type Server } from 'node:http';
42
43
  import type { AddressInfo } from 'node:net';
43
44
 
@@ -198,6 +199,18 @@ export class A2AFakePeer {
198
199
  return `http://127.0.0.1:${this._boundPort}`;
199
200
  }
200
201
 
202
+ /**
203
+ * The address to hand THE HOST UNDER TEST — `OPENWOP_A2A_FAKE_PEER_URL` when the operator fronts
204
+ * this peer publicly (https, publicly resolvable; validated loudly, same rule
205
+ * as the webhook receiver), else `endpoint()`. The suite's own requests to its
206
+ * own fake keep using `endpoint()`: they need no tunnel and must not depend on
207
+ * one. Pin the listener with the matching `_PORT` variable so the front has a
208
+ * fixed port to forward to.
209
+ */
210
+ hostFacingEndpoint(): string {
211
+ return resolvePublicFront('OPENWOP_A2A_FAKE_PEER_URL', this.endpoint()).url;
212
+ }
213
+
201
214
  reset(): void {
202
215
  this._tasks.clear();
203
216
  this._invocations.length = 0;
@@ -293,7 +306,7 @@ export class A2AFakePeer {
293
306
  description: 'Synthetic A2A peer for openwop conformance suite (dual-era: 1.0 + 0.3-legacy)',
294
307
  version: '1.1.0',
295
308
  supportedInterfaces: [...this._protocolVersions].sort((a, b) => (a === '1.0' ? -1 : b === '1.0' ? 1 : 0)).map((v) => ({
296
- url: `${this.endpoint()}/a2a/jsonrpc`,
309
+ url: `${this.hostFacingEndpoint()}/a2a/jsonrpc`,
297
310
  protocolBinding: 'JSONRPC',
298
311
  protocolVersion: v,
299
312
  })),
@@ -310,13 +323,13 @@ export class A2AFakePeer {
310
323
  protocolVersion: '0.3.0',
311
324
  name: 'openwop-conformance-fake-a2a',
312
325
  description: 'Synthetic A2A peer for openwop conformance suite',
313
- url: `${this.endpoint()}/a2a/jsonrpc`,
326
+ url: `${this.hostFacingEndpoint()}/a2a/jsonrpc`,
314
327
  version: '1.0.0',
315
328
  capabilities: { streaming: false, pushNotifications: false },
316
329
  skills: [{ id: 'echo', name: 'echo', description: 'Returns input verbatim', tags: ['echo'] }],
317
330
  defaultInputModes: ['text'],
318
331
  defaultOutputModes: ['text'],
319
- additionalInterfaces: [{ url: `${this.endpoint()}/a2a/jsonrpc`, transport: 'JSONRPC' }],
332
+ additionalInterfaces: [{ url: `${this.hostFacingEndpoint()}/a2a/jsonrpc`, transport: 'JSONRPC' }],
320
333
  };
321
334
  }
322
335