@openwop/openwop-conformance 2.34.2 → 2.35.0

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,29 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.35.0] — 2026-09-21 — blocked means blocked, the declarations are signed, and the header a subscriber reads is checked
4
+
5
+ ### Security
6
+
7
+ - **`v2-webhook-egress-refusal` (`0171.webhook-egress-refused`) now registers loopback, cloud metadata and RFC 1918 written as IPv4-mapped IPv6** — `https://[::ffff:7f00:1]/`, `https://[::ffff:a9fe:a9fe]/`, `https://[::ffff:a00:1]/` — eleven probes, up from eight. A URL parser emits the mapped form in hex (`[::ffff:127.0.0.1]` has hostname `::ffff:7f00:1`), and an egress guard that recognises only the dotted mapped form lets all three through at registration and at delivery. They are loopback, link-local and RFC 1918 addresses under `webhooks.md` §SSRF as written, so this enforces an existing MUST under the same id. Measured: the steward's reference host before its fix accepted exactly these three (`3 of 11`); after the fix, none.
8
+
9
+ ### Changed
10
+
11
+ > **The three rules below are RFC 0195's.** They were merged before that RFC was filed; it was then made `Active` by steward override of RFC 0147 §A.6, whose full comment window it did not run.
12
+
13
+ - **A `blocked` note stands at major 2 even after setup assertions.** A test that asserted something and then soft-skipped `blocked` recorded `executed-pass` with a `partial-witness:` detail — refused by the acceptance predicate, but counted by certification, so a profile could certify on a requirement nobody observed. At major 2 it now records `blocked`, which denies certification (`conformance.md` §Bundle v3). Swept first: every one of the 24 major-2 sites is "requirement unobserved" (unreachable, a control that did not answer, a window that closed), none an optional extra. Major 1 keeps its convention; the 146 v1-side sites were not measured and v1 bundles are read through its end of support. Measured on the committed bundles: the next cut changes 0 rows on the reference host and MyndHyve, 1 on openwop-app.
14
+ - **`host.relaxations[]` is signed.** It sits outside the attestation, so a relaxation deleted after signing let the verifier re-derive the relaxed profile as certified on a bundle that still verified. `witnessSha256` now digests `{ rows, relaxations }` whenever relaxations are declared, and the rows alone otherwise — every committed bundle digests unchanged. An older verifier fails closed (`witness-digest`) on a 2.35.0 bundle that declares relaxations. `v2-relaxation-recorded` now strips a signed relaxation and requires the rejection.
15
+ - **Opt-outs are derived and checked.** The verifier derives the operator's opt-outs from the signed `skipped` rows the behavior gate writes and rejects a bundle whose captured discovery document advertises one (`opted-out-but-advertised`); `--verify` prints the set. No schema change — a root-level list would have been unsigned. The file-level detail now says "profile or family".
16
+
17
+ ### Added
18
+
19
+ - **`v2-terminal-event-once` — RFC 0194 (`0194.terminal-once`, `0194.terminal-once.duplicate-delivery`).** A run's log carries exactly one terminal run event and no forward-execution event after it; `compensation.*` and `run.dead-lettered` may follow. The ordinary leg drives a noop run, and the failure and cancellable fixtures where advertised; the duplicate-delivery leg needs the RFC 0158 durability test hook (`inapplicable` without it). The log is read after it settles. Pure rule in `lib/terminal-shape.ts`, unit-tested against the two logs observed in openwop#1445.
20
+ - **`v2-callback-url-guarded` — RFC 0196 §A.3 (`0196.callback-url-guarded`), invariant `callback-url-egress-guarded`.** A host advertising `interrupt.callbackDelivery` must refuse at `createRun` a loopback, cloud-metadata or hex-mapped-loopback `callbackUrl` with `400 validation_error`, `details.field: callbackUrl`. `inapplicable` where the facet is absent — no committed host advertises it today.
21
+ - **`0187.bound-id-kinds.webhook-emitted` (openwop#1450).** The mint row reads the `webhookId` a host returns; a subscriber identifies its deliveries by the one it emits. A tier-2 host bound the 201 and left the delivery headers bare, and the mint row stayed green — the defect surfaced only as a durability failure elsewhere. The new leg drives one delivery, selects it by the run in the body (never by the header under test), and requires `OpenWOP-Webhook-Id` — and `X-openwop-Webhook-Id` on a host advertising both majors — to equal the minted id.
22
+
23
+ ### Fixed
24
+
25
+ - **`v2-bound-id-kinds` left a subscription behind on every run** (its `deliveryId` leg registered and never deleted). Every registering leg now deletes in `finally`. Measured: 1 leftover subscription per run before, 0 after.
26
+
3
27
  ## [2.34.2] — 2026-09-21 — a host that died on schedule failed the rows that were watching it recover
4
28
 
5
29
  - **The RFC 0158 kill rows read the host through two calls that did not survive the host dying.** `waitBack()` treated a refused connection as "down, keep waiting"; the watch's `readStatus` / `readLog` called `driver.get` bare. A seam may answer and *then* die — `during-execution` dies at the first `node.started`, seconds after its response — so `waitBack` saw a 200 from the still-living process, the process died mid-watch, and `fetch failed` escaped the scenario as an **`executed-fail` — for recovering.** Measured by a tier-1 host at its exact production image under a real restart supervisor, behind a port proxy that accepts and then closes: four of five rows `executed-fail` on `UND_ERR_SOCKET: other side closed`, reproduced twice; the one that passed was the row whose kill lands before the seam answers.
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.34.2 @openwop/spec-artifacts@2.34.2
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.35.0 @openwop/spec-artifacts@2.35.0
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 524 scenario files under `src/scenarios/`.
138
+ The current suite has 526 scenario files under `src/scenarios/`.
139
139
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
140
140
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
141
141
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -480,7 +480,7 @@ Server-required (added in 1.7.0):
480
480
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
481
481
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
482
482
 
483
- Current source tree: 524 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
483
+ Current source tree: 526 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
484
484
 
485
485
  ## Remaining Gaps
486
486
 
package/coverage.md CHANGED
@@ -144,7 +144,9 @@ 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-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) |
147
+ | `v2-webhook-egress-refusal.test.ts` | the `webhooks` family (capability-gated) | A (`webhooks.md` §SSRF at MAJOR 2 — **eleven** registration probes since 2.35.0: the eight below plus loopback, cloud metadata and RFC 1918 written as IPv4-mapped IPv6, which a URL parser emits in HEX (`[::ffff:127.0.0.1]` has hostname `::ffff:7f00:1`) and a guard matching only the dotted mapped form lets through — same id, the same MUST. The original eight 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-terminal-event-once.test.ts` | none for the ordinary leg (every host has runs; the failed and cancelled fixtures are each gated on being advertised); the RFC 0158 durability test hook for the duplicate-delivery leg | A (RFC 0194 §A: a run's log carries exactly one terminal run event and no forward-execution event after it — `compensation.*` and `run.dead-lettered` may follow. The log is read after it SETTLES, two reads ≥ 2 s apart agreeing, bounded at 20 s, because a second delivery can finish after the first terminal event. Two ids: `0194.terminal-once`, `0194.terminal-once.duplicate-delivery`) | `host-pending` | A dedicated file on purpose: a violation must fail this RFC's rows, not unrelated ones. |
149
+ | `v2-callback-url-guarded.test.ts` | the `interrupt` family with `interrupt.callbackDelivery: true` | A (RFC 0196 §A.3: `createRun` with a loopback, cloud-metadata or hex-mapped-loopback `callbackUrl` MUST answer `400 validation_error`, `details.field: callbackUrl`; invariant `callback-url-egress-guarded`) | `host-pending` | No committed host advertises the facet; witnessed on none today. |
148
150
  | `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` | **2.34.0 — the rows now WRITE evidence into the bundle (RFC 0158 §E):** `bound-is-derived` records `evidence.recoveryBounds[]` per recovery class and each kill row records `evidence.recovery { class, boundMs, observedMs }` — the interval was always measured and, on a pass, never recorded. Both ride on the ROW so the witness digest, and through it the signature, covers them; the bundle claims `durability.rung` only when `deriveRung` supports it and `--verify` rejects a claim it cannot re-derive (`rung-not-derivable`). A kill row records nothing unless the seam NAMES the class it exercised (`recoveryClass`) and the bound response declares that class — no name, no rung, rather than a guessed one. **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`. |
149
151
  | `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`. |
150
152
  | `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`. |
package/dist/cli.js CHANGED
@@ -697,7 +697,7 @@ async function runCertify(args, baseUrl, apiKey) {
697
697
  discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
698
698
  claimedProfiles: claimed3,
699
699
  results: { totals: totals3, requirements: rows3 },
700
- witnessSha256: witnessDigest(rows3),
700
+ witnessSha256: witnessDigest(rows3, relaxations),
701
701
  assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
702
702
  ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
703
703
  // RFC 0158 §D: claimed ONLY when these rows support it. The verifier
@@ -908,6 +908,8 @@ async function main() {
908
908
  `certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
909
909
  // RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
910
910
  `rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
911
+ // 2.35.0: derived from the signed `skipped` rows; checked against the signed discovery document.
912
+ `opted out: ${verdict.optedOut.length > 0 ? verdict.optedOut.join(', ') : '(none)'}`,
911
913
  '',
912
914
  'What this command does NOT do:',
913
915
  ' · It does not re-run anything. A host that measured itself wrongly, and signed',
@@ -31,12 +31,26 @@ export function canonicalJSON(value) {
31
31
  }
32
32
  return JSON.stringify(value);
33
33
  }
34
- /** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
35
- export function witnessDigest(rows) {
34
+ /**
35
+ * RFC 0148 §C — the digest over the reporter record (the requirement rows),
36
+ * and, from 2.35.0, the operator's declared relaxations when there are any.
37
+ *
38
+ * Why relaxations are in here: the attestation covers exactly
39
+ * `{ witnessSha256, host.build, suite.version, discovery.sha256 }`, so a
40
+ * `host.relaxations[]` beside the rows was UNSIGNED — strip it after signing
41
+ * and the verifier re-derives the relaxed profile as certified, on a bundle
42
+ * that still verifies. Included ONLY WHEN NON-EMPTY: the preimage is the rows
43
+ * array exactly as before for every bundle that declares nothing (all
44
+ * committed bundles digest unchanged, pinned in durability-evidence.test.ts),
45
+ * and `{ rows, relaxations }` otherwise. An older verifier fails closed on a
46
+ * 2.35.0 bundle that declares relaxations — `witness-digest`, never a pass.
47
+ */
48
+ export function witnessDigest(rows, relaxations) {
36
49
  const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
37
50
  // ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
38
51
  ...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
39
- return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
52
+ const preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
53
+ return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
40
54
  }
41
55
  /** The bytes the attestation covers. */
42
56
  export function attestationPayload(b) {
@@ -58,6 +72,14 @@ export function verifierSign(bundle, privateKeyPem, keyId) {
58
72
  export function publicKeyFromPrivate(privateKeyPem) {
59
73
  return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' });
60
74
  }
75
+ const OPT_OUT_ROW = 'openwop.profile.';
76
+ /** The opt-outs a bundle's signed rows record. */
77
+ export function optedOutFromRows(rows) {
78
+ return rows
79
+ .filter((r) => r.id.startsWith(OPT_OUT_ROW) && r.result === 'skipped' && (r.detail ?? '').includes('OPENWOP_OPTED_OUT_PROFILES'))
80
+ .map((r) => r.id.slice(OPT_OUT_ROW.length))
81
+ .sort();
82
+ }
61
83
  export function verifyBundleV3(bundle, opts = {}) {
62
84
  const rejections = [];
63
85
  if (bundle.bundleVersion !== '3')
@@ -78,9 +100,9 @@ export function verifyBundleV3(bundle, opts = {}) {
78
100
  for (const k of Object.keys(expected))
79
101
  if (bundle.results?.totals?.[k] !== expected[k])
80
102
  rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
81
- const digest = witnessDigest(rows);
103
+ const digest = witnessDigest(rows, bundle.host?.relaxations);
82
104
  if (bundle.witnessSha256 !== digest)
83
- rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
105
+ rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows and declared relaxations (${digest.slice(0, 12)})` });
84
106
  const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
85
107
  if (bundle.assertionCount !== assertions)
86
108
  rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
@@ -183,6 +205,21 @@ export function verifyBundleV3(bundle, opts = {}) {
183
205
  }
184
206
  else {
185
207
  derivabilityChecked = true;
208
+ // RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same
209
+ // profile." The suite refuses that at run time (behaviorGate throws),
210
+ // so a bundle that records both was edited after the run or emitted by
211
+ // something else. Both inputs are signed — the rows through
212
+ // witnessSha256, the document through discovery.sha256 — so the
213
+ // contradiction is checkable offline, and it is bundle-wide: an opt-out
214
+ // list that is false makes every `skipped` row in the bundle suspect.
215
+ const root = document;
216
+ for (const name of optedOutFromRows(rows)) {
217
+ const advertised = name.startsWith('family.')
218
+ ? (() => { const v = root[name.slice('family.'.length)]; return typeof v === 'object' && v !== null && !Array.isArray(v); })()
219
+ : profileDerivable(document, name, targetMajor);
220
+ if (advertised)
221
+ rejections.push({ kind: 'opted-out-but-advertised', detail: `${name} is recorded as an operator opt-out, but the captured discovery document advertises it — a host MUST NOT both advertise and opt out of the same profile (RFC 0148 §B)` });
222
+ }
186
223
  for (const p of bundle.claimedProfiles ?? []) {
187
224
  // §B(1) binds the CERTIFICATION, not the listing: a profile a bundle
188
225
  // names without certifying makes no claim for derivability to falsify.
@@ -209,5 +246,6 @@ export function verifyBundleV3(bundle, opts = {}) {
209
246
  verifierSignatureVerified,
210
247
  certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
211
248
  derivabilityChecked,
249
+ optedOut: optedOutFromRows(rows),
212
250
  };
213
251
  }
@@ -96,13 +96,27 @@ export const PARTIAL_WITNESS_PREFIX = 'partial-witness: ';
96
96
  * dishonest per-`it` rows denied certification to every profile on a host that
97
97
  * simply did not advertise the gated surface.
98
98
  */
99
- export function resolveItRecord(state, assertionCalls, gate, noted, firstError) {
99
+ export function resolveItRecord(state, assertionCalls, gate, noted, firstError,
100
+ /**
101
+ * 2.35.0, major 2 (`conformance.md` §Bundle v3): a requirement the test did
102
+ * not observe records `blocked` even when the test asserted setup facts
103
+ * first. At major 1 a `blocked` note after assertions stays a
104
+ * `partial-witness` pass unless the leg used `blockedDespiteAssertions`.
105
+ *
106
+ * Why the rule changed: a `partial-witness` row is refused by the acceptance
107
+ * predicate but COUNTED by certification, so a profile could certify on a
108
+ * requirement nobody observed. Swept before the change: all 24 major-2
109
+ * sites were "requirement unobserved" (unreachable, control silent, window
110
+ * closed), none an optional extra. Scoped to major 2 because the 146 v1-side
111
+ * sites were not measured and v1 bundles are read through its EOS.
112
+ */
113
+ blockedStands = false) {
100
114
  if (state === 'fail')
101
115
  return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
102
116
  if (state === 'pass' && assertionCalls > 0) {
103
117
  // `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
104
118
  // assertions are not the requirement, and the requirement went unobserved.
105
- if (noted !== null && noted.kind === 'blocked' && noted.conclusive === true)
119
+ if (noted !== null && noted.kind === 'blocked' && (noted.conclusive === true || blockedStands))
106
120
  return { disposition: 'blocked', detail: noted.reason };
107
121
  // A leg that asserted AND THEN soft-skipped is only a partial witness, and
108
122
  // the file-level record has always said so (`resolveFileRecord` below).
@@ -141,14 +155,14 @@ export function fileDisposition(states, gateReason, assertionCount) {
141
155
  return { disposition: 'inapplicable', detail: 'every test returned early through behaviorGate with zero assertions: profile not advertised in the captured discovery set' };
142
156
  }
143
157
  if (assertionCount === 0 && gateReason === 'skipped') {
144
- return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
158
+ return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
145
159
  }
146
160
  return { disposition: 'executed-pass' };
147
161
  }
148
162
  if (gateReason === 'inapplicable')
149
163
  return { disposition: 'inapplicable', detail: 'every test skipped: profile not advertised in the captured discovery set (behaviorGate)' };
150
164
  if (gateReason === 'skipped')
151
- return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
165
+ return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
152
166
  return {
153
167
  disposition: 'blocked',
154
168
  detail: states.length === 0
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.34.2",
4
- "stampSha256": "f4c6cce1906546565c17cc8c80b081aea9aff2d0a3338ad45d92b4836875d979"
3
+ "version": "2.35.0",
4
+ "stampSha256": "164c05f7216f16c89a05636375b4e5eaebce3c461eb3c24fb5a992750633765e"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.34.2",
3
+ "version": "2.35.0",
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.34.2"
59
+ "@openwop/spec-artifacts": "2.35.0"
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": 582,
6
- "tests": 2209,
7
- "withStableId": 2209,
5
+ "files": 584,
6
+ "tests": 2213,
7
+ "withStableId": 2213,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2163
9
+ "explicitIds": 2167
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26009,7 +26009,7 @@
26009
26009
  {
26010
26010
  "id": "openwop.it.v2-bound-id-kinds.subscriptionid-post-webhooks-mints-a-bound-webhookid-the-projected-segment-is-ac",
26011
26011
  "file": "v2-bound-id-kinds.test.ts",
26012
- "line": 61,
26012
+ "line": 89,
26013
26013
  "title": "subscriptionId: POST /webhooks mints a bound webhookId, the projected segment is accepted, and a foreign tenant segment is refused 403",
26014
26014
  "explicitId": "openwop.requirement.0187.bound-id-kinds.webhook",
26015
26015
  "citations": [
@@ -26033,10 +26033,29 @@
26033
26033
  }
26034
26034
  ]
26035
26035
  },
26036
+ {
26037
+ "id": "openwop.it.v2-bound-id-kinds.subscriptionid-the-webhookid-a-host-emits-on-a-delivery-is-the-one-it-minted",
26038
+ "file": "v2-bound-id-kinds.test.ts",
26039
+ "line": 132,
26040
+ "title": "subscriptionId: the webhookId a host EMITS on a delivery is the one it minted",
26041
+ "explicitId": "openwop.requirement.0187.bound-id-kinds.webhook-emitted",
26042
+ "citations": [
26043
+ {
26044
+ "section": "webhooks.md §Headers (RFC 0187 §A.1)",
26045
+ "requirement": null,
26046
+ "interpolated": true
26047
+ },
26048
+ {
26049
+ "section": "webhooks.md §Headers (dual emission, RFC 0176 §D.2)",
26050
+ "requirement": null,
26051
+ "interpolated": true
26052
+ }
26053
+ ]
26054
+ },
26036
26055
  {
26037
26056
  "id": "openwop.it.v2-bound-id-kinds.interruptid-the-id-a-suspension-puts-on-the-wire-is-bound",
26038
26057
  "file": "v2-bound-id-kinds.test.ts",
26039
- "line": 95,
26058
+ "line": 179,
26040
26059
  "title": "interruptId: the id a suspension puts on the wire is bound",
26041
26060
  "explicitId": null,
26042
26061
  "citations": []
@@ -26044,7 +26063,7 @@
26044
26063
  {
26045
26064
  "id": "openwop.it.v2-bound-id-kinds.effectid-every-row-get-runs-runid-effects-serves-is-bound",
26046
26065
  "file": "v2-bound-id-kinds.test.ts",
26047
- "line": 116,
26066
+ "line": 200,
26048
26067
  "title": "effectId: every row GET /runs/{runId}/effects serves is bound",
26049
26068
  "explicitId": "openwop.requirement.0187.bound-id-kinds.per-kind",
26050
26069
  "citations": [
@@ -26057,7 +26076,7 @@
26057
26076
  {
26058
26077
  "id": "openwop.it.v2-bound-id-kinds.deliveryid-every-record-in-the-dead-letter-read-carries-a-bound-id-in-the-caller",
26059
26078
  "file": "v2-bound-id-kinds.test.ts",
26060
- "line": 131,
26079
+ "line": 215,
26061
26080
  "title": "deliveryId: every record in the dead-letter read carries a bound id in the caller's tenant",
26062
26081
  "explicitId": "openwop.requirement.0187.bound-id-kinds.per-kind",
26063
26082
  "citations": [
@@ -26184,6 +26203,23 @@
26184
26203
  }
26185
26204
  ]
26186
26205
  },
26206
+ {
26207
+ "id": "openwop.it.v2-callback-url-guarded.a-host-advertising-callback-delivery-refuses-a-callbackurl-its-egress-guard-woul",
26208
+ "file": "v2-callback-url-guarded.test.ts",
26209
+ "line": 36,
26210
+ "title": "a host advertising callback delivery refuses a callbackUrl its egress guard would refuse",
26211
+ "explicitId": "openwop.requirement.0196.callback-url-guarded",
26212
+ "citations": [
26213
+ {
26214
+ "section": "spec/v2/core/interrupt.md §Callback delivery (RFC 0196 §A.3)",
26215
+ "requirement": "a host advertising interrupt.callbackDelivery MUST refuse at createRun a callbackUrl the webhooks.md §SSRF registration guard would refuse"
26216
+ },
26217
+ {
26218
+ "section": "spec/v2/core/interrupt.md §Callback delivery (RFC 0196 §A.3)",
26219
+ "requirement": "the refusal MUST be 400 validation_error naming details.field \"callbackUrl\""
26220
+ }
26221
+ ]
26222
+ },
26187
26223
  {
26188
26224
  "id": "openwop.it.v2-capabilities-root-closed.the-host-document-validates-against-capabilities-schema-json",
26189
26225
  "file": "v2-capabilities-root-closed.test.ts",
@@ -28418,13 +28454,17 @@
28418
28454
  {
28419
28455
  "section": "security-defaults.md §Relaxations",
28420
28456
  "requirement": "a relaxation recorded against an unclaimed profile is not a rejection — recording is the obligation, claiming is the defect"
28457
+ },
28458
+ {
28459
+ "section": "conformance.md §Bundle v3",
28460
+ "requirement": "a relaxation removed after signing MUST be detected — the declaration is covered by witnessSha256, so the stripped bundle fails `witness-digest`"
28421
28461
  }
28422
28462
  ]
28423
28463
  },
28424
28464
  {
28425
28465
  "id": "openwop.it.v2-relaxation-recorded.a-relaxation-denies-the-real-profile-that-owns-its-family-not-only-one-named-aft",
28426
28466
  "file": "v2-relaxation-recorded.test.ts",
28427
- "line": 118,
28467
+ "line": 130,
28428
28468
  "title": "a relaxation denies the REAL profile that owns its family, not only one named after it",
28429
28469
  "explicitId": "openwop.requirement.0173.relaxation-recorded.owned-by-registry",
28430
28470
  "citations": [
@@ -28449,7 +28489,7 @@
28449
28489
  {
28450
28490
  "id": "openwop.it.v2-relaxation-recorded.durability-is-a-closed-set-session-deployment-persisted",
28451
28491
  "file": "v2-relaxation-recorded.test.ts",
28452
- "line": 143,
28492
+ "line": 155,
28453
28493
  "title": "durability is a closed set: session | deployment | persisted",
28454
28494
  "explicitId": "openwop.requirement.0173.relaxation-recorded.durability-closed",
28455
28495
  "citations": [
@@ -29253,6 +29293,46 @@
29253
29293
  }
29254
29294
  ]
29255
29295
  },
29296
+ {
29297
+ "id": "openwop.it.v2-terminal-event-once.every-run-observed-to-a-terminal-state-has-exactly-one-terminal-event-and-no-for",
29298
+ "file": "v2-terminal-event-once.test.ts",
29299
+ "line": 71,
29300
+ "title": "every run observed to a terminal state has exactly one terminal event and no forward execution after it",
29301
+ "explicitId": "openwop.requirement.0194.terminal-once",
29302
+ "citations": [
29303
+ {
29304
+ "section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
29305
+ "requirement": "a run's log MUST contain exactly one terminal run event and no forward-execution event after it"
29306
+ },
29307
+ {
29308
+ "section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
29309
+ "requirement": null,
29310
+ "interpolated": true
29311
+ }
29312
+ ]
29313
+ },
29314
+ {
29315
+ "id": "openwop.it.v2-terminal-event-once.work-delivered-twice-still-leaves-one-terminal-event-and-no-forward-execution-af",
29316
+ "file": "v2-terminal-event-once.test.ts",
29317
+ "line": 99,
29318
+ "title": "work delivered twice still leaves one terminal event and no forward execution after it",
29319
+ "explicitId": "openwop.requirement.0194.terminal-once.duplicate-delivery",
29320
+ "citations": [
29321
+ {
29322
+ "section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
29323
+ "requirement": "under a duplicate delivery the log MUST still contain exactly one terminal run event and no forward-execution event after it"
29324
+ },
29325
+ {
29326
+ "section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
29327
+ "requirement": "the redelivered run's log reached a terminal event"
29328
+ },
29329
+ {
29330
+ "section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
29331
+ "requirement": null,
29332
+ "interpolated": true
29333
+ }
29334
+ ]
29335
+ },
29256
29336
  {
29257
29337
  "id": "openwop.it.v2-unmapped-type-refused.poll-over-a-log-with-an-unmapped-unprefixed-type-fails-with-500-event-type-unmap",
29258
29338
  "file": "v2-unmapped-type-refused.test.ts",
@@ -29603,7 +29683,7 @@
29603
29683
  {
29604
29684
  "id": "openwop.it.v2-webhook-egress-refusal.a-webhook-registration-naming-a-destination-the-guard-must-refuse-is-refused-400",
29605
29685
  "file": "v2-webhook-egress-refusal.test.ts",
29606
- "line": 82,
29686
+ "line": 92,
29607
29687
  "title": "a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected",
29608
29688
  "explicitId": "openwop.requirement.0171.webhook-egress-refused",
29609
29689
  "citations": [
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
3
3
  "counts": {
4
- "files": 524,
4
+ "files": 526,
5
5
  "v1": 445,
6
- "v2": 81
6
+ "v2": 83
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1253,6 +1253,9 @@
1253
1253
  "v2-bundle-v3-signed.test.ts": [
1254
1254
  2
1255
1255
  ],
1256
+ "v2-callback-url-guarded.test.ts": [
1257
+ 2
1258
+ ],
1256
1259
  "v2-capabilities-root-closed.test.ts": [
1257
1260
  2
1258
1261
  ],
@@ -1445,6 +1448,9 @@
1445
1448
  "v2-subject-link-record.test.ts": [
1446
1449
  2
1447
1450
  ],
1451
+ "v2-terminal-event-once.test.ts": [
1452
+ 2
1453
+ ],
1448
1454
  "v2-unmapped-type-refused.test.ts": [
1449
1455
  2
1450
1456
  ],
@@ -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.34.2",
5
- "corpusTag": "v2.34.0",
4
+ "version": "2.35.0",
5
+ "corpusTag": "v2.35.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": "d26d906b044e53fbb6ba742be90c853b9dcf14af4005bb6376e354d618fbdd5a",
13
- "api/v2/asyncapi.yaml": "eda3679b6902a3ab931e2dab55a04f6b6bb2b9b664748de38d8a104e53e93c76",
14
- "api/v2/openapi.yaml": "313fc72043a3730fbb98fdf47f4539773564bf9c5bed54f1f180efb50cf57410",
12
+ "api/seams-v2.yaml": "3ce63e8b7bd3b93b7bae997821d4f7f591da89c1045e76c28e7c3bd81561cae9",
13
+ "api/v2/asyncapi.yaml": "91a2b046c8cc3d24d4fca8e74da170f972e03115fa5a467f4f1a7db7113427a4",
14
+ "api/v2/openapi.yaml": "ddc0f56e92487f650fd8e5828cf61451617d7eefe425bd47d88d72675e008ab1",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -28,7 +28,7 @@
28
28
  "schemas/ai-envelope.schema.json": "78167a5767809286071bfcfcdfeceed0ed04c8b9f8c13079019d70eff4f351c1",
29
29
  "schemas/annotation-create.schema.json": "567e8327777968e0ddab8e1547b4acd7beda734cf9d1fc6cde17037d8d9540d4",
30
30
  "schemas/annotation.schema.json": "b26d22f9a97e1ae7c195e8799964aca804e78452a079ca3a0d61ea013907ce91",
31
- "schemas/artifact-type-pack-manifest.schema.json": "13932ea58e819e01b3a0cc7a65590b70683dd5fe9208fc06c64c339943d3c4ce",
31
+ "schemas/artifact-type-pack-manifest.schema.json": "67895666b7201bb82e846f3e1fb86ddd07aa3a6a89ece9f98462a7bc8f35ee96",
32
32
  "schemas/audit-verify-result.schema.json": "e8fd013fb610ba5db27ab0113ec2eb18e7da5f9310dd3de2dd3dc87a259c21fe",
33
33
  "schemas/budget-policy.schema.json": "3d4fe30ae129fe136ab9ece395f2a2a42a76360786b1b9a1d5f1cbc753eb5b39",
34
34
  "schemas/capabilities.schema.json": "b413e0235cad412081df09234237e0f322a85903b7411b91aaff54032e946671",
@@ -112,10 +112,10 @@
112
112
  "schemas/v2/ai-envelope.schema.json": "0dc7dfbf032a37239ffd81fc2d67f999a40782ff5074866a56e414164cdee809",
113
113
  "schemas/v2/annotation-create.schema.json": "ec32c22a243b343eeb6956a23be2d76ba5b50c8018ec3ec9d6efa5a1d9b0c792",
114
114
  "schemas/v2/annotation.schema.json": "eb4813cdaa8e325412cfcaeeee5c8a28f3751c946dec64e546c7ba0ad3aad8d4",
115
- "schemas/v2/artifact-type-pack-manifest.schema.json": "f77d8dcc3112afec8b2a8e67e56752d5811deb2c2455b0d6d4c6489eb4c4a829",
115
+ "schemas/v2/artifact-type-pack-manifest.schema.json": "abb60653f2f29925fb430fa04fa03fa67e14af47c1f8c5c07db324576fc17fa9",
116
116
  "schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
117
117
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
118
- "schemas/v2/capabilities.schema.json": "e62d44f23c21c8f64b273fcdb083636e801c0de976cbd1f93333f99928dd1025",
118
+ "schemas/v2/capabilities.schema.json": "304cdcebb291b633abaac9faf91bfb62377125e7d53434cc78f1789d4315e52f",
119
119
  "schemas/v2/certification-bundle.schema.json": "46a5a07670f2457bf1b7af8f6df1f4f76edb3ff7e64d1b6ed85738270c95668e",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
@@ -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": "67e8053f7355380ba2b7a1b3f56dfb761de38565ee9cdd9a86302307cb956736",
204
+ "spec/v1/core-standard-manifest.json": "ea55ec7d5a86d574479c545f3cb62ea6689ae91e4411c4ba6b27c22ca4d1f980",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -214,29 +214,29 @@
214
214
  "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
215
215
  "spec/v1/spec-gaps.json": "eb3bfcb9c7d05c9a6845d4a40ec9493cfaa5a671932daf1af561310136fdabe8",
216
216
  "spec/v2/README.md": "8dbfc17b10f373dba95bdd2e8fec0580f35b380a834d304be3e24e7498170185",
217
- "spec/v2/core/capabilities.md": "4c4e5577caf4ae20c84fe59d580de4c6599e76783471d3b59f9730fc3807de71",
218
- "spec/v2/core/conformance.md": "e5cb403f6ea6633eebc8ee4fa7bde5bf023bf4aee7c959b849e7105d37ab1f8b",
217
+ "spec/v2/core/capabilities.md": "8ffa639da1504d57df235e8685095d78fe9591ee3ef7e99fea359e9df99a54b4",
218
+ "spec/v2/core/conformance.md": "f224d49c5376ad39ea8a972cf623d5212a5fe140fd46ee83f234f92f6548ee0e",
219
219
  "spec/v2/core/connection-packs.md": "466bdb9dde79d85615ad8281dacfb7bf923ca22d7fb3ed219749e59574c753ed",
220
220
  "spec/v2/core/conversation.md": "e425887c4ba199b8cdc5c99e792689f7bd7d46de492f28670de2e402fa6d9506",
221
221
  "spec/v2/core/errors.md": "0dff4a0ae102a86f6a17a9a3b16a203292588a0f422ed19d1c0d9213995aa01d",
222
- "spec/v2/core/events.md": "c98742f5a310eee8287ca8135493502152e88b479200a6544bb3340fed1e4efe",
222
+ "spec/v2/core/events.md": "e90f860b48b87b5fbd3824029009c3044a7446715484c55d68cc4ec005a03a25",
223
223
  "spec/v2/core/form-content-packs.md": "af3517dd7125106088249c0e32220b0b9fc3e9304c10378a6327cc8ad84b6bf1",
224
224
  "spec/v2/core/headers.md": "14cdf4ae9ee7d80eebfd426d7c3552e577baea0300915cd4bb3a68e8588195a8",
225
225
  "spec/v2/core/host-services.md": "3753f1e034d325bb4bd5751ae6d321fbb16700d4e28032ab4e273d25eb134fc4",
226
226
  "spec/v2/core/idempotency.md": "e4edae1834101fd331379d84de77eaa9cead49383529c31f8bcd18322f67c965",
227
227
  "spec/v2/core/identity.md": "e44f34f7c2db1f12babb19ca8ee1e5b1401a89f38fd5c90d0c4b74c95f6e5d42",
228
228
  "spec/v2/core/interop.md": "6bd581556b9ab1a1509c01b9a1ec2bcb9e4a8300e68fb01570704417e70f20b9",
229
- "spec/v2/core/interrupt.md": "3a926d84b942d4ee7daccc4443c93dc29f6a69f2936ba813182f2c1336db2cbb",
229
+ "spec/v2/core/interrupt.md": "18bcdfee975b3d9c336bc9b1845176f5c158274f7b66bb1870c2d7b49d9cfcdc",
230
230
  "spec/v2/core/overview.md": "7dc00ace32d4e4ce929a383651f0029fc4b7091ec87487b1bf26ead1be209e0e",
231
231
  "spec/v2/core/packs.md": "1c0919059760f9a3940a93264a76b7e30f9a0f6f27ff19753cc88f15753d8088",
232
232
  "spec/v2/core/persistence.md": "9785e536362ec2000dab7fee9847543240d40a907745efc70cf2c8b4c1b8344e",
233
- "spec/v2/core/replay.md": "b980a66e2543107240e3dc4f58131b9f4ff52478737e79ade6d5777b26ad1119",
234
- "spec/v2/core/runs.md": "136319842bacbbb300178980e4b83444917d5b193cc5fdd92e39a4902ba8d3c0",
233
+ "spec/v2/core/replay.md": "02d2757bd263a3e7e06dd6af0da9d58dff37202941f84b6fc097c083a8186146",
234
+ "spec/v2/core/runs.md": "05fba9b83bbfcca4bc531f9623bff7bf79d5f587170ed5e8b26cd536a950b2eb",
235
235
  "spec/v2/core/security-defaults.md": "2a54ceaa4cef02c26ee0fbcf44f822ceeea79537c7d5a51ed300f21aa4fea576",
236
236
  "spec/v2/core/versioning.md": "56c1e273c790bcb0a2c3c760ffc7595afc9f5d4a745965e57f36aefcd456da76",
237
- "spec/v2/core/webhooks.md": "46c1311c1897236f9bf9db5025c8af31dd84cd19208da3e5e9ff69eec9436965",
237
+ "spec/v2/core/webhooks.md": "dca6ec657a7489d6469c2acb20f656e0bf9542dfc9144520f5441d83575c8025",
238
238
  "spec/v2/core/workflow-chain-packs.md": "df78b76dd79f2267fb6b72c08e0911e5f9d5bb1925c6efdd6deda62fe8a51dc8",
239
- "spec/v2/declaration.json": "0f35205af96f29cacced435d1b606e9e8292c74a5509444bec78f17d315d63bf",
239
+ "spec/v2/declaration.json": "3a5960e97782d64ace047e5a1edd4ae5a7b956bdc94ff7bcfd9f4c270740340c",
240
240
  "spec/v2/declaration.schema.json": "c86e3083fb9789c1958ecb4576cc6f71aa90e6f339521139238543ed08211dd4",
241
241
  "spec/v2/errors.json": "296117235d21ce4a393371c2f80caf03a4ae93d1f80a89f87cd3f56902cf5b01",
242
242
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
@@ -265,7 +265,7 @@
265
265
  "spec/v2/facets/auth.schema.json": "73440c944ad12c41aab0618aeb799a90a0b59e97fc685527f248afb7d015381a",
266
266
  "spec/v2/facets/envelopeStrictness.schema.json": "28be0a27f880bd45ec1222a16155f5e9167bbca57bf0322970fe1eb2654562aa",
267
267
  "spec/v2/facets/heartbeat.schema.json": "81d7fa1895a8b0241bd07cf00a7e7905bef0fd14eb3427325261c62eee5746a6",
268
- "spec/v2/facets/interrupt.schema.json": "c76c58c0abd99de619f8c8afad246568f5bc9cce3b7a0d097d16d87eba84be3a",
268
+ "spec/v2/facets/interrupt.schema.json": "3cc228b9bee48922b22bd6b0bee1a3244dc83d377a616cfb523435368da11ebb",
269
269
  "spec/v2/facets/mcp.schema.json": "f56609fe24ba6160ad29ae31aa16043354ff005e5cb6a0f3871c4d226087cc08",
270
270
  "spec/v2/facets/packs.schema.json": "d544a280419c0052ce0f3c3b378f7aa04085cadc31df52a3c8f43da600b131b1",
271
271
  "spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
@@ -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": "eaf652e3a01f02a1d9c4eee7d7ea2c2a0396fe5694b5dce4cb01ca2af2891cc5",
282
+ "spec/v2/release.json": "5b9a956506b011835f1bd2fec54064d9cc8657d8a45a371314850da377d69069",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "1a06f6f3c070c043a959f82ed3e91e22d26c658e"
285
+ "corpusCommit": "a7ff2254a6ed9cbd47e327a95eb280e4aaf23699"
286
286
  }
package/src/cli.ts CHANGED
@@ -724,7 +724,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
724
724
  discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
725
725
  claimedProfiles: claimed3,
726
726
  results: { totals: totals3, requirements: rows3 },
727
- witnessSha256: witnessDigest(rows3),
727
+ witnessSha256: witnessDigest(rows3, relaxations),
728
728
  assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
729
729
  ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
730
730
  // RFC 0158 §D: claimed ONLY when these rows support it. The verifier
@@ -959,6 +959,8 @@ async function main(): Promise<never> {
959
959
  `certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
960
960
  // RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
961
961
  `rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
962
+ // 2.35.0: derived from the signed `skipped` rows; checked against the signed discovery document.
963
+ `opted out: ${verdict.optedOut.length > 0 ? verdict.optedOut.join(', ') : '(none)'}`,
962
964
  '',
963
965
  'What this command does NOT do:',
964
966
  ' · It does not re-run anything. A host that measured itself wrongly, and signed',
@@ -87,12 +87,26 @@ export function canonicalJSON(value: unknown): string {
87
87
  return JSON.stringify(value);
88
88
  }
89
89
 
90
- /** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
91
- export function witnessDigest(rows: readonly BundleV3Requirement[]): string {
90
+ /**
91
+ * RFC 0148 §C — the digest over the reporter record (the requirement rows),
92
+ * and, from 2.35.0, the operator's declared relaxations when there are any.
93
+ *
94
+ * Why relaxations are in here: the attestation covers exactly
95
+ * `{ witnessSha256, host.build, suite.version, discovery.sha256 }`, so a
96
+ * `host.relaxations[]` beside the rows was UNSIGNED — strip it after signing
97
+ * and the verifier re-derives the relaxed profile as certified, on a bundle
98
+ * that still verifies. Included ONLY WHEN NON-EMPTY: the preimage is the rows
99
+ * array exactly as before for every bundle that declares nothing (all
100
+ * committed bundles digest unchanged, pinned in durability-evidence.test.ts),
101
+ * and `{ rows, relaxations }` otherwise. An older verifier fails closed on a
102
+ * 2.35.0 bundle that declares relaxations — `witness-digest`, never a pass.
103
+ */
104
+ export function witnessDigest(rows: readonly BundleV3Requirement[], relaxations?: readonly BundleV3Relaxation[]): string {
92
105
  const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
93
106
  // ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
94
107
  ...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
95
- return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
108
+ const preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
109
+ return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
96
110
  }
97
111
 
98
112
  /** The bytes the attestation covers. */
@@ -138,6 +152,22 @@ export interface V3Verdict {
138
152
  * checked. Read it before quoting a verdict.
139
153
  */
140
154
  readonly derivabilityChecked: boolean;
155
+ /**
156
+ * 2.35.0 — the profiles and families the operator opted out of, derived from
157
+ * the SIGNED `skipped` rows `behaviorGate` writes (`openwop.profile.<name>`,
158
+ * where a family is `family.<key>`). Derived rather than read from a root
159
+ * member because a root member would sit outside the attestation.
160
+ */
161
+ readonly optedOut: string[];
162
+ }
163
+
164
+ const OPT_OUT_ROW = 'openwop.profile.';
165
+ /** The opt-outs a bundle's signed rows record. */
166
+ export function optedOutFromRows(rows: ReadonlyArray<{ readonly id: string; readonly result: string; readonly detail?: string }>): string[] {
167
+ return rows
168
+ .filter((r) => r.id.startsWith(OPT_OUT_ROW) && r.result === 'skipped' && (r.detail ?? '').includes('OPENWOP_OPTED_OUT_PROFILES'))
169
+ .map((r) => r.id.slice(OPT_OUT_ROW.length))
170
+ .sort();
141
171
  }
142
172
 
143
173
  export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3Verdict {
@@ -154,8 +184,8 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
154
184
  const count = (d: BundleV3Result): number => rows.filter((r) => r.result === d).length;
155
185
  const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
156
186
  for (const k of Object.keys(expected) as (keyof typeof expected)[]) if (bundle.results?.totals?.[k] !== expected[k]) rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
157
- const digest = witnessDigest(rows);
158
- if (bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
187
+ const digest = witnessDigest(rows, bundle.host?.relaxations);
188
+ if (bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows and declared relaxations (${digest.slice(0, 12)})` });
159
189
  const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
160
190
  if (bundle.assertionCount !== assertions) rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
161
191
  const nonPass = rows.filter((r) => r.result !== 'executed-pass');
@@ -243,6 +273,20 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
243
273
  derivabilityChecked = false;
244
274
  } else {
245
275
  derivabilityChecked = true;
276
+ // RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same
277
+ // profile." The suite refuses that at run time (behaviorGate throws),
278
+ // so a bundle that records both was edited after the run or emitted by
279
+ // something else. Both inputs are signed — the rows through
280
+ // witnessSha256, the document through discovery.sha256 — so the
281
+ // contradiction is checkable offline, and it is bundle-wide: an opt-out
282
+ // list that is false makes every `skipped` row in the bundle suspect.
283
+ const root = document as unknown as Record<string, unknown>;
284
+ for (const name of optedOutFromRows(rows)) {
285
+ const advertised = name.startsWith('family.')
286
+ ? (() => { const v = root[name.slice('family.'.length)]; return typeof v === 'object' && v !== null && !Array.isArray(v); })()
287
+ : profileDerivable(document as DiscoveryPayload, name, targetMajor);
288
+ if (advertised) rejections.push({ kind: 'opted-out-but-advertised', detail: `${name} is recorded as an operator opt-out, but the captured discovery document advertises it — a host MUST NOT both advertise and opt out of the same profile (RFC 0148 §B)` });
289
+ }
246
290
  for (const p of bundle.claimedProfiles ?? []) {
247
291
  // §B(1) binds the CERTIFICATION, not the listing: a profile a bundle
248
292
  // names without certifying makes no claim for derivability to falsify.
@@ -268,5 +312,6 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
268
312
  verifierSignatureVerified,
269
313
  certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
270
314
  derivabilityChecked,
315
+ optedOut: optedOutFromRows(rows),
271
316
  };
272
317
  }
@@ -104,12 +104,26 @@ export function resolveItRecord(
104
104
  gate: { disposition: 'inapplicable' | 'skipped'; detail?: string } | undefined,
105
105
  noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string; conclusive?: true } | null,
106
106
  firstError?: string,
107
+ /**
108
+ * 2.35.0, major 2 (`conformance.md` §Bundle v3): a requirement the test did
109
+ * not observe records `blocked` even when the test asserted setup facts
110
+ * first. At major 1 a `blocked` note after assertions stays a
111
+ * `partial-witness` pass unless the leg used `blockedDespiteAssertions`.
112
+ *
113
+ * Why the rule changed: a `partial-witness` row is refused by the acceptance
114
+ * predicate but COUNTED by certification, so a profile could certify on a
115
+ * requirement nobody observed. Swept before the change: all 24 major-2
116
+ * sites were "requirement unobserved" (unreachable, control silent, window
117
+ * closed), none an optional extra. Scoped to major 2 because the 146 v1-side
118
+ * sites were not measured and v1 bundles are read through its EOS.
119
+ */
120
+ blockedStands = false,
107
121
  ): { disposition: Disposition; detail?: string } {
108
122
  if (state === 'fail') return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
109
123
  if (state === 'pass' && assertionCalls > 0) {
110
124
  // `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
111
125
  // assertions are not the requirement, and the requirement went unobserved.
112
- if (noted !== null && noted.kind === 'blocked' && noted.conclusive === true) return { disposition: 'blocked', detail: noted.reason };
126
+ if (noted !== null && noted.kind === 'blocked' && (noted.conclusive === true || blockedStands)) return { disposition: 'blocked', detail: noted.reason };
113
127
  // A leg that asserted AND THEN soft-skipped is only a partial witness, and
114
128
  // the file-level record has always said so (`resolveFileRecord` below).
115
129
  // This `it`-level record dropped the note — and the `it`-level rows are the
@@ -148,12 +162,12 @@ export function fileDisposition(
148
162
  return { disposition: 'inapplicable', detail: 'every test returned early through behaviorGate with zero assertions: profile not advertised in the captured discovery set' };
149
163
  }
150
164
  if (assertionCount === 0 && gateReason === 'skipped') {
151
- return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
165
+ return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
152
166
  }
153
167
  return { disposition: 'executed-pass' };
154
168
  }
155
169
  if (gateReason === 'inapplicable') return { disposition: 'inapplicable', detail: 'every test skipped: profile not advertised in the captured discovery set (behaviorGate)' };
156
- if (gateReason === 'skipped') return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
170
+ if (gateReason === 'skipped') return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
157
171
  return {
158
172
  disposition: 'blocked',
159
173
  detail:
@@ -0,0 +1,30 @@
1
+ /**
2
+ * RFC 0194 §A — the shape a run's log must have around its terminal event.
3
+ *
4
+ * Exactly one terminal run event, and after it no forward execution: no second
5
+ * terminal event, no run.started / run.resumed / run.resume-started /
6
+ * run.paused / run.restored-from-snapshot, no node.* and no interrupt.*.
7
+ * `compensation.*` and `run.dead-lettered` MAY follow (a compensating host
8
+ * unwinds after a cancelled parent — compensation.md §"Cancellation of the
9
+ * parent"); vendor-prefixed types are not constrained.
10
+ *
11
+ * Pure, so a scenario can add a leg under RFC 0194's ids cheaply, and so the
12
+ * rule is pinned without a host.
13
+ */
14
+ export const TERMINAL_RUN_EVENTS: ReadonlySet<string> = new Set(['run.completed', 'run.failed', 'run.cancelled']);
15
+ const FORWARD_RUN_EVENTS: ReadonlySet<string> = new Set(['run.started', 'run.resumed', 'run.resume-started', 'run.paused', 'run.restored-from-snapshot']);
16
+
17
+ /** The first violation of §A.1–§A.2 in `types` (log order), or null when the shape holds. */
18
+ export function terminalShapeViolation(types: readonly string[]): string | null {
19
+ const terminals = types.map((t, i) => [t, i] as const).filter(([t]) => TERMINAL_RUN_EVENTS.has(t));
20
+ if (terminals.length === 0) return 'the log carries no terminal run event (run.completed / run.failed / run.cancelled)';
21
+ if (terminals.length > 1) return `the log carries ${terminals.length} terminal run events (${terminals.map(([t, i]) => `${t}@${i}`).join(', ')}) — exactly one is allowed`;
22
+ const at = terminals[0]![1];
23
+ for (let i = at + 1; i < types.length; i++) {
24
+ const t = types[i]!;
25
+ if (FORWARD_RUN_EVENTS.has(t) || t.startsWith('node.') || t.startsWith('interrupt.')) {
26
+ return `${t} at index ${i} follows the terminal event ${types[at]} at index ${at} — only compensation.* and run.dead-lettered may`;
27
+ }
28
+ }
29
+ return null;
30
+ }
@@ -27,17 +27,45 @@
27
27
  * @see RFCS/0187-host-found-bindings.md §A
28
28
  */
29
29
  import { describe, it, expect } from 'vitest';
30
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
30
31
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
31
32
  import { v2Discovery, gateFamily } from '../lib/v2.js';
32
33
  import { readErrorCode } from '../lib/error-envelope.js';
33
34
  import { softSkip } from '../lib/soft-skip.js';
34
- import { resolveRegistrationUrl } from '../lib/webhook-receiver.js';
35
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
36
+ import { scaledTimeoutMs } from '../lib/polling.js';
35
37
  import { req } from '../lib/requirement-ids.js';
36
38
  import { projectBoundId, BOUND_ID } from '../lib/bound-id.js';
37
39
 
38
40
  const DOC = 'spec/v2/core/identity.md §5 (RFC 0187 §A)';
39
41
  const WEBHOOK_ID = 'openwop.requirement.0187.bound-id-kinds.webhook';
40
42
  const PER_KIND = 'openwop.requirement.0187.bound-id-kinds.per-kind';
43
+ /**
44
+ * 2.35.0 (openwop#1450). The mint leg above reads the id a host RETURNS; a
45
+ * subscriber identifies its deliveries by the id the host EMITS in the
46
+ * delivery headers. A tier-2 host bound the first and left the second bare,
47
+ * and this file stayed green — the defect surfaced only as a durability
48
+ * failure in another file, whose delivery filter keys on the header.
49
+ */
50
+ const WEBHOOK_EMITTED = 'openwop.requirement.0187.bound-id-kinds.webhook-emitted';
51
+
52
+ type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
53
+ /** The suite's receiver, bound the way every webhook scenario binds it (pinned port / public front honoured). */
54
+ async function startReceiver(): Promise<{ server: Server; url: string; deliveries: Delivery[] }> {
55
+ const deliveries: Delivery[] = [];
56
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
57
+ const chunks: Buffer[] = [];
58
+ request.on('data', (c: Buffer) => chunks.push(c));
59
+ request.on('end', () => { deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers }); res.writeHead(204); res.end(); });
60
+ });
61
+ const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
62
+ const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
63
+ const binding = receiverBinding();
64
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
65
+ const addr = server.address();
66
+ return { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/hook`, deliveries };
67
+ }
68
+ const header = (d: Delivery, name: string): string | undefined => { const v = d.headers[name]; return Array.isArray(v) ? v[0] : v; };
41
69
 
42
70
  async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
43
71
  try { return await fn(); } catch { return null; }
@@ -74,6 +102,11 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
74
102
  expect(reg.status, req(WEBHOOK_ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
75
103
  const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
76
104
  expectBound(webhookId, 'webhookId', 'POST /webhooks 201', WEBHOOK_ID);
105
+ // Never leave the subscription behind, whatever happens below (a leaked
106
+ // subscription is delivered to for the rest of the run and skews every
107
+ // later webhook file's attribution).
108
+ let deleted = false;
109
+ try {
77
110
  if (typeof webhookId !== 'string' || !BOUND_ID.test(webhookId)) return softSkip('blocked', `the mint answered ${JSON.stringify(webhookId)}, which is not a tenant-bound id — the projection and 403 legs have no bound id to drive (the grammar failure is recorded by the assertion above)`);
78
111
 
79
112
  // The 403 check §5 exists for: a bound id whose tenant is not the caller's.
@@ -86,10 +119,61 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
86
119
 
87
120
  // The accept side: the RFC 0184 projection on a tenant-bound parameter that is not runId.
88
121
  const del = await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
122
+ deleted = del?.status === 204;
89
123
  expect(
90
124
  del?.status ?? null,
91
125
  req(WEBHOOK_ID, DOC, `DELETE /webhooks/{webhookId} MUST accept the ~-projected spelling of the id it minted (RFC 0184 §A: every tenant-bound path parameter) — got ${del?.status ?? 'no response'} ${readErrorCode(del?.json) ?? ''}`.trim()),
92
126
  ).toBe(204);
127
+ } finally {
128
+ if (!deleted && typeof webhookId === 'string') await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
129
+ }
130
+ });
131
+
132
+ it('subscriptionId: the webhookId a host EMITS on a delivery is the one it minted', async () => {
133
+ const doc = await v2Discovery();
134
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
135
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised — nothing is delivered (gate recorded under openwop.family.webhooks)');
136
+ const rx = await startReceiver();
137
+ let webhookId: string | null = null;
138
+ try {
139
+ const registration = resolveRegistrationUrl(rx.url);
140
+ const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
141
+ if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
142
+ if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
143
+ return softSkip('blocked', `host SSRF guard rejected the suite receiver ${registration.url} (webhooks.md §SSRF requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
144
+ }
145
+ const minted = (reg.json as { webhookId?: unknown } | null)?.webhookId;
146
+ if (reg.status !== 201 || typeof minted !== 'string') return softSkip('blocked', `POST /webhooks answered ${reg.status} without a webhookId — the mint leg above owns that obligation; this leg needs an id to compare`);
147
+ webhookId = minted;
148
+ const create = await http(() => driver.post('/runs', { workflowId: 'conformance-noop' }));
149
+ const runId = (create?.json as { runId?: unknown } | null)?.runId;
150
+ if (create === null || create.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${create?.status ?? 'no response'} — no run, so no delivery to read`);
151
+ // Select the delivery by the RUN in the body, never by the header under
152
+ // test: filtering on the header is exactly how a bare header hid in the
153
+ // durability scenario (openwop#1450).
154
+ const ours = (): Delivery[] => rx.deliveries.filter((d) => { try { return (JSON.parse(d.body) as { runId?: unknown }).runId === runId; } catch { return false; } });
155
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
156
+ while (ours().length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
157
+ const delivery = ours()[0];
158
+ if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed`);
159
+ expect(
160
+ header(delivery, 'openwop-webhook-id'),
161
+ req(WEBHOOK_EMITTED, 'webhooks.md §Headers (RFC 0187 §A.1)', `the delivery's OpenWOP-Webhook-Id MUST equal the tenant-bound webhookId the host minted (${minted}) — a subscriber identifies its deliveries by the header, not by the 201 it received once`),
162
+ ).toBe(minted);
163
+ // The X-openwop-* family is required only of a host advertising BOTH
164
+ // majors (webhooks.md: dual emission through the overlap). A v2-only host
165
+ // sends none, and failing it for that would be the suite's error.
166
+ const both = Array.isArray(doc.protocolVersions) && doc.protocolVersions.some((v) => typeof v === 'string' && v.startsWith('1.'));
167
+ if (both) {
168
+ expect(
169
+ header(delivery, 'x-openwop-webhook-id'),
170
+ req(WEBHOOK_EMITTED, 'webhooks.md §Headers (dual emission, RFC 0176 §D.2)', `a host advertising both majors MUST send X-openwop-Webhook-Id with the same value as OpenWOP-Webhook-Id (${minted})`),
171
+ ).toBe(minted);
172
+ }
173
+ } finally {
174
+ if (webhookId !== null) await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId as string)}`));
175
+ await new Promise<void>((ok) => rx.server.close(() => ok()));
176
+ }
93
177
  });
94
178
 
95
179
  it('interruptId: the id a suspension puts on the wire is bound', async () => {
@@ -140,11 +224,17 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
140
224
  if (reg === null || reg.status !== 201) return softSkip('blocked', `POST /webhooks answered ${reg?.status ?? 'no response'} — no subscription to read a sink for`);
141
225
  const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
142
226
  if (typeof webhookId !== 'string') return softSkip('blocked', 'the mint returned no webhookId');
227
+ // This leg only READS the sink; it must not leave its subscription behind
228
+ // (measured: it left one on every run until 2.35.0).
229
+ try {
143
230
  const res = await http(() => driver.get(`/webhooks/${projectBoundId(webhookId)}/dead-letters`));
144
231
  if (res === null) return softSkip('blocked', 'GET /webhooks/{webhookId}/dead-letters unreachable (fetch failed)');
145
232
  expect(res.status, req(PER_KIND, 'RFC 0188 §A.1', 'a host advertising webhooks.deadLetter MUST serve the dead-letter read (200)')).toBe(200);
146
233
  const rows = ((res.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
147
234
  if (rows.length === 0) return softSkip('inapplicable', 'the subscription has no dead-lettered delivery in this run — the read is served and the shape is unwitnessed here; v2-webhook-durable-delivery drives an exhaustion and asserts the record');
148
235
  for (const r of rows) expectBound(r['deliveryId'], 'deliveryId', 'GET /webhooks/{webhookId}/dead-letters', PER_KIND);
236
+ } finally {
237
+ await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
238
+ }
149
239
  });
150
240
  });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * RFC 0196 §A.3 — a host that advertises `interrupt.callbackDelivery` holds
3
+ * `createRun.callbackUrl` to the `webhooks.md` §SSRF guard at create time
4
+ * (`spec/v2/core/interrupt.md` §Callback delivery). Target major 2.
5
+ *
6
+ * `callbackUrl` is an outbound request to a URL the CALLER chooses — the
7
+ * surface the SSRF guard exists for. Measured on three hosts before the RFC:
8
+ * one ignored it, one refused it, one delivered to it with no guard at all.
9
+ *
10
+ * Gated on the facet: a host that does not advertise delivery has no egress
11
+ * here to guard (it SHOULD refuse the member, which this file does not assert).
12
+ * No committed host advertises the facet today, so this row is witnessed on
13
+ * none; it is written so the first host that does is held to it.
14
+ *
15
+ * @see spec/v2/core/interrupt.md §Callback delivery
16
+ * @see RFCS/0196-callbackurl-and-embedded-ipv4-egress.md §A.3
17
+ */
18
+ import { describe, expect, it } from 'vitest';
19
+ import { driver } from '../lib/driver.js';
20
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
21
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
22
+ import { readErrorCode } from '../lib/error-envelope.js';
23
+ import { softSkip } from '../lib/soft-skip.js';
24
+ import { req } from '../lib/requirement-ids.js';
25
+
26
+ const ID = 'openwop.requirement.0196.callback-url-guarded';
27
+ const DOC = 'spec/v2/core/interrupt.md §Callback delivery (RFC 0196 §A.3)';
28
+ const FIXTURE = 'conformance-approval';
29
+ const PROBES = [
30
+ { url: 'https://127.0.0.1/openwop-callback-probe', cls: 'loopback (IPv4)' },
31
+ { url: 'https://169.254.169.254/latest/meta-data/', cls: 'cloud metadata' },
32
+ { url: 'https://[::ffff:7f00:1]/openwop-callback-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
33
+ ];
34
+
35
+ describe('v2 callbackUrl egress guard (RFC 0196 §A.3)', () => {
36
+ it('a host advertising callback delivery refuses a callbackUrl its egress guard would refuse', async () => {
37
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
38
+ const fam = await gateFamily('interrupt');
39
+ if (!fam) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
40
+ if (fam['callbackDelivery'] !== true) return softSkip('inapplicable', 'the host does not advertise interrupt.callbackDelivery — it delivers nothing to callbackUrl, so there is no egress to guard (RFC 0196 §A.2: it SHOULD refuse the member)');
41
+ const workflowId = isFixtureAdvertised(FIXTURE) ? FIXTURE : 'conformance-noop';
42
+ const accepted: string[] = [];
43
+ const wrong: string[] = [];
44
+ for (const p of PROBES) {
45
+ const r = await driver.post('/runs', { workflowId, callbackUrl: p.url });
46
+ if (r.status === 201) { accepted.push(`${p.cls} (${p.url})`); const id = (r.json as { runId?: unknown } | null)?.runId; if (typeof id === 'string') await driver.post(`/runs/${encodeURIComponent(id)}/cancel`, {}).catch(() => undefined); continue; }
47
+ const field = (r.json as { details?: { field?: unknown } } | null)?.details?.field;
48
+ if (r.status !== 400 || readErrorCode(r.json) !== 'validation_error' || field !== 'callbackUrl') wrong.push(`${p.cls}: ${r.status} ${readErrorCode(r.json) ?? ''} field=${String(field)}`);
49
+ }
50
+ expect(accepted, req(ID, DOC, 'a host advertising interrupt.callbackDelivery MUST refuse at createRun a callbackUrl the webhooks.md §SSRF registration guard would refuse')).toEqual([]);
51
+ expect(wrong, req(ID, DOC, 'the refusal MUST be 400 validation_error naming details.field "callbackUrl"')).toEqual([]);
52
+ });
53
+ });
@@ -58,7 +58,7 @@ function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean;
58
58
  { id: 'openwop-discovery-core', evidenceTier: 'self', witnessCount: 1, certified: true },
59
59
  ],
60
60
  results: { totals: { executedPass: ROWS.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: ROWS },
61
- witnessSha256: witnessDigest(ROWS),
61
+ witnessSha256: witnessDigest(ROWS, opts.relaxations),
62
62
  assertionCount: ROWS.reduce((n, r) => n + (r.assertions ?? 0), 0),
63
63
  };
64
64
  return { ...unsigned, signature: signBundleV3(unsigned, hostPem, 'host-key-1') };
@@ -105,6 +105,18 @@ describe('RFC 0173 §A.2 — relaxation-recorded (unaided, fixture bundle)', ()
105
105
  honest.rejections.map((r) => r.kind),
106
106
  req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'a relaxation recorded against an unclaimed profile is not a rejection — recording is the obligation, claiming is the defect'),
107
107
  ).not.toContain('relaxed-profile-certified');
108
+
109
+ // 2.35.0: the declaration is SIGNED. `host.relaxations[]` sits outside the
110
+ // attestation, so until the witness digest covered it a relaxation could be
111
+ // deleted after signing and the relaxed profile re-derived as certified on a
112
+ // bundle that still verified. Strip it from the honest bundle and re-verify.
113
+ const signed = bundle({ relaxations: [RELAXED], certified: false });
114
+ const stripped = { ...signed, host: { ...signed.host } };
115
+ delete (stripped.host as { relaxations?: unknown }).relaxations;
116
+ expect(
117
+ verifyBundleV3(stripped).rejections.map((r) => r.kind),
118
+ req('openwop.requirement.0173.relaxation-recorded', 'conformance.md §Bundle v3', 'a relaxation removed after signing MUST be detected — the declaration is covered by witnessSha256, so the stripped bundle fails `witness-digest`'),
119
+ ).toContain('witness-digest');
108
120
  });
109
121
 
110
122
  // Until 2.33.0 every leg above used the FIXTURE profile `openwop-webhooks`, and
@@ -0,0 +1,138 @@
1
+ /**
2
+ * RFC 0194 §A — a run's terminal event is emitted once and ends its forward
3
+ * execution (`spec/v2/core/events.md` §The terminal event). Target major 2.
4
+ *
5
+ * Found by two independently written hosts under duplicate delivery: one wrote
6
+ * `run.completed` then `node.failed`, the other two `run.completed` events
7
+ * (openwop#1445). Nothing in v2 said either was wrong.
8
+ *
9
+ * A DEDICATED file rather than a check bolted onto every scenario that reaches
10
+ * a terminal state: a host that violates this RFC must fail this RFC's rows,
11
+ * not unrelated ones (openwop#1450 showed what misattribution costs).
12
+ *
13
+ * The log is read after it SETTLES — two reads at least 2 s apart returning the
14
+ * same length, bounded at 20 s — because the second delivery's executor can
15
+ * finish after the first terminal event. Growth after a terminal event is the
16
+ * violation's own evidence, not an inconclusive read.
17
+ *
18
+ * @see spec/v2/core/events.md §The terminal event
19
+ * @see RFCS/0194-terminal-event-ends-forward-execution.md
20
+ */
21
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
22
+ import { describe, expect, it } from 'vitest';
23
+ import { driver } from '../lib/driver.js';
24
+ import { v2Discovery } from '../lib/v2.js';
25
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
26
+ import { softSkip } from '../lib/soft-skip.js';
27
+ import { scaledTimeoutMs } from '../lib/polling.js';
28
+ import { req } from '../lib/requirement-ids.js';
29
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
30
+ import { terminalShapeViolation, TERMINAL_RUN_EVENTS } from '../lib/terminal-shape.js';
31
+
32
+ const ONCE = 'openwop.requirement.0194.terminal-once';
33
+ const DUP = 'openwop.requirement.0194.terminal-once.duplicate-delivery';
34
+ const DOC = 'spec/v2/core/events.md §The terminal event (RFC 0194 §A)';
35
+ const TERMINAL_STATUS = new Set(['completed', 'failed', 'cancelled']);
36
+ const KILL_SEAM = '/host/durability/kill';
37
+
38
+ async function http<T>(fn: () => Promise<T>): Promise<T | null> { try { return await fn(); } catch { return null; } }
39
+
40
+ async function status(runId: string): Promise<string | null> {
41
+ const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
42
+ return r !== null && r.status === 200 ? String((r.json as { status?: unknown } | null)?.status ?? '') : null;
43
+ }
44
+ async function waitStatus(runId: string, want: (s: string) => boolean, timeoutMs: number): Promise<string | null> {
45
+ const deadline = Date.now() + scaledTimeoutMs(timeoutMs);
46
+ let s: string | null = null;
47
+ while (Date.now() < deadline) { s = await status(runId); if (s !== null && want(s)) return s; await new Promise((r) => setTimeout(r, 250)); }
48
+ return s;
49
+ }
50
+ async function logTypes(runId: string): Promise<string[] | null> {
51
+ const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
52
+ if (r === null || r.status !== 200) return null;
53
+ const ev = (r.json as { events?: Array<{ type?: unknown }> } | null)?.events;
54
+ return Array.isArray(ev) ? ev.map((e) => String(e.type)) : null;
55
+ }
56
+ /** Re-read until two reads ≥ 2 s apart agree (bounded); returns the last read. */
57
+ async function settledLog(runId: string): Promise<string[] | null> {
58
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
59
+ let last = await logTypes(runId);
60
+ while (Date.now() < deadline) {
61
+ await new Promise((r) => setTimeout(r, 2_000));
62
+ const next = await logTypes(runId);
63
+ if (next === null) return last;
64
+ if (last !== null && next.length === last.length) return next;
65
+ last = next;
66
+ }
67
+ return last;
68
+ }
69
+
70
+ describe('v2 terminal event (RFC 0194 §A)', () => {
71
+ it('every run observed to a terminal state has exactly one terminal event and no forward execution after it', async () => {
72
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
73
+ req(ONCE, DOC, 'a run\'s log MUST contain exactly one terminal run event and no forward-execution event after it');
74
+ const cases: Array<{ fixture: string; cancel: boolean; want: string }> = [
75
+ { fixture: 'conformance-noop', cancel: false, want: 'completed' },
76
+ { fixture: 'conformance-noop', cancel: false, want: 'completed' },
77
+ { fixture: 'conformance-failure', cancel: false, want: 'failed' },
78
+ { fixture: 'conformance-cancellable', cancel: true, want: 'cancelled' },
79
+ ];
80
+ const observed: Array<{ fixture: string; runId: string }> = [];
81
+ for (const c of cases) {
82
+ if (!isFixtureAdvertised(c.fixture)) continue;
83
+ const create = await http(() => driver.post('/runs', { workflowId: c.fixture }));
84
+ const runId = (create?.json as { runId?: unknown } | null)?.runId;
85
+ if (create === null || create.status !== 201 || typeof runId !== 'string') continue;
86
+ if (c.cancel) await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
87
+ // `cancelling` is not terminal; wait for the terminal status itself.
88
+ const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 30_000);
89
+ if (s !== null && TERMINAL_STATUS.has(s)) observed.push({ fixture: c.fixture, runId });
90
+ }
91
+ if (observed.length === 0) return softSkip('blocked', 'no advertised fixture produced a run that reached a terminal status — there is no log to check');
92
+ for (const o of observed) {
93
+ const types = await settledLog(o.runId);
94
+ if (types === null) return softSkip('blocked', `GET /runs/{runId}/events/poll did not answer for ${o.fixture} — the log is unobservable`);
95
+ expect(terminalShapeViolation(types), req(ONCE, DOC, `${o.fixture} run ${o.runId}: exactly one terminal run event, and no forward-execution event after it — log: ${types.join(' ')}`)).toBeNull();
96
+ }
97
+ }, 180_000);
98
+
99
+ it('work delivered twice still leaves one terminal event and no forward execution after it', async () => {
100
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
101
+ req(DUP, DOC, 'under a duplicate delivery the log MUST still contain exactly one terminal run event and no forward-execution event after it');
102
+ const probe = await http(() => driver.get(KILL_SEAM));
103
+ if (probe === null) return softSkip('blocked', `GET ${KILL_SEAM} unreachable (fetch failed)`);
104
+ if (probe.status === 404 || probe.status === 405) return softSkip('inapplicable', `no RFC 0158 durability test hook at ${KILL_SEAM} (HTTP ${probe.status}) — a black-box suite cannot make a host redeliver accepted work, so this row needs the hook`);
105
+ const rx = await startEffectReceiver();
106
+ try {
107
+ const target = resolveRegistrationUrl(rx.url);
108
+ const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url }));
109
+ const runId = (fired?.json as { runId?: unknown } | null)?.runId;
110
+ if (fired === null || fired.status >= 400 || typeof runId !== 'string') return softSkip('blocked', `the durability hook answered ${fired?.status ?? 'no response'} for mode=duplicate-delivery — no redelivered run to read`);
111
+ const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 60_000);
112
+ if (s === null || !TERMINAL_STATUS.has(s)) return softSkip('blocked', `the redelivered run did not reach a terminal status (last: ${s ?? 'unreadable'})`);
113
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
114
+ while (rx.arrivals === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
115
+ const types = await settledLog(runId);
116
+ if (types === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — the log is unobservable');
117
+ expect(types.some((t) => TERMINAL_RUN_EVENTS.has(t)), req(DUP, DOC, 'the redelivered run\'s log reached a terminal event')).toBe(true);
118
+ expect(terminalShapeViolation(types), req(DUP, DOC, `work delivered twice MUST leave exactly one terminal run event and nothing forward after it — log: ${types.join(' ')}`)).toBeNull();
119
+ } finally {
120
+ await new Promise<void>((ok) => rx.server.close(() => ok()));
121
+ }
122
+ }, 180_000);
123
+ });
124
+
125
+ async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: number }> {
126
+ const state = { arrivals: 0 };
127
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
128
+ request.on('data', () => { /* drain */ });
129
+ request.on('end', () => { state.arrivals++; res.writeHead(204); res.end(); });
130
+ });
131
+ const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
132
+ const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
133
+ const binding = receiverBinding();
134
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
135
+ const addr = server.address();
136
+ const out = { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/effect`, get arrivals() { return state.arrivals; } };
137
+ return out;
138
+ }
@@ -64,6 +64,16 @@ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
64
64
  { url: 'https://192.168.255.1/openwop-egress-probe', cls: 'RFC 1918 (192.168/16)' },
65
65
  { url: 'https://169.254.169.254/latest/meta-data/', cls: 'link-local / cloud metadata' },
66
66
  { url: 'https://[fd00::1]/openwop-egress-probe', cls: 'IPv6 ULA' },
67
+ // 2.35.0 — the same MUST, spelled the way a URL parser spells it. An
68
+ // IPv4-mapped IPv6 literal is normalised to HEX (`[::ffff:127.0.0.1]` has
69
+ // hostname `::ffff:7f00:1`), and a guard that matched only the dotted mapped
70
+ // form let loopback, metadata and RFC 1918 through at registration and at
71
+ // delivery. Measured on the steward's own reference host.
72
+ // These are loopback / link-local / RFC 1918 addresses by any reading of
73
+ // webhooks.md §SSRF, so they enforce the existing obligation — same id.
74
+ { url: 'https://[::ffff:7f00:1]/openwop-egress-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
75
+ { url: 'https://[::ffff:a9fe:a9fe]/latest/meta-data/', cls: 'cloud metadata as IPv4-mapped IPv6 (hex)' },
76
+ { url: 'https://[::ffff:a00:1]/openwop-egress-probe', cls: 'RFC 1918 (10/8) as IPv4-mapped IPv6 (hex)' },
67
77
  ];
68
78
 
69
79
  /** The `webhooks.*` obligations the operator declared relaxed, if any. */
package/src/setup.ts CHANGED
@@ -470,7 +470,7 @@ afterEach(({ task }) => {
470
470
  // `inapplicable` came out `blocked` — which denies certification bundle-wide).
471
471
  const noted = softSkipDispositionSince(file, _itSoftSkipMarks.get(file) ?? 0);
472
472
  const err = (task.result?.errors ?? [])[0] as { message?: string } | undefined;
473
- const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message);
473
+ const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2);
474
474
  disposition = rec.disposition;
475
475
  detail = rec.detail;
476
476
  }