@openwop/openwop-conformance 2.31.0 → 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 +18 -0
- package/README.md +1 -1
- package/dist/cli.js +3 -2
- package/dist/lib/child-env.js +29 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +83 -9
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +3 -2
- package/src/lib/child-env.ts +29 -0
- package/src/lib/polling.ts +8 -2
- package/src/scenarios/v2-durability-recovery.test.ts +245 -55
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
16
|
+
## [2.31.1] — 2026-09-20 — major-correct shared polling
|
|
17
|
+
|
|
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.
|
|
19
|
+
- Added a two-major self-test that pins both paths. Suite remains pinned to corpus `2.31.0`; this is a conformance-harness patch only.
|
|
20
|
+
|
|
3
21
|
## [2.31.0] — 2026-09-19 — the fifth rung row, and the projection helper
|
|
4
22
|
|
|
5
23
|
- `v2-durability-recovery` gains `poison-exhaustion`, completing RFC 0158's `durable-single-instance` rung at major 2. The existing scenario was major-1 only and seam-bound; the port reads the canonical `GET /runs/{runId}/events` and needs **no seam**, so it can never record `blocked` for want of one. The v1 scenario is untouched.
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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":
|
|
7
|
-
"withStableId":
|
|
6
|
+
"tests": 2207,
|
|
7
|
+
"withStableId": 2207,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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.
|
|
5
|
-
"corpusTag": "v2.
|
|
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": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
282
|
+
"spec/v2/release.json": "df06e652e6f851338df9b4e0f230bb176511e62691e867f7228d05c4263b1bec",
|
|
283
283
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
284
284
|
},
|
|
285
|
-
"corpusCommit": "
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/src/lib/polling.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { driver } from './driver.js';
|
|
31
|
+
import { targetMajor } from './seams.js';
|
|
31
32
|
|
|
32
33
|
export interface RunSnapshot {
|
|
33
34
|
readonly runId: string;
|
|
@@ -73,9 +74,14 @@ export function scaledTimeoutMs(timeoutMs: number): number {
|
|
|
73
74
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
74
75
|
|
|
75
76
|
export async function getRun(runId: string): Promise<RunSnapshot> {
|
|
76
|
-
|
|
77
|
+
// A major-2 request MUST use the unversioned path space. The driver adds the
|
|
78
|
+
// OpenWOP-Version: 2 header, so retaining `/v1` here asked the host to serve
|
|
79
|
+
// two different majors at once and a conforming negotiator correctly refused
|
|
80
|
+
// it with protocol_version_mismatch. Keep the v1 key only in the v1 lane.
|
|
81
|
+
const prefix = targetMajor() === 2 ? '' : '/v1';
|
|
82
|
+
const res = await driver.get(`${prefix}/runs/${encodeURIComponent(runId)}`);
|
|
77
83
|
if (res.status !== 200) {
|
|
78
|
-
throw new Error(`GET /
|
|
84
|
+
throw new Error(`GET ${prefix}/runs/${runId} returned ${res.status}: ${res.text.slice(0, 200)}`);
|
|
79
85
|
}
|
|
80
86
|
return res.json as RunSnapshot;
|
|
81
87
|
}
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume
|
|
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
|
-
},
|
|
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
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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,
|