@openwop/openwop-conformance 2.33.0 → 2.33.2
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 +17 -0
- package/README.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +9 -9
- package/schemas/CORPUS-STAMP.json +3 -3
- package/src/lib/durability-watch.ts +53 -0
- package/src/scenarios/v2-durability-recovery.test.ts +13 -21
- package/src/scenarios/v2-run-snapshot-etag.test.ts +34 -9
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +45 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.33.2] — 2026-09-21 — `v2-run-snapshot-etag` took its tag from a run that was still moving
|
|
4
|
+
|
|
5
|
+
- **The scenario slept a fixed 1 s after `POST /runs`, took the snapshot's `ETag`, and demanded `304`.** A host whose ETag tracks the run's event-log sequence — the strong tag `runs.md` §Snapshot asks for — is still appending while a noop executes, so between the two GETs the representation **changes** and `200` with a new tag is the only correct answer; a `304` there would be a stale-cache bug. **Measured on a tier-2 production host, 4 of 4:** immediately after create `etag1 ≠ etag2` and the conditional GET answers `200`; after the run settles the same request answers `304` every time. The row had passed three earlier cuts of that host and failed the fourth — a timing lottery, won whenever the run finished inside the sleep.
|
|
6
|
+
- **It now waits for the run to be terminal, and the violation is stated exactly:** a `200` whose `ETag` **equals** the `If-None-Match` that was sent. A `200` carrying a *different* tag means the representation moved and the tag sent was honestly stale, so the scenario takes the new tag and retries (up to four reads); a terminal run whose snapshot never holds still records `blocked`, not fail.
|
|
7
|
+
- A host with a **constant** ETag passed the old row for the wrong reason whenever it won the lottery; the non-matching control (`If-None-Match: "…no-such-tag"` MUST answer `200`) still catches a host that answers `304` to everything.
|
|
8
|
+
- False FAIL only. Found by the MyndHyve host session, reproduced by hand on production before being reported.
|
|
9
|
+
- Suite patch: corpus release stays `2.33.0`.
|
|
10
|
+
|
|
11
|
+
## [2.33.1] — 2026-09-21 — two false fails: a race between two reads, and a subscription nobody unregistered
|
|
12
|
+
|
|
13
|
+
- **`v2-durability-recovery`: the "completed without re-execution" latch was decided from two non-atomic reads in the wrong order.** Each iteration read the LOG, then the STATUS, and latched when the status said `completed` and the — older — log showed no resumption. A host that re-dispatched between the two requests read as "completed un-re-executed" while being neither. `conformance-noop` re-executes in milliseconds, so the window is real: per run roughly (gap between the requests) / (500 ms poll), a few percent. **Measured on a tier-1 host:** after a genuine SIGKILL and a correct recovery by lease expiry (727 s inside a declared 750 s bound) the row failed with *"read status completed with 2 run.started"* — printing the re-execution it was denying, because the message used a later read than the latch. The same host and code had passed the two previous runs.
|
|
14
|
+
- **Status first, log second.** A run's log is append-only, so a log read taken *after* a `completed` status can only show more than the status implied: if it still shows no resumption the defect is real, if it shows resumption nothing was wrong, if it is unreadable nothing is concluded. The loop moves to `src/lib/durability-watch.ts` with injected readers, and `durability-watch.test.ts` pins the ordering without a host — the race reproduced, the genuine defect still latching, the latch staying sticky, and an unreadable log concluding nothing.
|
|
15
|
+
- **The defect only ever produced FALSE FAILS, never false passes** — the latch could be set wrongly, never cleared wrongly — so an `executed-pass` on this row under 2.32.0 or 2.33.0 stands. A host that saw this row fail on those versions should re-run on 2.33.1 before triaging it.
|
|
16
|
+
- Found by the openwop-app host session reading the installed scenario after an unexplained red. Introduced in 2.32.0, by the same change that made the row assert both clauses of §E item 11.
|
|
17
|
+
- **`v2-webhook-delivery-shape` never unregistered anything, and behind a public front that failed a correct host.** On loopback each leg's receiver binds its own ephemeral port, so leg 1's leftover MAJOR-2 subscription delivered to a dead address and nobody noticed. Behind a tunnel every leg shares ONE URL on ONE pinned port: the still-live v2 subscription delivered its v2 rendering into leg 2, and the major-1 leg read it — *"a major-1 run.started payload MUST validate against the V1 definition … engineVersion must be string"* — on a host that had rendered both contracts correctly. It was the ONE failing row of the v2 reference host's first relaxation-free cut (241 pass / 1 fail / 0 blocked). **Reproduced with no ingress at all** by pinning `OPENWOP_WEBHOOK_RECEIVER_PORT` on a loopback run: ephemeral → 3/3, pinned → the major-1 leg fails; after the fix both → 3/3 and the host is left with **0** subscriptions. Every leg now unregisters what it registered, and — belt and braces — a delivery whose `OpenWOP-Webhook-Id` / `X-openwop-Webhook-Id` names a different subscription is not read as this leg's. The file also left a live subscription on every host it ever ran against; MyndHyve's operator had been sweeping them by hand after each tunnelled cut. The other major-2 webhook files were audited and already unregister.
|
|
18
|
+
- Suite patch: corpus release stays `2.33.0`.
|
|
19
|
+
|
|
3
20
|
## [2.33.0] — 2026-09-21 — a declared relaxation denied nothing, an undeclared one was invisible, and two fakes made both unavoidable
|
|
4
21
|
|
|
5
22
|
Step 2 of closing `docs/KNOWN-LIMITS.md` §"The reference host's certified bundles were cut under an undeclared relaxation". Three suite defects, all the corpus's.
|
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.33.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.33.2 @openwop/spec-artifacts@2.33.2
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.33.
|
|
3
|
+
"version": "2.33.2",
|
|
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.33.
|
|
59
|
+
"@openwop/spec-artifacts": "2.33.2"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -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": 254,
|
|
26654
26654
|
"title": "accepted work survives a kill before dispatch and dispatches on resume",
|
|
26655
26655
|
"explicitId": "openwop.requirement.0158.kill-after-accept",
|
|
26656
26656
|
"citations": [
|
|
@@ -26664,7 +26664,7 @@
|
|
|
26664
26664
|
{
|
|
26665
26665
|
"id": "openwop.it.v2-durability-recovery.work-executing-at-a-real-process-death-is-never-reported-complete-and-resumes",
|
|
26666
26666
|
"file": "v2-durability-recovery.test.ts",
|
|
26667
|
-
"line":
|
|
26667
|
+
"line": 299,
|
|
26668
26668
|
"title": "work executing at a real process death is never reported complete, and resumes",
|
|
26669
26669
|
"explicitId": "openwop.requirement.0158.kill-during-execution",
|
|
26670
26670
|
"citations": [
|
|
@@ -26683,7 +26683,7 @@
|
|
|
26683
26683
|
{
|
|
26684
26684
|
"id": "openwop.it.v2-durability-recovery.the-same-accepted-work-delivered-twice-fires-each-effect-exactly-once",
|
|
26685
26685
|
"file": "v2-durability-recovery.test.ts",
|
|
26686
|
-
"line":
|
|
26686
|
+
"line": 346,
|
|
26687
26687
|
"title": "the same accepted work delivered twice fires each effect exactly once",
|
|
26688
26688
|
"explicitId": "openwop.requirement.0158.duplicate-delivery",
|
|
26689
26689
|
"citations": [
|
|
@@ -26702,7 +26702,7 @@
|
|
|
26702
26702
|
{
|
|
26703
26703
|
"id": "openwop.it.v2-durability-recovery.the-declared-recovery-bound-is-derived-from-the-mechanism-that-enforces-it",
|
|
26704
26704
|
"file": "v2-durability-recovery.test.ts",
|
|
26705
|
-
"line":
|
|
26705
|
+
"line": 420,
|
|
26706
26706
|
"title": "the declared recovery bound is derived from the mechanism that enforces it",
|
|
26707
26707
|
"explicitId": "openwop.requirement.0158.bound-is-derived",
|
|
26708
26708
|
"citations": [
|
|
@@ -26716,7 +26716,7 @@
|
|
|
26716
26716
|
{
|
|
26717
26717
|
"id": "openwop.it.v2-durability-recovery.deterministically-failing-work-reaches-a-terminal-state-and-stops-being-retried",
|
|
26718
26718
|
"file": "v2-durability-recovery.test.ts",
|
|
26719
|
-
"line":
|
|
26719
|
+
"line": 450,
|
|
26720
26720
|
"title": "deterministically failing work reaches a terminal state and stops being retried",
|
|
26721
26721
|
"explicitId": "openwop.requirement.0158.poison-exhaustion",
|
|
26722
26722
|
"citations": [
|
|
@@ -28985,7 +28985,7 @@
|
|
|
28985
28985
|
{
|
|
28986
28986
|
"id": "openwop.it.v2-run-snapshot-etag.a-matching-if-none-match-receives-304-with-no-body-and-the-version-header-a-non",
|
|
28987
28987
|
"file": "v2-run-snapshot-etag.test.ts",
|
|
28988
|
-
"line":
|
|
28988
|
+
"line": 35,
|
|
28989
28989
|
"title": "a matching If-None-Match receives 304 with no body and the version header; a non-matching one receives 200",
|
|
28990
28990
|
"explicitId": "openwop.requirement.0170.run-snapshot-etag",
|
|
28991
28991
|
"citations": [
|
|
@@ -29440,7 +29440,7 @@
|
|
|
29440
29440
|
{
|
|
29441
29441
|
"id": "openwop.it.v2-webhook-delivery-shape.a-major-2-subscriber-receives-the-v2-rendering-the-delivery-validates-and-run-st",
|
|
29442
29442
|
"file": "v2-webhook-delivery-shape.test.ts",
|
|
29443
|
-
"line":
|
|
29443
|
+
"line": 220,
|
|
29444
29444
|
"title": "a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal",
|
|
29445
29445
|
"explicitId": "openwop.requirement.0171.webhook-delivery-shape",
|
|
29446
29446
|
"citations": [
|
|
@@ -29471,7 +29471,7 @@
|
|
|
29471
29471
|
{
|
|
29472
29472
|
"id": "openwop.it.v2-webhook-delivery-shape.a-major-1-subscriber-still-receives-the-v1-rendering-the-v1-wire-does-not-move-m",
|
|
29473
29473
|
"file": "v2-webhook-delivery-shape.test.ts",
|
|
29474
|
-
"line":
|
|
29474
|
+
"line": 240,
|
|
29475
29475
|
"title": "a major-1 subscriber still receives the v1 rendering — the v1 wire does not move mid-overlap",
|
|
29476
29476
|
"explicitId": "openwop.requirement.0171.webhook-delivery-shape",
|
|
29477
29477
|
"citations": [
|
|
@@ -29489,7 +29489,7 @@
|
|
|
29489
29489
|
{
|
|
29490
29490
|
"id": "openwop.it.v2-webhook-delivery-shape.a-seeded-era-2-run-is-delivered-projected-too-the-fan-out-branch-a-fresh-run-can",
|
|
29491
29491
|
"file": "v2-webhook-delivery-shape.test.ts",
|
|
29492
|
-
"line":
|
|
29492
|
+
"line": 264,
|
|
29493
29493
|
"title": "a seeded era-2 run is delivered projected too — the fan-out branch a fresh run cannot reach (seam-gated)",
|
|
29494
29494
|
"explicitId": "openwop.requirement.0171.webhook-delivery-shape",
|
|
29495
29495
|
"citations": [
|
|
@@ -1,7 +1,7 @@
|
|
|
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.33.
|
|
4
|
+
"version": "2.33.2",
|
|
5
5
|
"corpusTag": "v2.33.0",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
@@ -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": "65a8576c2e5ea9deb9d463be5b02a8bfb0e125a8dcbe3fe2cf286a4fd6d5d53e",
|
|
205
205
|
"spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
|
|
206
206
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
207
207
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -282,5 +282,5 @@
|
|
|
282
282
|
"spec/v2/release.json": "94a3ea302324d9f7df87ce62a186b0b98936de6ce5ac5ec01a3fb3208c38efc3",
|
|
283
283
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
284
284
|
},
|
|
285
|
-
"corpusCommit": "
|
|
285
|
+
"corpusCommit": "65e40d60fbf43b1a0cd95a58e63d7464ed944ddb"
|
|
286
286
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0158 §E item 11 — watching a run across a real process death.
|
|
3
|
+
*
|
|
4
|
+
* Two facts are wanted from one watch: (a) did resumption appear within the
|
|
5
|
+
* budget, and (b) was the run EVER observable as `completed` without having
|
|
6
|
+
* been re-executed. (b) is a statement about two pieces of host state — the
|
|
7
|
+
* status and the log — and they come from two requests, so the ORDER of the
|
|
8
|
+
* reads decides whether a conclusion is sound.
|
|
9
|
+
*
|
|
10
|
+
* Until 2.33.1 each iteration read the LOG, then the STATUS, and latched (b)
|
|
11
|
+
* when the status said `completed` and the (older) log showed no resumption. A
|
|
12
|
+
* host that re-dispatched between the two requests therefore read as "completed
|
|
13
|
+
* un-re-executed" while being neither: the log read predated the re-execution,
|
|
14
|
+
* the status read followed its completion. The staged work is `conformance-noop`,
|
|
15
|
+
* which re-executes in milliseconds, so the window is real — per run roughly
|
|
16
|
+
* (gap between the two requests) / (poll interval), a few percent. Measured on
|
|
17
|
+
* a tier-1 host: after a genuine SIGKILL and a correct recovery by lease expiry
|
|
18
|
+
* (727 s, inside a declared 750 s bound), the row failed with the message
|
|
19
|
+
* "read status completed with 2 run.started" — it printed the re-execution it
|
|
20
|
+
* was denying, because the message used a LATER read than the latch did. It had
|
|
21
|
+
* passed on the two previous runs of the same host and code.
|
|
22
|
+
*
|
|
23
|
+
* The rule: STATUS FIRST, LOG SECOND. A run's log is append-only, so a log read
|
|
24
|
+
* taken AFTER a `completed` status can only show MORE than the status implied.
|
|
25
|
+
* If that later log still shows no resumption, the run really was observable as
|
|
26
|
+
* completed un-re-executed. If it shows resumption, nothing was wrong. If it is
|
|
27
|
+
* unreadable, nothing is concluded — an unreadable log is never evidence.
|
|
28
|
+
*
|
|
29
|
+
* Readers are injected so the ordering itself is testable without a host.
|
|
30
|
+
*/
|
|
31
|
+
export interface Observation { readonly readable: boolean; readonly runStarted: number; readonly nodeStarted: number; readonly restored: number }
|
|
32
|
+
export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number }
|
|
33
|
+
export interface WatchIo {
|
|
34
|
+
readStatus(): Promise<string | null>;
|
|
35
|
+
readLog(): Promise<Observation>;
|
|
36
|
+
now(): number;
|
|
37
|
+
sleep(ms: number): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function watchForResumption(io: WatchIo, budgetMs: number, resumed: (o: Observation) => boolean, pollMs = 500): Promise<Watch> {
|
|
41
|
+
const t0 = io.now();
|
|
42
|
+
let completedUnresumed = false;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const status = await io.readStatus(); // FIRST
|
|
45
|
+
const last = await io.readLog(); // SECOND — never older than the status it is judged against
|
|
46
|
+
const waitedMs = io.now() - t0;
|
|
47
|
+
const isResumed = last.readable && resumed(last);
|
|
48
|
+
if (status === 'completed' && last.readable && !isResumed) completedUnresumed = true;
|
|
49
|
+
if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
|
|
50
|
+
if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
|
|
51
|
+
await io.sleep(pollMs);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -73,6 +73,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
73
73
|
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
74
74
|
import { req } from '../lib/requirement-ids.js';
|
|
75
75
|
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
76
|
+
import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
|
|
76
77
|
|
|
77
78
|
const FIXTURE = 'conformance-noop';
|
|
78
79
|
const FAILURE_FIXTURE = 'conformance-failure';
|
|
@@ -156,7 +157,6 @@ async function waitBack(deadlineMs: number): Promise<number | null> {
|
|
|
156
157
|
* minted for it is not failed for declining to re-emit `run.started`.
|
|
157
158
|
*/
|
|
158
159
|
const RESTORED_TYPES = new Set(['workflow.restored', 'run.restored-from-snapshot']);
|
|
159
|
-
interface Observation { readable: boolean; runStarted: number; nodeStarted: number; restored: number }
|
|
160
160
|
async function observe(runId: string): Promise<Observation> {
|
|
161
161
|
const events = await runEvents(runId);
|
|
162
162
|
if (events === null) return { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
|
|
@@ -214,26 +214,18 @@ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared:
|
|
|
214
214
|
: { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
interface Watch { resumedAfterMs: number | null; last: Observation; completedUnresumed: boolean; waitedMs: number }
|
|
218
217
|
/**
|
|
219
|
-
* Observe `runId`
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* is a state a later observation can paper over, so it is checked at every read.
|
|
218
|
+
* Observe `runId` until `resumed` holds or `budgetMs` elapses. The loop itself —
|
|
219
|
+
* and the reason the STATUS is read before the LOG — lives in
|
|
220
|
+
* `lib/durability-watch.ts`, where the ordering is unit-tested without a host.
|
|
223
221
|
*/
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
}
|
|
222
|
+
function watch(runId: string, budgetMs: number, resumed: (o: Observation) => boolean): Promise<Watch> {
|
|
223
|
+
return watchForResumption({
|
|
224
|
+
readStatus: async () => { const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`); const st = (snap.json as { status?: unknown } | null)?.status; return typeof st === 'string' ? st : null; },
|
|
225
|
+
readLog: () => observe(runId),
|
|
226
|
+
now: () => Date.now(),
|
|
227
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
228
|
+
}, budgetMs, resumed);
|
|
237
229
|
}
|
|
238
230
|
|
|
239
231
|
/**
|
|
@@ -293,7 +285,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
293
285
|
// host's OWN declared bound elapses, never once at the instant of return.
|
|
294
286
|
const bound = await declaredBoundMs(fired.json);
|
|
295
287
|
const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
|
|
296
|
-
const w = await
|
|
288
|
+
const w = await watch(runId, budget, (o) => o.runStarted >= 1 || o.nodeStarted >= 1 || o.restored >= 1);
|
|
297
289
|
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
290
|
if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
|
|
299
291
|
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`);
|
|
@@ -336,7 +328,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
336
328
|
// further one, or the registry's own recovery event.
|
|
337
329
|
const bound = await declaredBoundMs(fired.json);
|
|
338
330
|
const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
|
|
339
|
-
const w = await
|
|
331
|
+
const w = await watch(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
|
|
340
332
|
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"');
|
|
341
333
|
expect(
|
|
342
334
|
w.completedUnresumed,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* @see spec/v2/core/versioning.md §1.4
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
19
20
|
import { describe, it, expect } from 'vitest';
|
|
20
21
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
21
22
|
import { v2Discovery } from '../lib/v2.js';
|
|
@@ -38,15 +39,39 @@ describe('v2 run-snapshot-etag (runs.md §Snapshot)', () => {
|
|
|
38
39
|
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
39
40
|
if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''} — create refused`.trim());
|
|
40
41
|
const path = `/runs/${encodeURIComponent(runId)}`;
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
// The tag MUST be taken from a representation that has stopped moving. Until
|
|
43
|
+
// 2.33.2 this slept a fixed 1 s and hoped. A host whose ETag tracks the run's
|
|
44
|
+
// event-log sequence (RFC 0115 — the strong tag the SHOULD asks for) is still
|
|
45
|
+
// appending while a noop executes, so between the two GETs the representation
|
|
46
|
+
// CHANGES and `200` with a new tag is the only correct answer; a `304` there
|
|
47
|
+
// would be a stale-cache bug. Measured on a tier-2 production host, 4 of 4:
|
|
48
|
+
// immediately after create etag1 != etag2 and the conditional GET answers 200;
|
|
49
|
+
// after the run settles the same request answers 304 every time. The row had
|
|
50
|
+
// passed three earlier cuts of that host — a timing lottery, won when the run
|
|
51
|
+
// happened to finish inside the sleep. It also let a host with a CONSTANT tag
|
|
52
|
+
// pass for the wrong reason, which the control below still catches.
|
|
53
|
+
await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(30_000) });
|
|
54
|
+
let etag: string | null = null;
|
|
55
|
+
let hit: OpenWOPResponse | null = null;
|
|
56
|
+
// A terminal run should be still, but "should" is not the claim under test:
|
|
57
|
+
// if the tag moves again, take the NEW tag and retry. The violation is a 200
|
|
58
|
+
// whose ETag EQUALS the If-None-Match that was sent — never a 200 per se.
|
|
59
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
60
|
+
const first = await http(() => driver.get(path));
|
|
61
|
+
if (first === null || first.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${first?.status ?? 'no response'}`);
|
|
62
|
+
etag = first.headers.get('etag');
|
|
63
|
+
if (!etag) return softSkip('inapplicable', 'the snapshot carries no ETag — runs.md §Snapshot makes the ETag a SHOULD; the 304 rule applies only when it is present');
|
|
64
|
+
const sent = etag;
|
|
65
|
+
hit = await http(() => driver.get(path, { headers: { 'If-None-Match': sent } }));
|
|
66
|
+
if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
|
|
67
|
+
if (hit.status !== 200 || hit.headers.get('etag') === sent) break; // 304, or the real violation: decided below
|
|
68
|
+
await new Promise((r) => setTimeout(r, 500)); // 200 with a DIFFERENT tag: the representation moved; the tag sent was honestly stale
|
|
69
|
+
}
|
|
70
|
+
if (hit === null || etag === null) return softSkip('blocked', 'no conditional GET was made');
|
|
71
|
+
if (hit.status === 200 && hit.headers.get('etag') !== etag) {
|
|
72
|
+
return softSkip('blocked', `the snapshot of a TERMINAL run kept changing across four reads (last tag sent ${etag}, answered 200 with ${String(hit.headers.get('etag'))}) — the 304 rule cannot be witnessed against a representation that never holds still`);
|
|
73
|
+
}
|
|
74
|
+
expect(hit.status, req(ID, DOC, `a request whose If-None-Match matches the CURRENT ETag MUST receive 304 — got ${hit.status} while the response still carried the same tag ${etag}`)).toBe(304);
|
|
50
75
|
expect(hit.text.length, req(ID, DOC, `the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
|
|
51
76
|
expect(hit.headers.get('openwop-version'), req(ID, 'spec/v2/core/versioning.md §1.4', 'every response carries OpenWOP-Version, the 304 included')).not.toBeNull();
|
|
52
77
|
const miss = await http(() => driver.get(path, { headers: { 'If-None-Match': '"openwop-conformance-no-such-tag"' } }));
|
|
@@ -115,9 +115,32 @@ async function register(url: string, major: 1 | 2): Promise<string | null> {
|
|
|
115
115
|
}
|
|
116
116
|
expect(reg.status, req(ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
|
|
117
117
|
const id = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
118
|
+
if (typeof id === 'string') registered.push({ id, major });
|
|
118
119
|
return typeof id === 'string' ? id : null;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Every subscription this file registers, unregistered after the leg that made
|
|
124
|
+
* it. Until 2.33.1 NOTHING here was ever unregistered. On loopback that was
|
|
125
|
+
* invisible: each leg's receiver bound its own ephemeral port, so a leftover
|
|
126
|
+
* subscription delivered to a dead address. Behind a public front every leg
|
|
127
|
+
* shares ONE URL on ONE pinned port - so leg 1's still-live MAJOR-2
|
|
128
|
+
* subscription delivered its v2 rendering into leg 2, and the major-1 leg read
|
|
129
|
+
* it and failed a host that had rendered both contracts correctly ("a major-1
|
|
130
|
+
* run.started payload MUST validate against the V1 definition … engineVersion
|
|
131
|
+
* must be string"). Found on the v2 reference host's first relaxation-free cut;
|
|
132
|
+
* reproduced with no ingress at all by pinning OPENWOP_WEBHOOK_RECEIVER_PORT on
|
|
133
|
+
* a loopback run. It also left a live subscription on every host this file ever
|
|
134
|
+
* ran against.
|
|
135
|
+
*/
|
|
136
|
+
const registered: Array<{ id: string; major: 1 | 2 }> = [];
|
|
137
|
+
async function unregisterAll(): Promise<void> {
|
|
138
|
+
for (const r of registered.splice(0)) {
|
|
139
|
+
const path = `${r.major === 2 ? '' : '/v1'}/webhooks/${encodeURIComponent(r.id)}`;
|
|
140
|
+
try { await driver.delete(path, { headers: { 'OpenWOP-Version': r.major === 2 ? '2.0' : '1.0' } }); } catch { /* best effort: the leg's verdict is already recorded */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
async function driveRun(): Promise<string> {
|
|
122
145
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
123
146
|
expect(create.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
@@ -135,10 +158,21 @@ async function waitFor<T>(fn: () => T | undefined, ms: number): Promise<T | unde
|
|
|
135
158
|
* receive the bare opaque id (versioning.md §5), so the match is on the segment
|
|
136
159
|
* both spellings share.
|
|
137
160
|
*/
|
|
138
|
-
|
|
161
|
+
/** The subscription a delivery says it belongs to, from either header family; undefined when it carries neither. */
|
|
162
|
+
function subscriptionOf(d: Delivery): string | undefined {
|
|
163
|
+
const h = d.headers['openwop-webhook-id'] ?? d.headers['x-openwop-webhook-id'];
|
|
164
|
+
return typeof h === 'string' ? h : Array.isArray(h) ? h[0] : undefined;
|
|
165
|
+
}
|
|
166
|
+
const bare = (id: string): string => (id.includes('/') ? id.slice(id.indexOf('/') + 1) : id);
|
|
167
|
+
|
|
168
|
+
function deliveryFor(deliveries: Delivery[], runId: string, webhookId?: string): { event: Record<string, unknown>; envelope: Record<string, unknown> } | undefined {
|
|
139
169
|
const opaque = runId.includes('/') ? runId.slice(runId.indexOf('/') + 1) : runId;
|
|
140
170
|
for (const d of deliveries) {
|
|
141
171
|
if (!d.body.includes(opaque)) continue;
|
|
172
|
+
// Belt and braces beside unregisterAll(): a delivery that NAMES another
|
|
173
|
+
// subscription is not this leg's, whatever URL it arrived on.
|
|
174
|
+
const sub = subscriptionOf(d);
|
|
175
|
+
if (webhookId !== undefined && sub !== undefined && bare(sub) !== bare(webhookId)) continue;
|
|
142
176
|
let parsed: unknown;
|
|
143
177
|
try { parsed = JSON.parse(d.body); } catch { continue; }
|
|
144
178
|
if (parsed === null || typeof parsed !== 'object') continue;
|
|
@@ -181,15 +215,16 @@ const V1 = 'https://openwop.dev/spec/v1/';
|
|
|
181
215
|
|
|
182
216
|
describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
|
|
183
217
|
let active: Server | null = null;
|
|
184
|
-
afterEach(async () => { const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
|
|
218
|
+
afterEach(async () => { await unregisterAll(); const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
|
|
185
219
|
|
|
186
220
|
it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
|
|
187
221
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
188
222
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
189
223
|
const receiver = await startReceiver(); active = receiver.server;
|
|
190
|
-
|
|
224
|
+
const webhookId = await register(receiver.url, 2);
|
|
225
|
+
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
191
226
|
const runId = await driveRun();
|
|
192
|
-
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
227
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
193
228
|
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
|
|
194
229
|
const v2 = validators(2);
|
|
195
230
|
const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
|
|
@@ -211,9 +246,10 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
211
246
|
const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
|
|
212
247
|
if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
|
|
213
248
|
const receiver = await startReceiver(); active = receiver.server;
|
|
214
|
-
|
|
249
|
+
const webhookId = await register(receiver.url, 1);
|
|
250
|
+
if (webhookId === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
|
|
215
251
|
const runId = await driveRun();
|
|
216
|
-
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
252
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
217
253
|
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
|
|
218
254
|
// The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
|
|
219
255
|
// §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
|
|
@@ -234,11 +270,12 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
234
270
|
const gate = era2Gate(disc);
|
|
235
271
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
236
272
|
const receiver = await startReceiver(); active = receiver.server;
|
|
237
|
-
|
|
273
|
+
const webhookId = await register(receiver.url, 2);
|
|
274
|
+
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
238
275
|
const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
|
|
239
276
|
if (!log.ok) return softSkip(log.kind, log.reason);
|
|
240
277
|
const runId = log.runId;
|
|
241
|
-
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
278
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
242
279
|
// The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
|
|
243
280
|
// reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
|
|
244
281
|
// era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.
|