@openwop/openwop-conformance 2.31.1 → 2.32.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,18 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.32.0] — 2026-09-20 — every published v2 pack failed the corpus schema, and two kill rows that mandated a fast bound
4
+
5
+ - **`v2-one-signing-scheme` now covers every bare `*-pack-manifest`, enumerated rather than listed** (openwop#1367). It pinned one file — `registry-version-manifest` — while six bare manifests kept the v1 `{ publicKeyRef, signatureRef, method }` block, closed, and `connection-pack-manifest` had no `signing` seat at all. Measured against the published v2 registry: **0 of 190** `pack.json` documents validated. New id `openwop.requirement.0177.one-signing-scheme.bare-manifests`. Sabotage-proven: fails on the 2.31.1 `node-pack-manifest`.
6
+ - **`openwop.requirement.0177.chain-fragment-config-open`** — `FragmentNode.config` is open exactly as the `WorkflowNode.config` it expands into. The v1→v2 seed closed it because it documents one key (`subChainRef`); 73 of 81 published chain packs failed on `config must NOT have additional properties`. With both corrections: **185 of 190** validate; the remaining five fail on constraints identical in v1 (registry data, not schema).
7
+ - **`v2-durability-recovery`: both kill rows observe until the host's DECLARED recovery bound elapses.** They read the log once, the instant discovery answered again — demanding resumption within ~0 ms of the listener returning, a fast bound RFC 0158 rejects. A host whose sweeper first ticks 5 s after boot against a derived 65 s bound failed, then resumed correctly ten seconds later. The bound (`GET /host/durability/bound`) is a ceiling for waiting only; 60 s fallback when unserved; the governing per-class figure is read off the seam's own response (`recoveryBoundMs`) when the host names one. The observation ceiling is 240 s by default and **operator-raisable** (`OPENWOP_DURABILITY_OBSERVATION_CEILING_MS`): a fixed ceiling would have moved the single read's defect from 0 s to 240 s and made a §B.6-conformant 12.5-minute leased-class bound uncertifiable. A declared bound over the ceiling records `blocked` with that precondition named, not fail. The row detail records the observed kill → resumption interval.
8
+ - **`kill-during-execution` asserts both clauses of §E item 11.** `status !== 'completed' || starts > 1` held forever for a run that was never resumed, so the row passed on a host that lost the work. Now: never observable as completed un-re-executed (latched across every observation) **and** resumed within the bound.
9
+ - **A resumption signal is a further `run.started` or the registry's own recovery event** (`workflow.restored`, `run.restored-from-snapshot`) — item 11's "equivalent progress-past-the-pre-kill-point signal", named.
10
+ - **`duplicate-delivery` counts the effect where it lands.** It counted rows per identity on `GET /runs/{id}/effects` — and a ledger keyed on effect identity admits at most one row per identity *by construction*, so the assertion held whatever the effect did. openwop-app sabotage-tested it before building to it: dedup claim forced to always win, then the effect emitted **twice per fire**; the per-identity count stayed 1 and the row stayed green. §C asks for invocation counts; the projection exposes identities. The scenario now starts its own receiver, passes `effectUrl` to the seam, and asserts **exactly one arrival**; the seam MUST stage work performing exactly one outbound effect addressed there and deliver it twice. The ledger read stays as a labelled consistency check. It also sends no `workflowId` (`conformance-noop` records no effect, so the row could only record `blocked`). Receiver reachability is a declared operator precondition, as for the webhook rows.
11
+ - **The run log is read from `GET /runs/{runId}/events/poll`, not `/events`.** At major 2 `/events` is `streamRunEvents`, SSE only; `pollRunEvents` is the JSON read. The scenario parsed the stream path as JSON, so on a host serving exactly what the OpenAPI states the read came back null and **`poison-exhaustion` resolved `executed-pass` with `partial-witness: blocked`** — its load-bearing clause never ran. Measured on the v2 reference host; openwop-app content-negotiates the stream path, which is why it never showed there. An unreadable log on a kill row is now `blocked`, never "nothing resumed".
12
+ - Four of the five durability defects were found by the openwop-app host reading the scenario against its own mechanism **before building the seam**; the fifth by the v2 reference host's first run. No host had witnessed the seam rows, so no bundle's seam rows are invalidated — but the reference host's committed `poison-exhaustion` pass was vacuous and is superseded by its next cut.
13
+ - **`--certify` no longer hands the bundle signing key to the vitest child.** The child's environment was `{ ...process.env }`, so `OPENWOP_BUNDLE_SIGNING_KEY` — the PKCS8 PEM — went with it, though only the parent signs. Reported by a host operator mid-cut: on macOS `npm exec` rewrites `process.title` and a process listing spills into the child's environ, so `ps` printed the signing key in full into a terminal and transcript. New `src/lib/child-env.ts`; both spawn sites use it, and a self-test asserts on `cli.ts`'s source that no child environment is a bare spread of `process.env`. The key **id** is not secret and stays. **If you listed processes during a `--certify` on ≤ 2.31.1, treat that key as seen and rotate it.**
14
+ - Suite pinned to corpus `2.32.0`.
15
+
3
16
  ## [2.31.1] — 2026-09-20 — major-correct shared polling
4
17
 
5
18
  - Fixed the shared run polling helper to use `/runs/{runId}` in the major-2 lane and retain `/v1/runs/{runId}` only in the major-1 lane. The old helper combined a `/v1` path with `OpenWOP-Version: 2`, so a conforming host correctly answered `protocol_version_mismatch`; `v2-durability-recovery` then misreported an unreachable run even though it had already failed terminally.
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.31.1 @openwop/spec-artifacts@2.31.1
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.32.0 @openwop/spec-artifacts@2.32.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
  ```
package/dist/cli.js CHANGED
@@ -47,6 +47,7 @@ import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witne
47
47
  import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
48
48
  import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
49
49
  import { v2ProfileIds } from './lib/v2-profiles.js';
50
+ import { childEnv } from './lib/child-env.js';
50
51
  function parseArgs(argv) {
51
52
  let baseUrl;
52
53
  let apiKey;
@@ -493,7 +494,7 @@ async function runCertify(args, baseUrl, apiKey) {
493
494
  // (and assertion count) here; the runner reads it after the run so bundle v2
494
495
  // rows come from what scenarios RECORDED, not from per-file pass/fail/skip.
495
496
  const ledgerFile = join(reportDir, 'requirement-ledger.jsonl');
496
- const env = { ...process.env };
497
+ const env = childEnv(process.env);
497
498
  env.OPENWOP_BASE_URL = baseUrl;
498
499
  env.OPENWOP_API_KEY = apiKey;
499
500
  env.OPENWOP_LEDGER_PATH = ledgerFile;
@@ -924,7 +925,7 @@ async function main() {
924
925
  }
925
926
  // Env vars OVERRIDE flags only when the flag was unset (consistent
926
927
  // with the rest of the harness — env wins on the absence of CLI input).
927
- const env = { ...process.env };
928
+ const env = childEnv(process.env);
928
929
  if (args.baseUrl)
929
930
  env.OPENWOP_BASE_URL = args.baseUrl;
930
931
  if (args.apiKey)
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The environment handed to the vitest child the CLI spawns.
3
+ *
4
+ * `{ ...process.env }` handed the child EVERYTHING the operator exported,
5
+ * including `OPENWOP_BUNDLE_SIGNING_KEY` — the PKCS8 PEM of the key that signs
6
+ * the certification bundle. Nothing in the child reads it: the signer runs in
7
+ * the PARENT, after the run (`cli.ts` `--certify`). It was there only because
8
+ * nothing took it out.
9
+ *
10
+ * Why that is a leak and not a tidiness point. Measured by a host operator
11
+ * during a production cut (2026-09-20): on macOS `npm exec` rewrites
12
+ * `process.title`, and a process listing (`ps`, `pgrep -fl`) then spills into
13
+ * the child's environ — so listing processes mid-cut printed the signing key,
14
+ * in full, into the operator's terminal and transcript. Same-uid or root only,
15
+ * but that is exactly who is looking at the terminal. A scenario, a reporter, a
16
+ * vitest plugin or a crash dump in the child had the same access for no reason.
17
+ *
18
+ * The rule: material only the parent uses never enters the child. The key IDS
19
+ * are not secret and stay (a scenario may legitimately name the key it expects).
20
+ */
21
+ export const PARENT_ONLY_ENV = [
22
+ 'OPENWOP_BUNDLE_SIGNING_KEY',
23
+ ];
24
+ export function childEnv(parent) {
25
+ const env = { ...parent };
26
+ for (const name of PARENT_ONLY_ENV)
27
+ delete env[name];
28
+ return env;
29
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.31.1",
4
- "stampSha256": "7d8c030abbf9fd38c0f527eaa57d0d1621bdbf1c94463854c33c2399e52866f9"
3
+ "version": "2.32.0",
4
+ "stampSha256": "09ede2a5f7639c91137ef502d8484ae467f51d0b66f37663081423c8c61f593f"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.31.1",
3
+ "version": "2.32.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.31.1"
59
+ "@openwop/spec-artifacts": "2.32.0"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -3,10 +3,10 @@
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
5
  "files": 581,
6
- "tests": 2205,
7
- "withStableId": 2205,
6
+ "tests": 2207,
7
+ "withStableId": 2207,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2159
9
+ "explicitIds": 2161
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26650,7 +26650,7 @@
26650
26650
  {
26651
26651
  "id": "openwop.it.v2-durability-recovery.accepted-work-survives-a-kill-before-dispatch-and-dispatches-on-resume",
26652
26652
  "file": "v2-durability-recovery.test.ts",
26653
- "line": 129,
26653
+ "line": 262,
26654
26654
  "title": "accepted work survives a kill before dispatch and dispatches on resume",
26655
26655
  "explicitId": "openwop.requirement.0158.kill-after-accept",
26656
26656
  "citations": [
@@ -26664,10 +26664,15 @@
26664
26664
  {
26665
26665
  "id": "openwop.it.v2-durability-recovery.work-executing-at-a-real-process-death-is-never-reported-complete-and-resumes",
26666
26666
  "file": "v2-durability-recovery.test.ts",
26667
- "line": 165,
26667
+ "line": 307,
26668
26668
  "title": "work executing at a real process death is never reported complete, and resumes",
26669
26669
  "explicitId": "openwop.requirement.0158.kill-during-execution",
26670
26670
  "citations": [
26671
+ {
26672
+ "section": "RFC 0158 §B.4 / §E item 11",
26673
+ "requirement": null,
26674
+ "interpolated": true
26675
+ },
26671
26676
  {
26672
26677
  "section": "RFC 0158 §B.4 / §E item 11",
26673
26678
  "requirement": null,
@@ -26678,10 +26683,15 @@
26678
26683
  {
26679
26684
  "id": "openwop.it.v2-durability-recovery.the-same-accepted-work-delivered-twice-fires-each-effect-exactly-once",
26680
26685
  "file": "v2-durability-recovery.test.ts",
26681
- "line": 199,
26686
+ "line": 354,
26682
26687
  "title": "the same accepted work delivered twice fires each effect exactly once",
26683
26688
  "explicitId": "openwop.requirement.0158.duplicate-delivery",
26684
26689
  "citations": [
26690
+ {
26691
+ "section": "RFC 0158 §C",
26692
+ "requirement": null,
26693
+ "interpolated": true
26694
+ },
26685
26695
  {
26686
26696
  "section": "RFC 0158 §C",
26687
26697
  "requirement": null,
@@ -26692,7 +26702,7 @@
26692
26702
  {
26693
26703
  "id": "openwop.it.v2-durability-recovery.the-declared-recovery-bound-is-derived-from-the-mechanism-that-enforces-it",
26694
26704
  "file": "v2-durability-recovery.test.ts",
26695
- "line": 238,
26705
+ "line": 428,
26696
26706
  "title": "the declared recovery bound is derived from the mechanism that enforces it",
26697
26707
  "explicitId": "openwop.requirement.0158.bound-is-derived",
26698
26708
  "citations": [
@@ -26706,7 +26716,7 @@
26706
26716
  {
26707
26717
  "id": "openwop.it.v2-durability-recovery.deterministically-failing-work-reaches-a-terminal-state-and-stops-being-retried",
26708
26718
  "file": "v2-durability-recovery.test.ts",
26709
- "line": 268,
26719
+ "line": 458,
26710
26720
  "title": "deterministically failing work reaches a terminal state and stops being retried",
26711
26721
  "explicitId": "openwop.requirement.0158.poison-exhaustion",
26712
26722
  "citations": [
@@ -36398,7 +36408,7 @@
36398
36408
  {
36399
36409
  "id": "openwop.it.v2-one-signing-scheme.registry-version-manifest-signing-has-exactly-one-scheme-ed25519-canonical-json",
36400
36410
  "file": "v2-one-signing-scheme.test.ts",
36401
- "line": 37,
36411
+ "line": 42,
36402
36412
  "title": "registry-version-manifest signing has exactly one scheme, ed25519-canonical-json, and no method / publicKeyRef",
36403
36413
  "explicitId": "openwop.requirement.0177.one-signing-scheme",
36404
36414
  "citations": [
@@ -36428,6 +36438,70 @@
36428
36438
  }
36429
36439
  ]
36430
36440
  },
36441
+ {
36442
+ "id": "openwop.it.v2-one-signing-scheme.every-bare-pack-manifest-carries-the-same-closed-keyid-scheme-signing-block-and",
36443
+ "file": "v2-one-signing-scheme.test.ts",
36444
+ "line": 62,
36445
+ "title": "every bare *-pack-manifest carries the same closed { keyId, scheme } signing block, and none keeps the v1 one",
36446
+ "explicitId": "openwop.requirement.0177.one-signing-scheme.bare-manifests",
36447
+ "citations": [
36448
+ {
36449
+ "section": "RFC 0177 §C.3",
36450
+ "requirement": "schemas/v2/ MUST hold the bare pack-manifest schemas this rule covers"
36451
+ },
36452
+ {
36453
+ "section": "RFC 0177 §C.3",
36454
+ "requirement": null,
36455
+ "interpolated": true
36456
+ },
36457
+ {
36458
+ "section": "RFC 0177 §C.3",
36459
+ "requirement": null,
36460
+ "interpolated": true
36461
+ },
36462
+ {
36463
+ "section": "RFC 0177 §C.3",
36464
+ "requirement": null,
36465
+ "interpolated": true
36466
+ },
36467
+ {
36468
+ "section": "RFC 0177 §C.3",
36469
+ "requirement": null,
36470
+ "interpolated": true
36471
+ },
36472
+ {
36473
+ "section": "RFC 0177 §C.3",
36474
+ "requirement": null,
36475
+ "interpolated": true
36476
+ },
36477
+ {
36478
+ "section": "RFC 0177 §C.3",
36479
+ "requirement": null,
36480
+ "interpolated": true
36481
+ }
36482
+ ]
36483
+ },
36484
+ {
36485
+ "id": "openwop.it.v2-one-signing-scheme.fragmentnode-config-is-open-exactly-as-the-workflownode-config-it-expands-into-a",
36486
+ "file": "v2-one-signing-scheme.test.ts",
36487
+ "line": 90,
36488
+ "title": "FragmentNode.config is open exactly as the WorkflowNode.config it expands into, and keeps its documented subChainRef key",
36489
+ "explicitId": "openwop.requirement.0177.chain-fragment-config-open",
36490
+ "citations": [
36491
+ {
36492
+ "section": "spec/v2/core/workflow-chain-packs.md §Parameter substitution",
36493
+ "requirement": "WorkflowNode.config is a free-form map validated against the typeId's own config schema — the premise this rule compares against"
36494
+ },
36495
+ {
36496
+ "section": "spec/v2/core/workflow-chain-packs.md §Parameter substitution",
36497
+ "requirement": "FragmentNode.config MUST be open: a fragment node expands into a WorkflowNode, so a config the expanded node may carry MUST be expressible in the fragment"
36498
+ },
36499
+ {
36500
+ "section": "spec/v2/core/workflow-chain-packs.md §Parameter substitution",
36501
+ "requirement": "opening the map MUST NOT drop the documented `subChainRef` key (RFC 0133)"
36502
+ }
36503
+ ]
36504
+ },
36431
36505
  {
36432
36506
  "id": "openwop.it.v2-path-parity.the-corpus-gate-script-exits-0",
36433
36507
  "file": "v2-path-parity.test.ts",
@@ -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.31.1",
5
- "corpusTag": "v2.31.0",
4
+ "version": "2.32.0",
5
+ "corpusTag": "v2.32.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": "dfecf104a518efca7b9b915578c4198c6a9e1f8965d8e84fc300bbbafc5503e7",
13
- "api/v2/asyncapi.yaml": "577bd2252f10d8c3dceaac0dc7855925cc466e06fd6bf1613b8e451869fcdc54",
14
- "api/v2/openapi.yaml": "82b884b23460438059a5b38b47b5f3e883d03365013ed8c2632b664417c0f699",
12
+ "api/seams-v2.yaml": "a1db4d5558ec828482688ad34db7a749f6f52ef102158d24036819402720a3e9",
13
+ "api/v2/asyncapi.yaml": "8bfff7d7d9c5ef82196d5b6da1566b36e7070d4996da731c4499fd90b8ee51a7",
14
+ "api/v2/openapi.yaml": "02338e18f6e0ba029dde8b6dadee126904d2ca97b10a6bdd43c7c1a423ad88d7",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -112,19 +112,19 @@
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": "85630d1b760d0b41cf5a6fad2acfada39abaf7f0befd95cc4334e1e883e9615d",
115
+ "schemas/v2/artifact-type-pack-manifest.schema.json": "f77d8dcc3112afec8b2a8e67e56752d5811deb2c2455b0d6d4c6489eb4c4a829",
116
116
  "schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
117
117
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
118
118
  "schemas/v2/capabilities.schema.json": "e62d44f23c21c8f64b273fcdb083636e801c0de976cbd1f93333f99928dd1025",
119
119
  "schemas/v2/certification-bundle.schema.json": "ab9204e37f9e0bba79857275cd38be2da98e54d26b4ecb97469829efe6ebea28",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
122
- "schemas/v2/chat-card-pack-manifest.schema.json": "8df0d9d273ae22efc6876d5586833f1d4c2e551edc3ac6cf6e8f0719329f03dd",
122
+ "schemas/v2/chat-card-pack-manifest.schema.json": "f7cf09b30d3e1d2251620d84ea9f65541438d015ab44aec10d1dcfe68d8bd496",
123
123
  "schemas/v2/compact-tool-descriptor.schema.json": "b5c418d72082d5b713bf318212a83397e5be98e56d25ca056302b41b8dd8273b",
124
124
  "schemas/v2/compensation-policy.schema.json": "b3c1142637f5bf980f2b0bb67adb47e8cb7abb99a0405a4b6816ccf5d9c089d6",
125
125
  "schemas/v2/compensation-projection.schema.json": "f46977c7076347b5473faa8c4bbe73d8321c33b33c98fff2f26ee7cbada4ed98",
126
126
  "schemas/v2/configurable.schema.json": "33a6884047e2490f723733bf8b74d3b9164e00374cfea6dc3eac5a70dc368861",
127
- "schemas/v2/connection-pack-manifest.schema.json": "7410a3011b28829b286e3ff764bdb283854dca69ea4b71a8441f8c1c4f1cc948",
127
+ "schemas/v2/connection-pack-manifest.schema.json": "4300e815cf376959f8250ed25ba0545ae8bd525e449c6442326934f451070cc9",
128
128
  "schemas/v2/conversation-event.schema.json": "e87b7fb36ab4ca0afe9f77993654065b28b67e5f24b20175907f90e238942025",
129
129
  "schemas/v2/conversation-turn.schema.json": "a15b43dc15dbcc6aaf216bfab7424c540b514d7e40e661a732384aad45387006",
130
130
  "schemas/v2/credential-provenance.schema.json": "f86b09e94b1c524dec7d1c6353965665ae8891e9517faef6e742d639c5578c99",
@@ -144,7 +144,7 @@
144
144
  "schemas/v2/error-envelope.schema.json": "bb3e6c487dafa951a56ff224a7c029c462f0f1b754a7f51f6729609b4f43a438",
145
145
  "schemas/v2/eval-summary.schema.json": "9d31025752d0f67ad3b8a32e82a5314972c530ef9f780dd82e484ae8c5131198",
146
146
  "schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
147
- "schemas/v2/form-content-pack-manifest.schema.json": "461529e4287ec3b15a9fbcde57226a1c6f17ea3ae71b33b418e6f595ff0619fe",
147
+ "schemas/v2/form-content-pack-manifest.schema.json": "8ec157cf51241d4df800398e811d2c35addd076b929c58be3ce3dcd671c67641",
148
148
  "schemas/v2/frontend-plugin-manifest.schema.json": "fed6b8ddb3056c53b1bfc91525410a4445a3c37a8bf9eb9f6f9964ffea26d993",
149
149
  "schemas/v2/goal.schema.json": "146c4182e36d3538778f947e668a92cdb69ec42ee0820104b00063b035e342d7",
150
150
  "schemas/v2/heartbeat-evaluated.schema.json": "4086b1ba1d0547c08b038dfb263ac89416a3e406430db1250156fb2f9b74f8d6",
@@ -156,12 +156,12 @@
156
156
  "schemas/v2/localized-content-section.schema.json": "29c7bad0ad9ebc6c45211282e8afb3fdb35a4ca075ce0404b6e2c29f35fa5313",
157
157
  "schemas/v2/memory-entry.schema.json": "290576ae84f4fef4cdf975932b87963283b11c50ea3d2654b32cd96aa6537c3d",
158
158
  "schemas/v2/memory-list-options.schema.json": "f66703bccb68efb28391c273abb66aa117ee5b5ce475c477846ed700c336afcd",
159
- "schemas/v2/node-pack-manifest.schema.json": "0da826ca63a29026f7a9551748048104910e0d9962db6bad2b50b97a24a9ea30",
159
+ "schemas/v2/node-pack-manifest.schema.json": "47d122f7d4711eaa13464c5487c26ed36103b7c85adf1987004bf2d6842d663b",
160
160
  "schemas/v2/orchestrator-decision.schema.json": "2da17538e84cfbe70151eb118f1ccf670cff58b8697148e394074d32ac4af6b4",
161
161
  "schemas/v2/org-chart-responsibility-view.schema.json": "8a2565e4cdc6737a3946a70799f93642808b92f9c8d56dcdff3a0782053b89a1",
162
162
  "schemas/v2/pack-lockfile.schema.json": "2685417702c105146b780cd99154296a46578fbb10d81c8c713348ee26fea898",
163
163
  "schemas/v2/prompt-kind.schema.json": "67251c937a5a91fb97c0422050e808dc0c162a9b8561366814cf87b0a7f62e00",
164
- "schemas/v2/prompt-pack-manifest.schema.json": "8e77d23840250640d26f2f99c1c5902e79bf4a6974cae2d6c6637176236bddb9",
164
+ "schemas/v2/prompt-pack-manifest.schema.json": "4b1e510341cc6ba167b007ecae38ce0d71d5b89c751671777c5457da926e9744",
165
165
  "schemas/v2/prompt-ref.schema.json": "11538fa36a7a652c93100cac4f1857eb4130137b546e3d94890618636d44b729",
166
166
  "schemas/v2/prompt-template.schema.json": "494fc83028cb3af5dbb8a943d0e83b2348f7775e9fa02369162bcb58a6f81559",
167
167
  "schemas/v2/proposal.schema.json": "cb5ea4fcb54be94fd2a92cf2799357dc73335181a89f5766f432d69e21cc3a80",
@@ -189,7 +189,7 @@
189
189
  "schemas/v2/ui-plugin-message.schema.json": "879d65867c01656b3962d64283e66364dcb8f626ad26c5fcf2d2d55a79ae0ce9",
190
190
  "schemas/v2/webhook-dead-letter-page.schema.json": "543f5ccc26d8f777684a037172ab7b9fdf70735e5394eeb42e5fa6569503f528",
191
191
  "schemas/v2/webhook-delivery.schema.json": "4e8ab3b722ad1731adf0548c1217f0de62da40de14e8abf77533aee4ace4c079",
192
- "schemas/v2/workflow-chain-pack-manifest.schema.json": "b61cc945b4df1c7093a1058fa71ee59ef10bacb3433ae4d7ae7536136e616cc2",
192
+ "schemas/v2/workflow-chain-pack-manifest.schema.json": "58fa5b67c6e3a320f77aa1eb5d5fa7c6c9b3356afe8b75ad74a7cf082496e04b",
193
193
  "schemas/v2/workflow-definition.schema.json": "9aafddec32b84eb6aec3185be024850f1b9444c6193568771b0b636595fef136",
194
194
  "schemas/v2/workload-identity.schema.json": "1f589debfac6d70e0908da511550ec69a27b4bcc8314551cd843b6d923605efc",
195
195
  "schemas/v2/workspace-file-create.schema.json": "b6b5260857318c3b67de0231ed15e3a746f525880a763e7b36a7854178dd10d0",
@@ -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": "0640fb5fdced5af7a5e2ae2108c69da13d79bd3c5373128ee5039e9cd1af0e43",
204
+ "spec/v1/core-standard-manifest.json": "01ed93af96a8ca6c562b8fa25658ff2e33a793970133c50838d50ac5e362819e",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -228,7 +228,7 @@
228
228
  "spec/v2/core/interop.md": "6bd581556b9ab1a1509c01b9a1ec2bcb9e4a8300e68fb01570704417e70f20b9",
229
229
  "spec/v2/core/interrupt.md": "3a926d84b942d4ee7daccc4443c93dc29f6a69f2936ba813182f2c1336db2cbb",
230
230
  "spec/v2/core/overview.md": "7dc00ace32d4e4ce929a383651f0029fc4b7091ec87487b1bf26ead1be209e0e",
231
- "spec/v2/core/packs.md": "cc06de2d49dd08410eececd5c279996ec839680553b332b4f33d6b31989b5757",
231
+ "spec/v2/core/packs.md": "1c0919059760f9a3940a93264a76b7e30f9a0f6f27ff19753cc88f15753d8088",
232
232
  "spec/v2/core/persistence.md": "9e241f84259e38848f4a2ad1c21debb0dffd5f8aafbad26408aa6cc8969bf93b",
233
233
  "spec/v2/core/replay.md": "b980a66e2543107240e3dc4f58131b9f4ff52478737e79ade6d5777b26ad1119",
234
234
  "spec/v2/core/runs.md": "136319842bacbbb300178980e4b83444917d5b193cc5fdd92e39a4902ba8d3c0",
@@ -274,13 +274,13 @@
274
274
  "spec/v2/facets/schemaVersions.schema.json": "890d3416c4b66511d51f329abc68e6f467f4338f16807e489fe682999b2b56b9",
275
275
  "spec/v2/facets/supportedEnvelopes.schema.json": "644f22cc2236e30a611b5b7f1ba0458ff1925d68b94c514d9672583224e126bd",
276
276
  "spec/v2/facets/webhooks.schema.json": "4bf3f8eb3161a43910efac4c44ccaf9567060c9da52d4cf09bcf3888d1b92192",
277
- "spec/v2/facets/workflowChainPacks.schema.json": "cd30907ac92396866a524e4db8d8c1537e3818cfff455d780efd72bcd0ebdac1",
277
+ "spec/v2/facets/workflowChainPacks.schema.json": "ea625ce818fbeb746478f00f31f96ee9613d538e4409c258c1bfb517acdeaba9",
278
278
  "spec/v2/id-field-bindings.json": "e73c06f3055ad30173507ac817a458b96362c8f26209ba105c53653292cf63d3",
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": "92131d62a7fcd12445d7e04eb588e2297f839b7e5e2a87f89ae9e61593f837cd",
282
+ "spec/v2/release.json": "df06e652e6f851338df9b4e0f230bb176511e62691e867f7228d05c4263b1bec",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "691974ccf758a6ea42dad8c38ab6b5570e8f89f2"
285
+ "corpusCommit": "7091dae28be398d49c446f779b4c91755a80a5ff"
286
286
  }
package/src/cli.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  } from './lib/profiles.js';
56
56
  import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
57
57
  import { v2ProfileIds } from './lib/v2-profiles.js';
58
+ import { childEnv } from './lib/child-env.js';
58
59
 
59
60
  interface ParsedArgs {
60
61
  readonly baseUrl: string | undefined;
@@ -525,7 +526,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
525
526
  // (and assertion count) here; the runner reads it after the run so bundle v2
526
527
  // rows come from what scenarios RECORDED, not from per-file pass/fail/skip.
527
528
  const ledgerFile = join(reportDir, 'requirement-ledger.jsonl');
528
- const env: NodeJS.ProcessEnv = { ...process.env };
529
+ const env: NodeJS.ProcessEnv = childEnv(process.env);
529
530
  env.OPENWOP_BASE_URL = baseUrl;
530
531
  env.OPENWOP_API_KEY = apiKey;
531
532
  env.OPENWOP_LEDGER_PATH = ledgerFile;
@@ -973,7 +974,7 @@ async function main(): Promise<never> {
973
974
 
974
975
  // Env vars OVERRIDE flags only when the flag was unset (consistent
975
976
  // with the rest of the harness — env wins on the absence of CLI input).
976
- const env: NodeJS.ProcessEnv = { ...process.env };
977
+ const env: NodeJS.ProcessEnv = childEnv(process.env);
977
978
  if (args.baseUrl) env.OPENWOP_BASE_URL = args.baseUrl;
978
979
  if (args.apiKey) env.OPENWOP_API_KEY = args.apiKey;
979
980
  if (args.impl) env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The environment handed to the vitest child the CLI spawns.
3
+ *
4
+ * `{ ...process.env }` handed the child EVERYTHING the operator exported,
5
+ * including `OPENWOP_BUNDLE_SIGNING_KEY` — the PKCS8 PEM of the key that signs
6
+ * the certification bundle. Nothing in the child reads it: the signer runs in
7
+ * the PARENT, after the run (`cli.ts` `--certify`). It was there only because
8
+ * nothing took it out.
9
+ *
10
+ * Why that is a leak and not a tidiness point. Measured by a host operator
11
+ * during a production cut (2026-09-20): on macOS `npm exec` rewrites
12
+ * `process.title`, and a process listing (`ps`, `pgrep -fl`) then spills into
13
+ * the child's environ — so listing processes mid-cut printed the signing key,
14
+ * in full, into the operator's terminal and transcript. Same-uid or root only,
15
+ * but that is exactly who is looking at the terminal. A scenario, a reporter, a
16
+ * vitest plugin or a crash dump in the child had the same access for no reason.
17
+ *
18
+ * The rule: material only the parent uses never enters the child. The key IDS
19
+ * are not secret and stay (a scenario may legitimately name the key it expects).
20
+ */
21
+ export const PARENT_ONLY_ENV: readonly string[] = [
22
+ 'OPENWOP_BUNDLE_SIGNING_KEY',
23
+ ];
24
+
25
+ export function childEnv(parent: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
26
+ const env: NodeJS.ProcessEnv = { ...parent };
27
+ for (const name of PARENT_ONLY_ENV) delete env[name];
28
+ return env;
29
+ }
@@ -10,7 +10,7 @@
10
10
  * v2 bundle could carry a durability row of any kind and the rung was
11
11
  * unwitnessable at major 2 even with the other four passing. The port is the
12
12
  * fifth `it` below, and it needs NO SEAM: at major 2 the canonical
13
- * `GET /runs/{runId}/events` answers the same question the v1 sample seam was
13
+ * `GET /runs/{runId}/events/poll` answers the same question the v1 sample seam was
14
14
  * invented to answer, so the row can never be `blocked` for want of a seam a
15
15
  * host did not wire. The v1 scenario stays where it is, unchanged.
16
16
  *
@@ -41,6 +41,18 @@
41
41
  * derived bound: a §B.5 violation that did not happen. Both measured failures
42
42
  * are recorded in the RFC.
43
43
  *
44
+ * ── 2.32.0: the observation runs to the DECLARED bound ───────────────────────
45
+ * Until 2.32.0 both kill rows read the log ONCE, the instant discovery answered
46
+ * again — mandating resumption within ~0 ms of the listener returning, i.e. a
47
+ * fast bound, which the RFC rejects. Found by the openwop-app host reading this
48
+ * file against its own sweeper BEFORE building the seam; a first witness on a
49
+ * host that re-enters runs at boot would have passed the single read and hidden
50
+ * it. `kill-during-execution` also asserted only the first clause of item 11
51
+ * and so passed on a host that never resumed. See the RFC's 2026-09-20 note.
52
+ * `duplicate-delivery` counted rows per identity on an identity-keyed ledger,
53
+ * which cannot show a double-fire; the effect is now counted at a receiver the
54
+ * suite owns. All four were found by hosts reading this file, not by a run.
55
+ *
44
56
  * ── What this file deliberately does not do ─────────────────────────────────
45
57
  * `durability/peer-resume` is the `durable-multi-instance` discriminator and is
46
58
  * NOT required for the rung these rows witness. §E makes it bundle-witnessed
@@ -52,6 +64,7 @@
52
64
  * @see RFCS/0158-durable-execution-and-disaster-recovery-qualification.md §B.4 §D.9 §E
53
65
  */
54
66
 
67
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
55
68
  import { describe, expect, it } from 'vitest';
56
69
  import { driver } from '../lib/driver.js';
57
70
  import { v2Discovery } from '../lib/v2.js';
@@ -59,6 +72,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
59
72
  import { softSkip } from '../lib/soft-skip.js';
60
73
  import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
61
74
  import { req } from '../lib/requirement-ids.js';
75
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
62
76
 
63
77
  const FIXTURE = 'conformance-noop';
64
78
  const FAILURE_FIXTURE = 'conformance-failure';
@@ -71,9 +85,23 @@ const ATTEMPT_TYPES = new Set(['node.started', 'node.retried']);
71
85
  * it is a wait for something that must not happen. */
72
86
  const QUIET_WINDOW_MS = 4_000;
73
87
 
74
- /** The canonical major-2 run-event read. Null when it does not answer. */
88
+ /**
89
+ * The canonical major-2 JSON read of a run's log. Null when it does not answer.
90
+ *
91
+ * `GET /runs/{runId}/events/poll` — NOT `/runs/{runId}/events`. At major 2 the
92
+ * latter is `streamRunEvents`, `text/event-stream` ONLY (api/v2/openapi.yaml);
93
+ * the JSON read is `pollRunEvents`. Until 2.32.0 this file parsed the STREAM
94
+ * path as JSON. A host that content-negotiates the stream path answered anyway;
95
+ * a host that serves exactly what the OpenAPI states answered SSE, the parse
96
+ * came back null, and `poison-exhaustion` — having already asserted the
97
+ * terminal status — soft-skipped its load-bearing clause and resolved
98
+ * `executed-pass` with the detail "partial-witness: blocked". Measured on the
99
+ * v2 reference host: a vacuous pass sitting in a bundle, from the suite reading
100
+ * the wrong one of the corpus's own two operations. `timeout=1`: existing
101
+ * events are returned at once; the wait only applies when there are none.
102
+ */
75
103
  async function runEvents(runId: string): Promise<Array<{ type: string }> | null> {
76
- const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events`);
104
+ const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
77
105
  if (r.status !== 200) return null;
78
106
  const events = (r.json as { events?: Array<{ type?: unknown }> } | null)?.events;
79
107
  if (!Array.isArray(events)) return null;
@@ -117,12 +145,117 @@ async function waitBack(deadlineMs: number): Promise<number | null> {
117
145
  return null;
118
146
  }
119
147
 
120
- /** Count the run-lifecycle re-starts on a run: §B.4's resumption observation. */
121
- async function runStartedCount(runId: string): Promise<number> {
122
- const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events`);
123
- if (r.status !== 200) return -1;
124
- const events = (r.json as { events?: Array<{ type?: string }> } | null)?.events ?? [];
125
- return events.filter((e) => e.type === 'run.started').length;
148
+ /**
149
+ * §B.4's resumption observation, read off the canonical event log.
150
+ *
151
+ * RFC 0158 §E item 11 names "a second `run.started`, or any equivalent
152
+ * progress-past-the-pre-kill-point signal". The registry already has two events
153
+ * whose whole meaning is that signal — `workflow.restored` ("an in-flight run is
154
+ * recovered from the event log on a fresh engine boot") and
155
+ * `run.restored-from-snapshot` — so a host that reports recovery with the event
156
+ * minted for it is not failed for declining to re-emit `run.started`.
157
+ */
158
+ const RESTORED_TYPES = new Set(['workflow.restored', 'run.restored-from-snapshot']);
159
+ interface Observation { readable: boolean; runStarted: number; nodeStarted: number; restored: number }
160
+ async function observe(runId: string): Promise<Observation> {
161
+ const events = await runEvents(runId);
162
+ if (events === null) return { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
163
+ const n = (pred: (t: string) => boolean): number => events.filter((e) => pred(e.type)).length;
164
+ return { readable: true, runStarted: n((t) => t === 'run.started'), nodeStarted: n((t) => t === 'node.started'), restored: n((t) => RESTORED_TYPES.has(t)) };
165
+ }
166
+
167
+ /**
168
+ * The longest this suite will wait for a resumption. 240 s by DEFAULT, and
169
+ * operator-raisable, because a fixed ceiling is the single read's defect moved
170
+ * from 0 s to 240 s: a host whose leased-class bound is 12.5 min (a 12 min
171
+ * dispatch lease + a 30 s orphan sweep) is conformant under §B.6, would observe
172
+ * nothing in 240 s, record `blocked`, and — `blocked` denying certification
173
+ * (RFC 0168 §E.1) — could never certify the rung without shortening a lease,
174
+ * the outcome §"Alternatives considered" rejects. An operator with a long bound
175
+ * sets `OPENWOP_DURABILITY_OBSERVATION_CEILING_MS` and waits it out; a
176
+ * 13-minute row in a certification cut is affordable, an uncertifiable
177
+ * conformant host is not. The `it` timeouts below scale from it.
178
+ */
179
+ const DEFAULT_OBSERVATION_CEILING_MS = 240_000;
180
+ const OBSERVATION_CEILING_MS = ((): number => {
181
+ const raw = Number(process.env['OPENWOP_DURABILITY_OBSERVATION_CEILING_MS']);
182
+ return Number.isFinite(raw) && raw >= DEFAULT_OBSERVATION_CEILING_MS ? raw : DEFAULT_OBSERVATION_CEILING_MS;
183
+ })();
184
+ /** RESUME_WINDOW + the observation + slack for the reads themselves. */
185
+ const KILL_ROW_TIMEOUT_MS = OBSERVATION_CEILING_MS + 120_000;
186
+ /** Used only when the host serves no bound to read; named in the row's detail. */
187
+ const UNDECLARED_BOUND_FALLBACK_MS = 60_000;
188
+
189
+ /**
190
+ * How long to keep observing: the host's OWN declared recovery bound.
191
+ *
192
+ * §E item 11: resumption "MUST be observed on a subsequent observation within
193
+ * the declared recovery bound", and §B.6 makes a long bound conformant. Until
194
+ * 2.32.0 both kill rows read the log ONCE, the instant discovery answered
195
+ * again — which demanded resumption within ~0 ms of the listener coming up, a
196
+ * fast bound the RFC's §"Alternatives considered" explicitly rejects. A host
197
+ * whose sweeper first ticks 5 s after boot against a 65 s derived bound read
198
+ * `0 run.started` and failed, then resumed correctly ten seconds later. The
199
+ * bound is used as a CEILING FOR WAITING only; it is never asserted as a
200
+ * scalar here (`bound-is-derived` owns the arithmetic).
201
+ */
202
+ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared: boolean }> {
203
+ // A host whose bound is PER CLASS (Unresolved Question 1: unleased work waits
204
+ // out an outbox lease, leased work a dispatch lease — 65 s against 750 s on
205
+ // one measured host) names the figure that governs THIS work on the seam's
206
+ // own response. The bare read below returns one class and would report the
207
+ // interval against a bound that does not govern the staged run.
208
+ const governing = (fired as { recoveryBoundMs?: unknown } | null)?.recoveryBoundMs;
209
+ if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true };
210
+ const r = await driver.get('/host/durability/bound');
211
+ const bound = (r.json as { bound?: unknown } | null)?.bound;
212
+ return r.status === 200 && typeof bound === 'number' && Number.isFinite(bound) && bound > 0
213
+ ? { ms: bound, declared: true }
214
+ : { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
215
+ }
216
+
217
+ interface Watch { resumedAfterMs: number | null; last: Observation; completedUnresumed: boolean; waitedMs: number }
218
+ /**
219
+ * Observe `runId` from the moment the service answers again until `resumed`
220
+ * holds or `budgetMs` elapses. `completedUnresumed` latches if ANY observation
221
+ * shows the run `completed` while `resumed` is still false — the item-11 defect
222
+ * is a state a later observation can paper over, so it is checked at every read.
223
+ */
224
+ async function watchForResumption(runId: string, budgetMs: number, resumed: (o: Observation) => boolean): Promise<Watch> {
225
+ const t0 = Date.now();
226
+ let last: Observation = { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
227
+ let completedUnresumed = false;
228
+ for (;;) {
229
+ last = await observe(runId);
230
+ const waitedMs = Date.now() - t0;
231
+ if (last.readable && resumed(last)) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
232
+ const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`);
233
+ if ((snap.json as { status?: unknown } | null)?.status === 'completed' && !(last.readable && resumed(last))) completedUnresumed = true;
234
+ if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
235
+ await new Promise((r) => setTimeout(r, 500));
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The suite's own destination for the one staged effect. Every request that
241
+ * reaches it is an INVOCATION — the thing §C says to count. Honours
242
+ * OPENWOP_WEBHOOK_RECEIVER_PORT so a tunnelled cut forwards here (the
243
+ * certification setting is already `--max-workers 1`, so the pinned port is
244
+ * not contended by the webhook files).
245
+ */
246
+ async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: Array<{ method: string; at: number }> }> {
247
+ const arrivals: Array<{ method: string; at: number }> = [];
248
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
249
+ request.on('data', () => { /* drain */ });
250
+ request.on('end', () => { arrivals.push({ method: request.method ?? '', at: Date.now() }); res.writeHead(204); res.end(); });
251
+ });
252
+ const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
253
+ const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
254
+ const binding = receiverBinding();
255
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
256
+ const addr = server.address();
257
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
258
+ return { server, url: `http://${binding.advertise}:${port}/effect`, arrivals };
126
259
  }
127
260
 
128
261
  describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-single-instance rows)', () => {
@@ -155,12 +288,21 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
155
288
  return softSkip('blocked', 'the service never answered again within the window — the operator precondition for this row is a restart supervisor (something must restart the killed instance; the suite cannot)');
156
289
  }
157
290
 
158
- const starts = await runStartedCount(runId);
291
+ // Accepted-but-undispatched work has, by definition, not executed: any
292
+ // dispatch evidence after the death is the resumption. Observed until the
293
+ // host's OWN declared bound elapses, never once at the instant of return.
294
+ const bound = await declaredBoundMs(fired.json);
295
+ const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
296
+ const w = await watchForResumption(runId, budget, (o) => o.runStarted >= 1 || o.nodeStarted >= 1 || o.restored >= 1);
297
+ if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
298
+ if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
299
+ return softSkip('blocked', `no dispatch observed in ${w.waitedMs}ms, but the host declares a ${bound.ms}ms recovery bound and this run observes for at most ${OBSERVATION_CEILING_MS}ms — a bound longer than the observation ceiling is conformant (§B.6) and is neither witnessed nor refuted here; the operator precondition for this row is OPENWOP_DURABILITY_OBSERVATION_CEILING_MS >= the declared bound`);
300
+ }
159
301
  expect(
160
- starts >= 1,
161
- req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume — the run's log MUST show it being executed after the kill, observed ${starts} run.started (service answered again after ${backIn}ms)`),
302
+ w.resumedAfterMs !== null,
303
+ req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume within the declared recovery bound — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.nodeStarted} node.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; dispatch first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
162
304
  ).toBe(true);
163
- }, 120_000);
305
+ }, KILL_ROW_TIMEOUT_MS);
164
306
 
165
307
  it('work executing at a real process death is never reported complete, and resumes', async () => {
166
308
  const doc = await v2Discovery();
@@ -183,18 +325,31 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
183
325
  return softSkip('blocked', 'the service never answered again within the window — the operator precondition for this row is a restart supervisor (something must restart the killed instance; the suite cannot)');
184
326
  }
185
327
 
186
- // §E item 11's first clause, and the one a host is most likely to get
187
- // wrong: work that was executing when the process died MUST NOT be
188
- // observable as completed. A host that marks it complete on restart has
189
- // reported success for work it never finished.
190
- const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`);
191
- const status = (snap.json as { status?: unknown } | null)?.status;
192
- const starts = await runStartedCount(runId);
328
+ // §E item 11 is TWO clauses and until 2.32.0 only the first was asserted:
329
+ // `status !== 'completed' || starts > 1` holds forever for a run that is
330
+ // simply never resumed, so the row passed on a host that lost the work —
331
+ // weaker than its own RFC table row ("resumed within the declared bound").
332
+ // (1) work executing at the death MUST NOT be observable as completed
333
+ // without having been re-executed — latched across EVERY observation;
334
+ // (2) resumption MUST be observed within the declared recovery bound.
335
+ // Work that was executing already has one `run.started`; resumption is a
336
+ // further one, or the registry's own recovery event.
337
+ const bound = await declaredBoundMs(fired.json);
338
+ const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
339
+ const w = await watchForResumption(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
340
+ if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
193
341
  expect(
194
- status !== 'completed' || starts > 1,
195
- req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST NOT be observable as completed without having been re-executed — read status ${String(status)} with ${starts} run.started (service answered again after ${backIn}ms); §B.4 measures kill → RESUMPTION, never kill → terminal`),
342
+ w.completedUnresumed,
343
+ req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST NOT be observable as completed without having been re-executed — an observation after the kill read status completed with ${w.last.runStarted} run.started and ${w.last.restored} restored (service answered again after ${backIn}ms)`),
344
+ ).toBe(false);
345
+ if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
346
+ return softSkip('blocked', `no resumption observed in ${w.waitedMs}ms, but the host declares a ${bound.ms}ms recovery bound and this run observes for at most ${OBSERVATION_CEILING_MS}ms — a bound longer than the observation ceiling is conformant (§B.6) and is neither witnessed nor refuted here; the operator precondition for this row is OPENWOP_DURABILITY_OBSERVATION_CEILING_MS >= the declared bound`);
347
+ }
348
+ expect(
349
+ w.resumedAfterMs !== null,
350
+ req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST resume within the declared recovery bound; §B.4 measures kill → RESUMPTION, never kill → terminal — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; resumption first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
196
351
  ).toBe(true);
197
- }, 120_000);
352
+ }, KILL_ROW_TIMEOUT_MS);
198
353
 
199
354
  it('the same accepted work delivered twice fires each effect exactly once', async () => {
200
355
  const doc = await v2Discovery();
@@ -202,38 +357,73 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
202
357
  const seam = await probeSeam();
203
358
  if (seam.kind === 'absent') return softSkip('inapplicable', seam.why);
204
359
 
205
- const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', workflowId: FIXTURE });
206
- if (fired.status >= 400) {
207
- return softSkip('blocked', `the durability seam answered ${fired.status} for mode=duplicate-delivery — the host exposes the route but could not stage a double delivery`);
208
- }
209
- const runId = (fired.json as { runId?: unknown } | null)?.runId;
210
- if (typeof runId !== 'string') {
211
- return softSkip('blocked', 'the seam staged a duplicate delivery but named no runId');
212
- }
360
+ // THE EFFECT IS COUNTED WHERE IT LANDS, not where the host says it landed.
361
+ // Until 2.32.0 this row counted rows per identity on GET /runs/{id}/effects.
362
+ // A ledger keyed on effect identity admits AT MOST ONE ROW per identity by
363
+ // construction — a second fire at the same identity writes the same key —
364
+ // so `no identity appears twice` held whatever the effect did. Measured by
365
+ // the openwop-app host before it built to this row: with its dedup claim
366
+ // forced to always win, and then with the effect emitted twice per fire (an
367
+ // unambiguous double-fire), the per-identity count stayed 1 and the row
368
+ // stayed green. §C asks for INVOCATION counts; the projection exposes
369
+ // IDENTITIES. The only black-box oracle for "fired once" is a receiver the
370
+ // suite owns, so the seam aims the staged work's effect at `effectUrl`.
371
+ //
372
+ // NO `workflowId` is sent: no canonical fixture is guaranteed effectful
373
+ // (`conformance-noop` records none). The seam is host test infrastructure
374
+ // (§E) and chooses the work; it MUST stage work that performs EXACTLY ONE
375
+ // outbound effect, addressed to `effectUrl`, and deliver it twice.
376
+ const rx = await startEffectReceiver();
377
+ try {
378
+ const target = resolveRegistrationUrl(rx.url);
379
+ const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url });
380
+ if (fired.status >= 400) {
381
+ return softSkip('blocked', `the durability seam answered ${fired.status} for mode=duplicate-delivery with effectUrl ${target.tunnelled ? '(tunnelled)' : rx.url} — the host exposes the route but could not stage a double delivery; if its egress guard refused the receiver, the operator precondition is the webhook rows' own: a publicly-resolvable https front (OPENWOP_WEBHOOK_RECEIVER_URL) or a host run with its private-egress relaxation recorded`);
382
+ }
383
+ const runId = (fired.json as { runId?: unknown } | null)?.runId;
384
+ if (typeof runId !== 'string') {
385
+ return softSkip('blocked', 'the seam staged a duplicate delivery but named no runId');
386
+ }
213
387
 
214
- const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
215
- if (eff.status !== 200) {
216
- return softSkip('blocked', `GET /runs/{runId}/effects answered ${eff.status} — per-identity invocation counts are unobservable, so the assertion would be vacuous`);
217
- }
218
- const effects = (eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? [];
219
- if (effects.length === 0) {
220
- return softSkip('blocked', 'the staged run recorded no effects — there is no identity to count invocations against, and an end-state assertion is exactly what §C rules out');
221
- }
222
- // §C: assert INVOCATION COUNTS PER IDENTITY, not final state. A legal end
223
- // state is precisely what a double-fire produces, so an end-state
224
- // assertion passes on the defect it exists to catch.
225
- const byIdentity = new Map<string, number>();
226
- for (const e of effects) {
227
- const id = String(e['effectId'] ?? e['keying'] ?? '');
228
- if (id === '') continue;
229
- byIdentity.set(id, (byIdentity.get(id) ?? 0) + 1);
388
+ await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(60_000) });
389
+ // A LONGER wait is a STRONGER claim: this is a wait for a second arrival
390
+ // that must not happen, and the second delivery may trail the first.
391
+ await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
392
+
393
+ const arrivals = rx.arrivals.length;
394
+ if (arrivals === 0 && !target.tunnelled) {
395
+ return softSkip('blocked', `the staged work's effect never reached the suite's receiver at ${rx.url} — for mode=duplicate-delivery the seam MUST aim exactly one outbound effect at the given effectUrl; with nothing landed there is no invocation to count, and the ledger alone cannot witness a double-fire`);
396
+ }
397
+ // With a tunnel declared, zero arrivals is a hard failure, never a skip
398
+ // (webhook-receiver.ts): a mis-wired tunnel must not read as a pass.
399
+ expect(
400
+ arrivals,
401
+ req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the same accepted work delivered twice MUST fire each effect exactly once, counted at the effect's destination — the suite's receiver observed ${arrivals} arrival(s) of the one staged effect for run ${runId}`),
402
+ ).toBe(1);
403
+
404
+ // Secondary, and labelled for what it is: the host's own account agrees
405
+ // with what landed. On an identity-keyed ledger this can never exceed one
406
+ // row per identity, so it witnesses that the PROJECTION IS CONSISTENT, not
407
+ // that no double-fire happened — the arrival count above owns that.
408
+ const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
409
+ if (eff.status === 200) {
410
+ const effects = (eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? [];
411
+ const byIdentity = new Map<string, number>();
412
+ for (const e of effects) {
413
+ const id = String(e['effectId'] ?? e['keying'] ?? '');
414
+ if (id === '') continue;
415
+ byIdentity.set(id, (byIdentity.get(id) ?? 0) + 1);
416
+ }
417
+ const doubled = [...byIdentity.entries()].filter(([, n]) => n > 1);
418
+ expect(
419
+ doubled.length === 0,
420
+ req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the effect ledger MUST agree with the destination: no effect identity recorded more than once — ${byIdentity.size} identity/identities, ${doubled.length} recorded more than once${doubled.length ? ` (${doubled.map(([k, n]) => `${k}×${n}`).join(', ')})` : ''}`),
421
+ ).toBe(true);
422
+ }
423
+ } finally {
424
+ await new Promise<void>((r) => rx.server.close(() => r()));
230
425
  }
231
- const doubled = [...byIdentity.entries()].filter(([, n]) => n > 1);
232
- expect(
233
- doubled.length === 0,
234
- req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the same accepted work delivered twice MUST fire each effect exactly once, asserted per effect identity — ${byIdentity.size} identity/identities recorded, ${doubled.length} fired more than once${doubled.length ? ` (${doubled.map(([k, n]) => `${k}×${n}`).join(', ')})` : ''}`),
235
- ).toBe(true);
236
- }, 120_000);
426
+ }, 180_000);
237
427
 
238
428
  it('the declared recovery bound is derived from the mechanism that enforces it', async () => {
239
429
  const doc = await v2Discovery();
@@ -290,7 +480,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
290
480
  ).toBe('failed');
291
481
 
292
482
  const before = await runEvents(runId);
293
- if (before === null) return softSkip('blocked', 'GET /runs/{runId}/events did not answer — attempts are unobservable, so boundedness would be a vacuous claim');
483
+ if (before === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — attempts are unobservable, so boundedness would be a vacuous claim');
294
484
  // Non-vacuity: the failure must actually be ON the log. Without this a host
295
485
  // returning an empty array sails through every count comparison below,
296
486
  // because 0 === 0 after any wait.
@@ -308,7 +498,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
308
498
  // reports a terminal status at some point. Count, wait, count again.
309
499
  await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
310
500
  const after = await runEvents(runId);
311
- if (after === null) return softSkip('blocked', 'the second GET /runs/{runId}/events did not answer, so the stability comparison has one side');
501
+ if (after === null) return softSkip('blocked', 'the second GET /runs/{runId}/events/poll did not answer, so the stability comparison has one side');
312
502
  const attemptsAfter = after.filter((e) => ATTEMPT_TYPES.has(e.type)).length;
313
503
  expect(
314
504
  attemptsAfter,